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
602 603 604 605 606 |
# File 'lib/clacky/ui2/layout_manager.rb', line 602 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
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 581 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
348 349 350 351 352 353 354 355 356 357 358 359 360 |
# File 'lib/clacky/ui2/layout_manager.rb', line 348 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.
363 364 365 366 367 368 369 370 371 372 373 374 375 376 |
# File 'lib/clacky/ui2/layout_manager.rb', line 363 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
661 662 663 664 665 666 667 668 669 670 671 672 |
# File 'lib/clacky/ui2/layout_manager.rb', line 661 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
682 683 684 685 686 687 688 689 690 |
# File 'lib/clacky/ui2/layout_manager.rb', line 682 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)
657 658 659 |
# File 'lib/clacky/ui2/layout_manager.rb', line 657 def fullscreen_mode? @fullscreen_mode end |
#initialize_screen ⇒ Object
Lifecycle + layout
341 342 343 344 345 346 |
# File 'lib/clacky/ui2/layout_manager.rb', line 341 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.
136 137 138 139 |
# File 'lib/clacky/ui2/layout_manager.rb', line 136 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.
441 442 443 444 445 446 447 448 449 450 451 |
# File 'lib/clacky/ui2/layout_manager.rb', line 441 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
640 641 642 643 644 |
# File 'lib/clacky/ui2/layout_manager.rb', line 640 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.
380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 |
# File 'lib/clacky/ui2/layout_manager.rb', line 380 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
674 675 676 677 678 679 680 |
# File 'lib/clacky/ui2/layout_manager.rb', line 674 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.
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 171 172 |
# File 'lib/clacky/ui2/layout_manager.rb', line 143 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).
187 188 189 190 |
# File 'lib/clacky/ui2/layout_manager.rb', line 187 def remove_last_line(line_count = 1, id: nil) target = id || @last_append_id remove_entry(target) if target end |
#render_all ⇒ Object
406 407 408 |
# File 'lib/clacky/ui2/layout_manager.rb', line 406 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.
490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 |
# File 'lib/clacky/ui2/layout_manager.rb', line 490 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
417 418 419 420 421 422 |
# File 'lib/clacky/ui2/layout_manager.rb', line 417 def render_input @render_mutex.synchronize do render_fixed_areas screen.flush end end |
#render_output ⇒ Object
410 411 412 413 414 415 |
# File 'lib/clacky/ui2/layout_manager.rb', line 410 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 |
# 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) repaint_entry_in_place(entry, old_lines, new_lines) unless @fullscreen_mode render_fixed_areas screen.flush end end |
#rerender_all ⇒ Object
424 425 426 427 428 429 430 431 |
# File 'lib/clacky/ui2/layout_manager.rb', line 424 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).
434 435 436 437 438 |
# File 'lib/clacky/ui2/layout_manager.rb', line 434 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
692 693 694 695 696 697 698 |
# File 'lib/clacky/ui2/layout_manager.rb', line 692 def restore_screen @render_mutex.synchronize do screen.clear_screen screen.hide_cursor render_all_internal end end |
#scroll_output_down(_lines = 1) ⇒ Object
530 |
# File 'lib/clacky/ui2/layout_manager.rb', line 530 def scroll_output_down(_lines = 1); end |
#scroll_output_up(_lines = 1) ⇒ Object
Legacy no-ops — terminal handles native scroll natively.
529 |
# File 'lib/clacky/ui2/layout_manager.rb', line 529 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).
181 182 183 184 |
# File 'lib/clacky/ui2/layout_manager.rb', line 181 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.
454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 |
# File 'lib/clacky/ui2/layout_manager.rb', line 454 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).
540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 |
# File 'lib/clacky/ui2/layout_manager.rb', line 540 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 |