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 ⇒ 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
686 687 688 689 690 |
# File 'lib/clacky/ui2/layout_manager.rb', line 686 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
665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 |
# File 'lib/clacky/ui2/layout_manager.rb', line 665 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 ⇒ Object
384 385 386 387 388 389 390 391 392 393 394 395 396 |
# File 'lib/clacky/ui2/layout_manager.rb', line 384 def cleanup_screen @render_mutex.synchronize do 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" screen.show_cursor screen.flush end end |
#clear_output ⇒ Object
/clear: wipe output area + buffer, keep fixed area.
399 400 401 402 403 404 405 406 407 408 409 410 411 412 |
# File 'lib/clacky/ui2/layout_manager.rb', line 399 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
745 746 747 748 749 750 751 752 753 754 755 756 |
# File 'lib/clacky/ui2/layout_manager.rb', line 745 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
766 767 768 769 770 771 772 773 774 |
# File 'lib/clacky/ui2/layout_manager.rb', line 766 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)
741 742 743 |
# File 'lib/clacky/ui2/layout_manager.rb', line 741 def fullscreen_mode? @fullscreen_mode end |
#hide_todos ⇒ Object
Hide todo area while preserving its data; pair with show_todos.
514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 |
# File 'lib/clacky/ui2/layout_manager.rb', line 514 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
377 378 379 380 381 382 |
# File 'lib/clacky/ui2/layout_manager.rb', line 377 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.
164 165 166 167 |
# File 'lib/clacky/ui2/layout_manager.rb', line 164 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.
477 478 479 480 481 482 483 484 485 486 487 |
# File 'lib/clacky/ui2/layout_manager.rb', line 477 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
724 725 726 727 728 |
# File 'lib/clacky/ui2/layout_manager.rb', line 724 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.
416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 |
# File 'lib/clacky/ui2/layout_manager.rb', line 416 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
758 759 760 761 762 763 764 |
# File 'lib/clacky/ui2/layout_manager.rb', line 758 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.
171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 |
# File 'lib/clacky/ui2/layout_manager.rb', line 171 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).
219 220 221 222 |
# File 'lib/clacky/ui2/layout_manager.rb', line 219 def remove_last_line(line_count = 1, id: nil) target = id || @last_append_id remove_entry(target) if target end |
#render_all ⇒ Object
442 443 444 |
# File 'lib/clacky/ui2/layout_manager.rb', line 442 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.
574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 |
# File 'lib/clacky/ui2/layout_manager.rb', line 574 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
453 454 455 456 457 458 |
# File 'lib/clacky/ui2/layout_manager.rb', line 453 def render_input @render_mutex.synchronize do render_fixed_areas screen.flush end end |
#render_output ⇒ Object
446 447 448 449 450 451 |
# File 'lib/clacky/ui2/layout_manager.rb', line 446 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 |
# 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) # Skip if gone, fully committed, or only partially visible (its # prefix is already in terminal scrollback and cannot be edited). return if entry.nil? || entry.committed return if (entry.committed_line_offset || 0) > 0 old_lines = entry.lines.dup new_lines = wrap_content_to_lines(content) if old_lines == new_lines screen.flush return end @buffer.replace(id, new_lines) 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. is_tail = @buffer.live_entries.last&.id == id 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
460 461 462 463 464 465 466 467 |
# File 'lib/clacky/ui2/layout_manager.rb', line 460 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).
470 471 472 473 474 |
# File 'lib/clacky/ui2/layout_manager.rb', line 470 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
776 777 778 779 780 781 782 |
# File 'lib/clacky/ui2/layout_manager.rb', line 776 def restore_screen @render_mutex.synchronize do screen.clear_screen screen.hide_cursor render_all_internal end end |
#scroll_output_down(_lines = 1) ⇒ Object
614 |
# File 'lib/clacky/ui2/layout_manager.rb', line 614 def scroll_output_down(_lines = 1); end |
#scroll_output_up(_lines = 1) ⇒ Object
Legacy no-ops — terminal handles native scroll natively.
613 |
# File 'lib/clacky/ui2/layout_manager.rb', line 613 def scroll_output_up(_lines = 1); end |
#show_todos ⇒ Object
Show todo area again after a previous hide_todos.
538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 |
# File 'lib/clacky/ui2/layout_manager.rb', line 538 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).
213 214 215 216 |
# File 'lib/clacky/ui2/layout_manager.rb', line 213 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.
490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 |
# File 'lib/clacky/ui2/layout_manager.rb', line 490 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).
624 625 626 627 628 629 630 631 632 633 634 635 636 637 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 |
# File 'lib/clacky/ui2/layout_manager.rb', line 624 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 |