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) ———————————————————————–.
-
#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.
-
#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
623 624 625 626 627 |
# File 'lib/clacky/ui2/layout_manager.rb', line 623 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
602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 |
# File 'lib/clacky/ui2/layout_manager.rb', line 602 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
369 370 371 372 373 374 375 376 377 378 379 380 381 |
# File 'lib/clacky/ui2/layout_manager.rb', line 369 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.
384 385 386 387 388 389 390 391 392 393 394 395 396 397 |
# File 'lib/clacky/ui2/layout_manager.rb', line 384 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
682 683 684 685 686 687 688 689 690 691 692 693 |
# File 'lib/clacky/ui2/layout_manager.rb', line 682 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
703 704 705 706 707 708 709 710 711 |
# File 'lib/clacky/ui2/layout_manager.rb', line 703 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)
678 679 680 |
# File 'lib/clacky/ui2/layout_manager.rb', line 678 def fullscreen_mode? @fullscreen_mode end |
#initialize_screen ⇒ Object
Lifecycle + layout
362 363 364 365 366 367 |
# File 'lib/clacky/ui2/layout_manager.rb', line 362 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.
157 158 159 160 |
# File 'lib/clacky/ui2/layout_manager.rb', line 157 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.
462 463 464 465 466 467 468 469 470 471 472 |
# File 'lib/clacky/ui2/layout_manager.rb', line 462 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
661 662 663 664 665 |
# File 'lib/clacky/ui2/layout_manager.rb', line 661 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.
401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 |
# File 'lib/clacky/ui2/layout_manager.rb', line 401 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
695 696 697 698 699 700 701 |
# File 'lib/clacky/ui2/layout_manager.rb', line 695 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.
164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 |
# File 'lib/clacky/ui2/layout_manager.rb', line 164 def remove_entry(id) return if id.nil? @render_mutex.synchronize do entry = @buffer.entry_by_id(id) return if entry.nil? || entry.committed 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).
208 209 210 211 |
# File 'lib/clacky/ui2/layout_manager.rb', line 208 def remove_last_line(line_count = 1, id: nil) target = id || @last_append_id remove_entry(target) if target end |
#render_all ⇒ Object
427 428 429 |
# File 'lib/clacky/ui2/layout_manager.rb', line 427 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.
511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 |
# File 'lib/clacky/ui2/layout_manager.rb', line 511 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
438 439 440 441 442 443 |
# File 'lib/clacky/ui2/layout_manager.rb', line 438 def render_input @render_mutex.synchronize do render_fixed_areas screen.flush end end |
#render_output ⇒ Object
431 432 433 434 435 436 |
# File 'lib/clacky/ui2/layout_manager.rb', line 431 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) 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 |
# 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) return if entry.nil? || entry.committed old_lines = entry.lines.dup new_lines = wrap_content_to_lines(content) @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
445 446 447 448 449 450 451 452 |
# File 'lib/clacky/ui2/layout_manager.rb', line 445 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).
455 456 457 458 459 |
# File 'lib/clacky/ui2/layout_manager.rb', line 455 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
713 714 715 716 717 718 719 |
# File 'lib/clacky/ui2/layout_manager.rb', line 713 def restore_screen @render_mutex.synchronize do screen.clear_screen screen.hide_cursor render_all_internal end end |
#scroll_output_down(_lines = 1) ⇒ Object
551 |
# File 'lib/clacky/ui2/layout_manager.rb', line 551 def scroll_output_down(_lines = 1); end |
#scroll_output_up(_lines = 1) ⇒ Object
Legacy no-ops — terminal handles native scroll natively.
550 |
# File 'lib/clacky/ui2/layout_manager.rb', line 550 def scroll_output_up(_lines = 1); 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).
202 203 204 205 |
# File 'lib/clacky/ui2/layout_manager.rb', line 202 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.
475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 |
# File 'lib/clacky/ui2/layout_manager.rb', line 475 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).
561 562 563 564 565 566 567 568 569 570 571 572 573 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 |
# File 'lib/clacky/ui2/layout_manager.rb', line 561 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 |