Class: Clacky::UI2::LayoutManager
- Inherits:
-
Object
- Object
- Clacky::UI2::LayoutManager
- Defined in:
- lib/clacky/ui2/layout_manager.rb
Overview
LayoutManager coordinates the split-screen layout:
[ scrollable output area ]
[ gap / todo / input (fixed) ]
Responsibilities:
-
Own an OutputBuffer (logical source of truth for output content).
-
Translate buffer mutations into screen paints, handling:
-
Native terminal scrolling when output overflows the output area.
-
Committing scrolled lines to the buffer (so they are never repainted from the buffer again — prevents the classic “double render on scroll up” bug).
-
-
Keep the fixed area (gap + todo + input) pinned at the bottom of the screen, repainting it only when it is dirty.
Public API (id-based, preferred):
append(content, kind: :text) -> id # add entry, returns id
replace_entry(id, content) # edit entry's content
remove_entry(id) # drop entry
Legacy API (shims, still used by InlineInput / progress):
append_output(content) -> id # alias for append
update_last_line(content, old_n, id: nil) # uses id if given
remove_last_line(n, id: nil) # uses id if given
Instance Attribute Summary collapse
-
#buffer ⇒ Object
readonly
Returns the value of attribute buffer.
-
#input_area ⇒ Object
readonly
Returns the value of attribute input_area.
-
#screen ⇒ Object
readonly
Returns the value of attribute screen.
-
#todo_area ⇒ Object
readonly
Returns the value of attribute todo_area.
Instance Method Summary collapse
-
#append(content, kind: :text) ⇒ Object
Append an output entry.
-
#append_output(content) ⇒ Object
Legacy: append, return id (callers that ignore it still work).
- #calculate_display_width(text) ⇒ Object
-
#calculate_layout ⇒ Object
———————————————————————– Layout math ———————————————————————–.
- #char_display_width(char) ⇒ Object
- #cleanup_screen(clear_screen: false) ⇒ Object
-
#clear_output ⇒ Object
/clear: wipe output area + buffer, keep fixed area.
- #enter_fullscreen(lines, hint: "Press Ctrl+O to return") ⇒ Object
- #exit_fullscreen ⇒ Object
- #fixed_area_height ⇒ Object
- #fixed_area_start_row ⇒ Object
-
#fullscreen_mode? ⇒ Boolean
———————————————————————– Fullscreen (alternate screen buffer) ———————————————————————–.
-
#hide_todos ⇒ Object
Hide todo area while preserving its data; pair with show_todos.
-
#initialize(input_area:, todo_area: nil) ⇒ LayoutManager
constructor
A new instance of LayoutManager.
-
#initialize_screen ⇒ Object
———————————————————————– Lifecycle + layout ———————————————————————–.
-
#live_entry?(id) ⇒ Boolean
Is this id still a live (not yet committed to scrollback) entry? Cheap probe callers use before deciding between replace vs append.
-
#position_inline_input_cursor(inline_input) ⇒ Object
Position cursor for inline input in output area.
- #process_pending_resize ⇒ Object
-
#recalculate_layout ⇒ Object
Recalculate layout after input height changed.
- #refresh_fullscreen(lines) ⇒ Object
-
#remove_entry(id) ⇒ Object
Remove an entry.
-
#remove_last_line(line_count = 1, id: nil) ⇒ Object
Remove the most recently appended entry (or the given id).
- #render_all ⇒ Object
-
#render_fixed_areas(skip_buffer_rerender: false) ⇒ Object
Repaint gap + todo + input at the bottom of the screen.
- #render_input ⇒ Object
- #render_output ⇒ Object
-
#replace_entry(id, content) ⇒ Object
Replace an existing entry’s content.
- #rerender_all ⇒ Object
-
#restore_cursor_to_input ⇒ Object
Restore cursor to input area (used after dialogs).
- #restore_screen ⇒ Object
- #scroll_output_down(_lines = 1) ⇒ Object
-
#scroll_output_up(_lines = 1) ⇒ Object
Legacy no-ops — terminal handles native scroll natively.
-
#show_todos ⇒ Object
Show todo area again after a previous hide_todos.
-
#update_last_line(content, old_line_count = nil, id: nil) ⇒ Object
Update the most recently appended entry.
-
#update_todos(todos) ⇒ Object
Update todos display; recalculates layout if height changed.
-
#wrap_long_line(line) ⇒ Object
Wrap a long line into multiple lines based on terminal width.
Constructor Details
#initialize(input_area:, todo_area: nil) ⇒ LayoutManager
Returns a new instance of LayoutManager.
35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 |
# File 'lib/clacky/ui2/layout_manager.rb', line 35 def initialize(input_area:, todo_area: nil) @screen = ScreenBuffer.new @input_area = input_area @todo_area = todo_area @buffer = OutputBuffer.new @render_mutex = Mutex.new @output_row = 0 # Next output row to paint into @last_fixed_area_height = 0 @fullscreen_mode = false @resize_pending = false # Tracks the most recent append's id so the legacy # update_last_line / remove_last_line shims still work without the # caller threading an id through. @last_append_id = nil calculate_layout setup_resize_handler end |
Instance Attribute Details
#buffer ⇒ Object (readonly)
Returns the value of attribute buffer.
33 34 35 |
# File 'lib/clacky/ui2/layout_manager.rb', line 33 def buffer @buffer end |
#input_area ⇒ Object (readonly)
Returns the value of attribute input_area.
33 34 35 |
# File 'lib/clacky/ui2/layout_manager.rb', line 33 def input_area @input_area end |
#screen ⇒ Object (readonly)
Returns the value of attribute screen.
33 34 35 |
# File 'lib/clacky/ui2/layout_manager.rb', line 33 def screen @screen end |
#todo_area ⇒ Object (readonly)
Returns the value of attribute todo_area.
33 34 35 |
# File 'lib/clacky/ui2/layout_manager.rb', line 33 def todo_area @todo_area end |
Instance Method Details
#append(content, kind: :text) ⇒ Object
Append an output entry. Returns the entry id so callers can later replace_entry / remove_entry. Multi-line content is wrapped and stored as one logical entry.
92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 |
# File 'lib/clacky/ui2/layout_manager.rb', line 92 def append(content, kind: :text) return nil if content.nil? content = sanitize(content) @render_mutex.synchronize do lines = wrap_content_to_lines(content) id = @buffer.append(lines, kind: kind) @last_append_id = id paint_new_lines(lines) unless @fullscreen_mode render_fixed_areas screen.flush id end end |
#append_output(content) ⇒ Object
Legacy: append, return id (callers that ignore it still work).
109 110 111 |
# File 'lib/clacky/ui2/layout_manager.rb', line 109 def append_output(content) append(content) end |
#calculate_display_width(text) ⇒ Object
700 701 702 703 704 |
# File 'lib/clacky/ui2/layout_manager.rb', line 700 def calculate_display_width(text) width = 0 text.each_char { |c| width += char_display_width(c) } width end |
#calculate_layout ⇒ Object
Layout math
60 61 62 63 64 65 66 67 68 69 70 71 72 73 |
# File 'lib/clacky/ui2/layout_manager.rb', line 60 def calculate_layout todo_height = @todo_area&.height || 0 input_height = @input_area.required_height gap_height = 1 @output_height = screen.height - gap_height - todo_height - input_height @output_height = [1, @output_height].max @gap_row = @output_height @todo_row = @gap_row + gap_height @input_row = @todo_row + todo_height @input_area.row = @input_row end |
#char_display_width(char) ⇒ Object
679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 |
# File 'lib/clacky/ui2/layout_manager.rb', line 679 def char_display_width(char) code = char.ord if (code >= 0x1100 && code <= 0x115F) || (code >= 0x2329 && code <= 0x232A) || (code >= 0x2E80 && code <= 0x303E) || (code >= 0x3040 && code <= 0xA4CF) || (code >= 0xAC00 && code <= 0xD7A3) || (code >= 0xF900 && code <= 0xFAFF) || (code >= 0xFE10 && code <= 0xFE19) || (code >= 0xFE30 && code <= 0xFE6F) || (code >= 0xFF00 && code <= 0xFF60) || (code >= 0xFFE0 && code <= 0xFFE6) || (code >= 0x1F300 && code <= 0x1F9FF) || (code >= 0x20000 && code <= 0x2FFFD) || (code >= 0x30000 && code <= 0x3FFFD) 2 else 1 end end |
#cleanup_screen(clear_screen: false) ⇒ Object
394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 |
# File 'lib/clacky/ui2/layout_manager.rb', line 394 def cleanup_screen(clear_screen: false) @render_mutex.synchronize do if clear_screen screen.clear_screen(mode: :reset) else fixed_start = fixed_area_start_row (fixed_start...screen.height).each do |row| screen.move_cursor(row, 0) screen.clear_line end screen.move_cursor([@output_row, 0].max, 0) print "\r" end screen.show_cursor screen.flush end end |
#clear_output ⇒ Object
/clear: wipe output area + buffer, keep fixed area.
413 414 415 416 417 418 419 420 421 422 423 424 425 426 |
# File 'lib/clacky/ui2/layout_manager.rb', line 413 def clear_output @render_mutex.synchronize do max_row = fixed_area_start_row (0...max_row).each do |row| screen.move_cursor(row, 0) screen.clear_line end @output_row = 0 @last_append_id = nil @buffer.clear render_fixed_areas screen.flush end end |
#enter_fullscreen(lines, hint: "Press Ctrl+O to return") ⇒ Object
759 760 761 762 763 764 765 766 767 768 769 770 |
# File 'lib/clacky/ui2/layout_manager.rb', line 759 def enter_fullscreen(lines, hint: "Press Ctrl+O to return") @render_mutex.synchronize do return if @fullscreen_mode @fullscreen_mode = true @fullscreen_hint = hint # Switch to alternate screen, clear it, position top-left. print "\e[?1049h\e[2J\e[H" $stdout.flush render_fullscreen_content(lines) end end |
#exit_fullscreen ⇒ Object
780 781 782 783 784 785 786 787 788 |
# File 'lib/clacky/ui2/layout_manager.rb', line 780 def exit_fullscreen @render_mutex.synchronize do return unless @fullscreen_mode @fullscreen_mode = false @fullscreen_hint = nil print "\e[?1049l" $stdout.flush end end |
#fixed_area_height ⇒ Object
75 76 77 78 79 |
# File 'lib/clacky/ui2/layout_manager.rb', line 75 def fixed_area_height todo_h = @todo_area&.height || 0 input_h = @input_area.required_height 1 + todo_h + input_h end |
#fixed_area_start_row ⇒ Object
81 82 83 |
# File 'lib/clacky/ui2/layout_manager.rb', line 81 def fixed_area_start_row screen.height - fixed_area_height end |
#fullscreen_mode? ⇒ Boolean
Fullscreen (alternate screen buffer)
755 756 757 |
# File 'lib/clacky/ui2/layout_manager.rb', line 755 def fullscreen_mode? @fullscreen_mode end |
#hide_todos ⇒ Object
Hide todo area while preserving its data; pair with show_todos.
528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 |
# File 'lib/clacky/ui2/layout_manager.rb', line 528 def hide_todos return unless @todo_area @render_mutex.synchronize do old_height = @todo_area.height old_gap_row = @gap_row @todo_area.hide new_height = @todo_area.height if old_height != new_height calculate_layout ([old_gap_row, 0].max...screen.height).each do |row| screen.move_cursor(row, 0) screen.clear_line end end render_fixed_areas screen.flush end end |
#initialize_screen ⇒ Object
Lifecycle + layout
387 388 389 390 391 392 |
# File 'lib/clacky/ui2/layout_manager.rb', line 387 def initialize_screen screen.clear_screen screen.hide_cursor @output_row = 0 render_all end |
#live_entry?(id) ⇒ Boolean
Is this id still a live (not yet committed to scrollback) entry? Cheap probe callers use before deciding between replace vs append.
174 175 176 177 |
# File 'lib/clacky/ui2/layout_manager.rb', line 174 def live_entry?(id) return false if id.nil? @buffer.live?(id) end |
#position_inline_input_cursor(inline_input) ⇒ Object
Position cursor for inline input in output area.
491 492 493 494 495 496 497 498 499 500 501 |
# File 'lib/clacky/ui2/layout_manager.rb', line 491 def position_inline_input_cursor(inline_input) return unless inline_input width = screen.width wrap_row, wrap_col = inline_input.cursor_position_for_display(width) line_count = inline_input.line_count(width) cursor_row = @output_row - line_count + wrap_row cursor_col = wrap_col screen.move_cursor(cursor_row, cursor_col) screen.flush end |
#process_pending_resize ⇒ Object
738 739 740 741 742 |
# File 'lib/clacky/ui2/layout_manager.rb', line 738 def process_pending_resize return unless @resize_pending @resize_pending = false handle_resize_safely end |
#recalculate_layout ⇒ Object
Recalculate layout after input height changed. If the layout moved, clear the old fixed area rows and re-render at the new position.
430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 |
# File 'lib/clacky/ui2/layout_manager.rb', line 430 def recalculate_layout @render_mutex.synchronize do old_gap_row = @gap_row old_input_row = @input_row calculate_layout if @input_row != old_input_row ([old_gap_row, 0].max...screen.height).each do |row| screen.move_cursor(row, 0) screen.clear_line end if input_area.paused? # Input paused (InlineInput active) — fixed area shrank, so the # cleared rows are now part of the output area. Repaint from # buffer to fill them in. render_output_from_buffer else render_fixed_areas end screen.flush end end end |
#refresh_fullscreen(lines) ⇒ Object
772 773 774 775 776 777 778 |
# File 'lib/clacky/ui2/layout_manager.rb', line 772 def refresh_fullscreen(lines) @render_mutex.synchronize do return unless @fullscreen_mode print "\e[2J\e[H" render_fullscreen_content(lines) end end |
#remove_entry(id) ⇒ Object
Remove an entry. If it’s the last live entry, the screen area it occupied is cleared and the output cursor rolls back.
181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 |
# File 'lib/clacky/ui2/layout_manager.rb', line 181 def remove_entry(id) return if id.nil? @render_mutex.synchronize do entry = @buffer.entry_by_id(id) return if entry.nil? || entry.committed # Can't remove an entry whose prefix has already scrolled into # terminal scrollback — those rows are immutable. The visible # suffix will roll off on its own as more output is produced. return if (entry.committed_line_offset || 0) > 0 height = entry.height # Check whether this entry is the tail of live entries. Only tail # removal is cheap — mid-buffer removal would require a full # output repaint. In practice only the progress / inline-input # entries are removed, and they are always the tail. is_tail = @buffer.live_entries.last&.id == id @buffer.remove(id) @last_append_id = nil if @last_append_id == id unless @fullscreen_mode if is_tail clear_tail_rows(height) else # Non-tail removal: rebuild the entire output area from buffer render_output_from_buffer end end render_fixed_areas screen.flush end end |
#remove_last_line(line_count = 1, id: nil) ⇒ Object
Remove the most recently appended entry (or the given id).
229 230 231 232 |
# File 'lib/clacky/ui2/layout_manager.rb', line 229 def remove_last_line(line_count = 1, id: nil) target = id || @last_append_id remove_entry(target) if target end |
#render_all ⇒ Object
456 457 458 |
# File 'lib/clacky/ui2/layout_manager.rb', line 456 def render_all @render_mutex.synchronize { render_all_internal } end |
#render_fixed_areas(skip_buffer_rerender: false) ⇒ Object
Repaint gap + todo + input at the bottom of the screen.
588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 |
# File 'lib/clacky/ui2/layout_manager.rb', line 588 def render_fixed_areas(skip_buffer_rerender: false) # When input is paused (InlineInput active), the "input area" is # rendered inline with output. Nothing to paint down here. return if input_area.paused? return if @fullscreen_mode current_fixed_height = fixed_area_height start_row = fixed_area_start_row gap_row = start_row todo_row = gap_row + 1 # Fixed-area height changed (e.g. multi-line input appeared or # command-suggestions popped) → repaint the output from buffer so # nothing is hidden. if !skip_buffer_rerender && @last_fixed_area_height > 0 && @last_fixed_area_height != current_fixed_height render_output_from_buffer end @last_fixed_area_height = current_fixed_height # gap line screen.move_cursor(gap_row, 0) screen.clear_line # todo @todo_area.render(start_row: todo_row) if @todo_area&.visible? # input (renders its own visual cursor) input_row = todo_row + (@todo_area&.height || 0) input_area.render(start_row: input_row, width: screen.width) end |
#render_input ⇒ Object
467 468 469 470 471 472 |
# File 'lib/clacky/ui2/layout_manager.rb', line 467 def render_input @render_mutex.synchronize do render_fixed_areas screen.flush end end |
#render_output ⇒ Object
460 461 462 463 464 465 |
# File 'lib/clacky/ui2/layout_manager.rb', line 460 def render_output @render_mutex.synchronize do render_fixed_areas screen.flush end end |
#replace_entry(id, content) ⇒ Object
Replace an existing entry’s content. The screen is updated in place if the entry still lives in the output area; otherwise (committed to scrollback, or partially scrolled off) this is a silent no-op.
116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 |
# File 'lib/clacky/ui2/layout_manager.rb', line 116 def replace_entry(id, content) return if id.nil? || content.nil? content = sanitize(content) @render_mutex.synchronize do entry = @buffer.entry_by_id(id) if entry.nil? Clacky::Logger.warn("[ph_debug] replace_entry_nil", id: id, content: content.to_s[0, 120]) return end if entry.committed Clacky::Logger.warn("[ph_debug] replace_entry_committed", id: id, content: content.to_s[0, 120]) return end if (entry.committed_line_offset || 0) > 0 Clacky::Logger.warn("[ph_debug] replace_entry_partial", id: id, offset: entry.committed_line_offset, content: content.to_s[0, 120]) return end old_lines = entry.lines.dup new_lines = wrap_content_to_lines(content) if old_lines == new_lines Clacky::Logger.warn("[ph_debug] replace_entry_same", id: id) screen.flush return end @buffer.replace(id, new_lines) is_tail = @buffer.live_entries.last&.id == id Clacky::Logger.warn("[ph_debug] replace_entry_paint", id: id, is_tail: is_tail, old_n: old_lines.length, new_n: new_lines.length, content: content.to_s[0, 120]) unless @fullscreen_mode # repaint_entry_in_place relies on the entry being the tail of # live entries (it computes the entry's top row from @output_row # and old height). When the entry is NOT the tail — e.g. a # background progress ticker fires after a newer entry was # appended — that assumption silently corrupts the screen: # the new frame gets painted at the tail's row, clobbering the # latest log line, and @output_row is reset to a position that # predates appended-but-still-live entries. On next scroll, # those stale-now-present rows end up in terminal scrollback as # duplicated lines (the user-visible "output repeats" bug). # # For non-tail replaces, fall back to a full rebuild of the # output area from the buffer. Slower, but correct regardless # of where the entry lives. if is_tail repaint_entry_in_place(entry, old_lines, new_lines) else render_output_from_buffer end end render_fixed_areas screen.flush end end |
#rerender_all ⇒ Object
474 475 476 477 478 479 480 481 |
# File 'lib/clacky/ui2/layout_manager.rb', line 474 def rerender_all @render_mutex.synchronize do screen.clear_screen render_output_from_buffer render_fixed_areas screen.flush end end |
#restore_cursor_to_input ⇒ Object
Restore cursor to input area (used after dialogs).
484 485 486 487 488 |
# File 'lib/clacky/ui2/layout_manager.rb', line 484 def restore_cursor_to_input input_row = fixed_area_start_row + 1 + (@todo_area&.height || 0) input_area.position_cursor(input_row) screen.show_cursor end |
#restore_screen ⇒ Object
790 791 792 793 794 795 796 |
# File 'lib/clacky/ui2/layout_manager.rb', line 790 def restore_screen @render_mutex.synchronize do screen.clear_screen screen.hide_cursor render_all_internal end end |
#scroll_output_down(_lines = 1) ⇒ Object
628 |
# File 'lib/clacky/ui2/layout_manager.rb', line 628 def scroll_output_down(_lines = 1); end |
#scroll_output_up(_lines = 1) ⇒ Object
Legacy no-ops — terminal handles native scroll natively.
627 |
# File 'lib/clacky/ui2/layout_manager.rb', line 627 def scroll_output_up(_lines = 1); end |
#show_todos ⇒ Object
Show todo area again after a previous hide_todos.
552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 |
# File 'lib/clacky/ui2/layout_manager.rb', line 552 def show_todos return unless @todo_area @render_mutex.synchronize do old_height = @todo_area.height old_gap_row = @gap_row @todo_area.show new_height = @todo_area.height if old_height != new_height calculate_layout ([old_gap_row, 0].max...screen.height).each do |row| screen.move_cursor(row, 0) screen.clear_line end end render_fixed_areas screen.flush end end |
#update_last_line(content, old_line_count = nil, id: nil) ⇒ Object
Update the most recently appended entry. Prefer passing id:; when omitted the last-append id is used. old_line_count is ignored (buffer knows the true height).
223 224 225 226 |
# File 'lib/clacky/ui2/layout_manager.rb', line 223 def update_last_line(content, old_line_count = nil, id: nil) target = id || @last_append_id replace_entry(target, content) if target end |
#update_todos(todos) ⇒ Object
Update todos display; recalculates layout if height changed.
504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 |
# File 'lib/clacky/ui2/layout_manager.rb', line 504 def update_todos(todos) return unless @todo_area @render_mutex.synchronize do old_height = @todo_area.height old_gap_row = @gap_row @todo_area.update(todos) new_height = @todo_area.height if old_height != new_height calculate_layout ([old_gap_row, 0].max...screen.height).each do |row| screen.move_cursor(row, 0) screen.clear_line end end render_fixed_areas screen.flush end end |
#wrap_long_line(line) ⇒ Object
Wrap a long line into multiple lines based on terminal width. Considers display width of multi-byte characters (e.g., Chinese characters).
638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 |
# File 'lib/clacky/ui2/layout_manager.rb', line 638 def wrap_long_line(line) return [""] if line.nil? || line.empty? max_width = screen.width return [line] if max_width <= 0 # Strip ANSI codes for width calculation visible_line = line.gsub(/\e\[[0-9;]*m/, '') display_width = calculate_display_width(visible_line) return [line] if display_width <= max_width wrapped = [] current_line = "" current_width = 0 ansi_codes = [] segments = line.split(/(\e\[[0-9;]*m)/) segments.each do |segment| if segment =~ /^\e\[[0-9;]*m$/ ansi_codes << segment current_line += segment else segment.each_char do |char| char_width = char_display_width(char) if current_width + char_width > max_width && !current_line.empty? wrapped << current_line current_line = ansi_codes.join current_width = 0 end current_line += char current_width += char_width end end end wrapped << current_line unless current_line.empty? || current_line == ansi_codes.join wrapped.empty? ? [""] : wrapped end |