tuile 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/README.md +51 -24
  4. data/book/01-first-app.md +186 -0
  5. data/book/02-repaint.md +177 -0
  6. data/book/03-layout.md +379 -0
  7. data/book/04-event-loop.md +209 -0
  8. data/book/05-focus.md +188 -0
  9. data/book/06-theming.md +250 -0
  10. data/book/07-components.md +231 -0
  11. data/book/08-testing.md +186 -0
  12. data/book/README.md +79 -0
  13. data/examples/hello_world.rb +1 -2
  14. data/examples/sampler.rb +109 -0
  15. data/lib/tuile/ansi.rb +16 -0
  16. data/lib/tuile/buffer.rb +481 -0
  17. data/lib/tuile/component/button.rb +3 -14
  18. data/lib/tuile/component/has_content.rb +0 -6
  19. data/lib/tuile/component/info_window.rb +4 -2
  20. data/lib/tuile/component/label.rb +15 -23
  21. data/lib/tuile/component/layout.rb +0 -21
  22. data/lib/tuile/component/list.rb +10 -37
  23. data/lib/tuile/component/log_window.rb +6 -5
  24. data/lib/tuile/component/picker_window.rb +4 -2
  25. data/lib/tuile/component/popup.rb +85 -55
  26. data/lib/tuile/component/text_area.rb +1 -1
  27. data/lib/tuile/component/text_field.rb +1 -1
  28. data/lib/tuile/component/text_input.rb +25 -9
  29. data/lib/tuile/component/text_view.rb +6 -30
  30. data/lib/tuile/component/window.rb +77 -112
  31. data/lib/tuile/component.rb +16 -71
  32. data/lib/tuile/event_queue.rb +31 -10
  33. data/lib/tuile/fake_event_queue.rb +21 -5
  34. data/lib/tuile/fake_screen.rb +14 -1
  35. data/lib/tuile/fraction.rb +46 -0
  36. data/lib/tuile/screen.rb +98 -105
  37. data/lib/tuile/screen_pane.rb +82 -19
  38. data/lib/tuile/styled_string.rb +40 -30
  39. data/lib/tuile/version.rb +1 -1
  40. data/sig/tuile.rbs +653 -282
  41. metadata +13 -3
  42. data/lib/tuile/sizing.rb +0 -59
data/sig/tuile.rbs CHANGED
@@ -23,6 +23,8 @@ module Tuile
23
23
  # same form.
24
24
  module Ansi
25
25
  RESET: String
26
+ SYNC_BEGIN: String
27
+ SYNC_END: String
26
28
  end
27
29
 
28
30
  # Constants for keys returned by {.getkey} and helpers for reading them from
@@ -526,6 +528,344 @@ module Tuile
526
528
  attr_reader custom: ::Hash[Symbol, Color]
527
529
  end
528
530
 
531
+ # An in-memory grid of styled cells mirroring the terminal screen. This is
532
+ # the back buffer behind flicker-free rendering: components paint into it
533
+ # (via {#set_line} / {#set_char} / {#fill}) instead of writing escape
534
+ # sequences straight to the terminal, and {#flush} emits the minimal escape
535
+ # string needed to bring a terminal — one that already matches the buffer's
536
+ # state as of the previous flush — up to date. Only cells that actually
537
+ # changed are emitted, so nothing flickers regardless of terminal/multiplexer
538
+ # synchronized-output support.
539
+ #
540
+ # Coordinates are 0-based `(x, y)` = `(column, row)`, matching
541
+ # {Component#rect} and `TTY::Cursor.move_to`.
542
+ #
543
+ # ## Dirty tracking
544
+ #
545
+ # Every mutator compares the incoming grapheme+style against what's already
546
+ # there and records the cell dirty only when it differs — so both mutation
547
+ # and {#flush} cost scale with what actually changed, never with the buffer
548
+ # size. There is deliberately no per-frame whole-buffer clear or copy;
549
+ # un-touched cells retain the previous frame's value.
550
+ #
551
+ # The bookkeeping avoids hashing and full-grid scans: a dirty flag **on each
552
+ # cell** (O(1) set, no `Set` bucket math, no separate array), a per-row
553
+ # boolean so {#flush} scans only the rows that changed, and one global flag
554
+ # so {#dirty?} and the "nothing changed" early-out are O(1). {#flush} clears
555
+ # every flag it consumes.
556
+ #
557
+ # Cells are **mutable and pre-allocated**: the grid builds its {Cell}s once
558
+ # (at construction and {#resize}) and rewrites them in place, so a normal
559
+ # paint allocates nothing per cell. That is why {Cell} is a plain mutable
560
+ # object rather than a frozen value type. The empty state of a cell is a
561
+ # space in the default style.
562
+ #
563
+ # ## Wide characters
564
+ #
565
+ # A 2-column glyph (fullwidth CJK, most emoji) occupies its origin cell plus a
566
+ # **continuation** cell to its right (an empty-grapheme {Cell} the flush emits
567
+ # nothing for, since the glyph itself advances the cursor two columns).
568
+ # Overwriting either half of a wide glyph blanks the orphaned half, so the
569
+ # grid never holds a dangling continuation or a headless one.
570
+ #
571
+ # ## Future direction
572
+ #
573
+ # Components paint through this drawing surface ({#set_line} / {#set_char})
574
+ # without knowing whether it is the one global buffer or a private one — that
575
+ # indirection is deliberate, so per-component back buffers plus a z-order
576
+ # compositor could drop in without touching component code. It is not worth
577
+ # doing yet: the diff already drops unchanged cells from the wire, and an
578
+ # occluded component that didn't change is never repainted at all, so a
579
+ # compositor would only save residual `repaint` CPU. It pays off in exactly
580
+ # one regime — high repeat-rate scroll (held arrow / mouse wheel) of a large
581
+ # component on a large screen, where re-rendering the content each repeat is
582
+ # the dominant cost.
583
+ class Buffer
584
+ DEFAULT_STYLE: StyledString::Style
585
+ WIDTH_CACHE: ::Hash[String, Integer]
586
+
587
+ # Memoized {Unicode::DisplayWidth.of}. Use this for every paint-path width
588
+ # lookup instead of calling the gem directly.
589
+ #
590
+ # _@param_ `grapheme` — one grapheme cluster.
591
+ #
592
+ # _@return_ — its display width in columns (0 for combining marks).
593
+ def self.display_width: (String grapheme) -> Integer
594
+
595
+ # _@param_ `size` — grid dimensions in columns × rows.
596
+ def initialize: (Size size) -> void
597
+
598
+ # _@return_ — grid dimensions.
599
+ def size: () -> Size
600
+
601
+ # _@param_ `x` — column.
602
+ #
603
+ # _@param_ `y` — row.
604
+ #
605
+ # _@return_ — the live cell at `(x, y)` (do not mutate — paint via
606
+ # {#set_char} / {#set_line} so dirty tracking stays correct), or nil when
607
+ # out of bounds.
608
+ def cell: (Integer x, Integer y) -> Cell?
609
+
610
+ # _@return_ — true if any cell has changed since the last {#flush}.
611
+ def dirty?: () -> bool
612
+
613
+ # Writes one grapheme cluster at `(x, y)`. A 2-column glyph also writes a
614
+ # continuation cell at `(x + 1, y)`; a wide glyph that would overflow the
615
+ # last column is replaced by a blank (terminals can't render a half-clipped
616
+ # wide glyph). Zero-width input (a lone combining mark) is ignored — it has
617
+ # no cell of its own. Out-of-bounds writes are dropped.
618
+ #
619
+ # _@param_ `x` — column.
620
+ #
621
+ # _@param_ `y` — row.
622
+ #
623
+ # _@param_ `grapheme` — one grapheme cluster.
624
+ #
625
+ # _@param_ `style`
626
+ def set_char: (
627
+ Integer x,
628
+ Integer y,
629
+ String grapheme,
630
+ ?StyledString::Style style
631
+ ) -> void
632
+
633
+ # Writes a {StyledString} starting at `(x, y)`, advancing by each grapheme's
634
+ # display width and clipping at the right edge. The workhorse that replaces
635
+ # the old `screen.print(TTY::Cursor.move_to(x, y), styled.to_ansi)` per-row
636
+ # paint. Newlines in the string are not handled — pass one physical line.
637
+ #
638
+ # _@param_ `x` — starting column.
639
+ #
640
+ # _@param_ `y` — row.
641
+ #
642
+ # _@param_ `styled`
643
+ def set_line: (Integer x, Integer y, StyledString styled) -> void
644
+
645
+ # Fills the intersection of `rect` and the buffer with blank cells in
646
+ # `style` — the cell-grid equivalent of clearing a background. Only `bg`
647
+ # shows; the grapheme is a space.
648
+ #
649
+ # _@param_ `rect`
650
+ #
651
+ # _@param_ `style`
652
+ def fill: (Rect rect, ?StyledString::Style style) -> void
653
+
654
+ # Blanks the entire buffer in `style`. A flat pass over every cell — no
655
+ # rect math or nested loops, since it covers the whole grid. Only cells
656
+ # that actually change are marked dirty (and their rows), so a {#flush}
657
+ # after clearing an already-blank buffer emits nothing.
658
+ #
659
+ # _@param_ `style`
660
+ def clear: (?StyledString::Style style) -> void
661
+
662
+ # Marks every cell dirty, so the next {#flush} re-emits the whole grid.
663
+ # Used after a resize and whenever the terminal contents become unknown
664
+ # (e.g. the screen was cleared underneath us).
665
+ def mark_all_dirty: () -> void
666
+
667
+ # Resizes the grid to `size`, reallocating blank cells and marking the
668
+ # whole buffer dirty — after a resize the terminal contents are undefined,
669
+ # so the next flush redraws from scratch.
670
+ #
671
+ # _@param_ `size`
672
+ def resize: (Size size) -> void
673
+
674
+ # Emits the minimal escape sequence that updates a terminal — already
675
+ # matching this buffer as of the previous flush — to the current contents,
676
+ # then clears the dirty flags. Returns `""` when nothing changed.
677
+ #
678
+ # Scans only dirty rows; within a row, consecutive dirty cells form one run
679
+ # (one `TTY::Cursor.move_to` followed by their graphemes), with a running
680
+ # {StyledString::Style#sgr_to} diff so only changed attributes are sent
681
+ # (continuation cells emit nothing). The sequence always ends in the default
682
+ # style ({Ansi::RESET} when needed), the invariant the next flush relies on:
683
+ # the terminal's SGR state is default at flush boundaries.
684
+ #
685
+ # _@return_ — the escape sequence to write to the terminal.
686
+ def flush: () -> String
687
+
688
+ # _@param_ `y` — row.
689
+ #
690
+ # _@return_ — the plain text of row `y` (continuation cells contribute
691
+ # nothing, so wide glyphs read as their single cluster). Intended for
692
+ # tests; see {FakeScreen}.
693
+ def row_text: (Integer y) -> String
694
+
695
+ # _@param_ `y` — row.
696
+ #
697
+ # _@return_ — row `y` rendered to ANSI across its full width — the
698
+ # minimal-SGR encoding of its cells, equivalent to what a component's
699
+ # `set_line` of the whole row would have printed. Intended for tests that
700
+ # assert on styled output (see {FakeScreen}); empty for an out-of-range row.
701
+ def row_ansi: (Integer y) -> String
702
+
703
+ # _@param_ `rect`
704
+ #
705
+ # _@return_ — the plain text of each row within `rect`'s column
706
+ # range, top to bottom. The region equivalent of {#row_text}, for asserting
707
+ # what a component painted into its own rect. Intended for tests.
708
+ def region_text: (Rect rect) -> ::Array[String]
709
+
710
+ # _@param_ `rect`
711
+ #
712
+ # _@return_ — each row within `rect` rendered to ANSI, top to
713
+ # bottom — byte-identical to what a component's per-row `set_line` over
714
+ # that rect emitted. The region equivalent of {#row_ansi}. Intended for
715
+ # tests asserting styled output.
716
+ def region_ansi: (Rect rect) -> ::Array[String]
717
+
718
+ # Core of {#set_char} with the grapheme's display width already known.
719
+ # {#set_line} computes each width once while advancing the column and passes
720
+ # it straight through, so the paint hot path measures every grapheme exactly
721
+ # once (and that once is a {.display_width} memo read). See {#set_char} for
722
+ # the wide-glyph / clipping / out-of-bounds contract.
723
+ #
724
+ # _@param_ `x` — column.
725
+ #
726
+ # _@param_ `y` — row.
727
+ #
728
+ # _@param_ `grapheme` — one grapheme cluster.
729
+ #
730
+ # _@param_ `w` — `grapheme`'s display width (0, 1, or 2).
731
+ #
732
+ # _@param_ `style`
733
+ def put_char: (
734
+ Integer x,
735
+ Integer y,
736
+ String grapheme,
737
+ Integer w,
738
+ StyledString::Style style
739
+ ) -> void
740
+
741
+ # (Re)allocates a blank grid of `size` with clean dirty state. Callers
742
+ # follow with {#mark_all_dirty} when the terminal doesn't match the new
743
+ # grid — construction and {#resize} both do.
744
+ #
745
+ # _@param_ `size`
746
+ def allocate_grid: (Size size) -> void
747
+
748
+ # Emits the dirty cells of row `y` into `out`, breaking a run at each clean
749
+ # cell, and returns the running style at the end of the row.
750
+ #
751
+ # _@param_ `out` — accumulator.
752
+ #
753
+ # _@param_ `y`
754
+ #
755
+ # _@param_ `style` — style the terminal currently holds.
756
+ def flush_row: (String _out, Integer y, StyledString::Style style) -> StyledString::Style
757
+
758
+ # _@param_ `rect`
759
+ #
760
+ # _@return_ — cells within `rect`, row-major, clamped to
761
+ # the grid (out-of-bounds positions yield a blank cell).
762
+ def region_cells: (Rect rect) -> ::Array[::Array[Cell]]
763
+
764
+ # _@param_ `x` — column
765
+ #
766
+ # _@param_ `y` — row
767
+ #
768
+ # _@return_ — flat-array index for `(x, y)`.
769
+ def index: (Integer x, Integer y) -> Integer
770
+
771
+ # _@param_ `x` — column
772
+ #
773
+ # _@param_ `y` — row
774
+ #
775
+ # _@return_ — true when `(x, y)` falls within the grid.
776
+ def in_bounds?: (Integer x, Integer y) -> bool
777
+
778
+ # Rewrites the cell at `(x, y)` in place, marking it (and its row) dirty
779
+ # only when grapheme or style actually changes. Caller guarantees `(x, y)`
780
+ # is in bounds.
781
+ #
782
+ # _@param_ `x` — column
783
+ #
784
+ # _@param_ `y` — row
785
+ #
786
+ # _@param_ `grapheme` — the new grapheme cluster
787
+ #
788
+ # _@param_ `style` — the new style
789
+ def write_cell: (
790
+ Integer x,
791
+ Integer y,
792
+ String grapheme,
793
+ StyledString::Style style
794
+ ) -> void
795
+
796
+ # If `(x, y)` holds the right half (continuation) of a wide glyph, blanks the
797
+ # orphaned left half at `x - 1`. Called before a write lands on `x`, so the
798
+ # wide glyph to the left isn't left headless.
799
+ #
800
+ # _@param_ `x` — column
801
+ #
802
+ # _@param_ `y` — row
803
+ def blank_left_partner: (Integer x, Integer y) -> void
804
+
805
+ # If the cell just right of `(x, y)` is a continuation (the right half of a
806
+ # wide glyph whose origin is `(x, y)`), blanks it. Called before a write
807
+ # lands on `x`, so overwriting a wide origin doesn't strand its continuation.
808
+ # A continuation can only ever belong to the wide glyph immediately to its
809
+ # left, so the empty-grapheme test is exact — and cheaper than re-measuring
810
+ # the origin's width.
811
+ #
812
+ # _@param_ `x` — column
813
+ #
814
+ # _@param_ `y` — row
815
+ def blank_right_partner: (Integer x, Integer y) -> void
816
+
817
+ attr_reader width: Integer
818
+
819
+ attr_reader height: Integer
820
+
821
+ # One screen cell: a single grapheme cluster, the {StyledString::Style} it's
822
+ # drawn in, and a dirty flag. Mutable by design (see {Buffer} "Dirty
823
+ # tracking") — the grid rewrites cells in place. A continuation cell (right
824
+ # half of a wide glyph) carries an empty grapheme — see {#continuation?}.
825
+ class Cell
826
+ # _@param_ `grapheme`
827
+ #
828
+ # _@param_ `style`
829
+ def initialize: (String grapheme, StyledString::Style style) -> void
830
+
831
+ # _@return_ — true if this is the right half of a wide glyph, which
832
+ # {Buffer#flush} skips (the glyph to the left already moved the cursor
833
+ # past it).
834
+ def continuation?: () -> bool
835
+
836
+ # Sets the cell's content, flipping {#dirty} on when grapheme or style
837
+ # actually changes (an already-dirty cell stays dirty). Returns the
838
+ # resulting dirty flag, so callers can aggregate row/buffer dirty state in
839
+ # one step. The single mutation path behind {Buffer#set_char} / {#fill} /
840
+ # {#clear}.
841
+ #
842
+ # _@param_ `grapheme`
843
+ #
844
+ # _@param_ `style`
845
+ #
846
+ # _@return_ — {#dirty} after the write.
847
+ def set: (String grapheme, StyledString::Style style) -> bool
848
+
849
+ # Content equality (grapheme + style); the dirty flag is bookkeeping and
850
+ # is deliberately excluded.
851
+ #
852
+ # _@param_ `other`
853
+ def ==: (Object other) -> bool
854
+
855
+ # Read-only: mutate content through {#set} so dirty tracking stays correct.
856
+ #
857
+ # _@return_ — one grapheme cluster, `" "` for blank, or `""` for a
858
+ # wide-glyph continuation.
859
+ attr_reader grapheme: String
860
+
861
+ attr_reader style: StyledString::Style
862
+
863
+ # _@return_ — true if this cell changed since the last {Buffer#flush}.
864
+ # {Buffer} flips it (off as it flushes, on via {Buffer#mark_all_dirty}).
865
+ attr_accessor dirty: bool
866
+ end
867
+ end
868
+
529
869
  # The TTY screen. There is exactly one screen per app.
530
870
  #
531
871
  # A screen runs the event loop; call {#run_event_loop} to do that.
@@ -698,23 +1038,12 @@ module Tuile
698
1038
 
699
1039
  def self.close: () -> void
700
1040
 
701
- # Prints given strings. While {#repaint} is running, writes are
702
- # accumulated into a frame buffer and flushed to the terminal as a
703
- # single `$stdout.write` at the end of the cycle. This stops the
704
- # emulator from rendering half-finished frames (e.g. a layout's
705
- # clear-background pass before its children have re-painted), which
706
- # was visible as a brief flicker when the auto-clear path triggers.
707
- #
708
- # Outside repaint, writes go straight to stdout. We deliberately
709
- # don't raise on a "print outside repaint" — that would be a useful
710
- # guardrail against components painting outside the repaint loop,
711
- # but it'd force terminal-housekeeping writes (`Screen#clear`,
712
- # mouse-tracking start/stop, cursor-show on teardown) to bypass
713
- # this method entirely and write directly to `$stdout`. {FakeScreen}
714
- # overrides `print` to capture every byte into its `@prints` array,
715
- # and tests that exercise `run_event_loop` against a real {Screen}
716
- # would otherwise leak escape sequences to the test runner's stdout.
717
- # Keeping `print` as the single sink preserves that override seam.
1041
+ # Writes terminal-housekeeping escapes straight to stdout: {#clear},
1042
+ # mouse-tracking start/stop, the color-scheme notify toggles, cursor-show
1043
+ # on teardown. Component painting does *not* go through here anymore — it
1044
+ # writes into {#buffer}, which {#repaint} diffs and {#emit}s. {FakeScreen}
1045
+ # overrides this (and {#emit}) to capture into `@prints` instead of the
1046
+ # test runner's stdout.
718
1047
  #
719
1048
  # _@param_ `args` — stuff to print.
720
1049
  def print: (*String args) -> void
@@ -757,14 +1086,16 @@ module Tuile
757
1086
  # _@return_ — true if focus moved.
758
1087
  def cycle_focus: (forward: bool) -> bool
759
1088
 
760
- # Collects a component and all its descendants in tree order
761
- # (parent before children).
762
- #
763
- # _@param_ `component`
764
- def collect_subtree: (Component component) -> ::Array[Component]
1089
+ # The escape sequence positioning the hardware cursor for the current focus
1090
+ # state: hidden when nothing owns it, else moved to the focused component's
1091
+ # {Component#cursor_position} and shown. Appended to each frame's flush.
1092
+ def cursor_sequence: () -> String
765
1093
 
766
- # Hides or moves the hardware cursor based on the current focus state.
767
- def position_cursor: () -> void
1094
+ # Writes an assembled frame (escape string) to the terminal. The single
1095
+ # sink for repaint output; {FakeScreen} overrides it to capture instead.
1096
+ #
1097
+ # _@param_ `str`
1098
+ def emit: (String str) -> void
768
1099
 
769
1100
  # Recalculates positions of all windows, and repaints the scene.
770
1101
  # Automatically called whenever terminal size changes. Call when the app
@@ -781,10 +1112,12 @@ module Tuile
781
1112
  # doesn't trap them.
782
1113
  # 2. App-level shortcuts from {#register_global_shortcut}. An entry
783
1114
  # registered with `over_popups: true` always fires; one with the
784
- # default `over_popups: false` fires only when no popup is open
785
- # (otherwise the popup receives the key normally).
786
- # 3. {ScreenPane#handle_key}, which routes to the topmost popup or
787
- # tiled content.
1115
+ # default `over_popups: false` fires only when no modal popup is open
1116
+ # (otherwise the modal popup receives the key normally). A non-modal
1117
+ # overlay doesn't suppress global shortcuts.
1118
+ # 3. {ScreenPane#handle_key}, which captures a matching {#key_shortcut}
1119
+ # in the active scope, then delivers the key to {#focused} and bubbles
1120
+ # it up the focus chain.
788
1121
  #
789
1122
  # _@param_ `key`
790
1123
  #
@@ -801,6 +1134,13 @@ module Tuile
801
1134
  # _@return_ — the structural root of the component tree.
802
1135
  attr_reader pane: ScreenPane
803
1136
 
1137
+ # _@return_ — `:light` or `:dark`
1138
+ attr_reader color_scheme: Symbol
1139
+
1140
+ # _@return_ — the back buffer components paint into
1141
+ # ({Buffer#set_line} / {Buffer#fill} / {Buffer#set_char}).
1142
+ attr_reader buffer: Buffer
1143
+
804
1144
  # Handler invoked when a {StandardError} escapes an event handler inside
805
1145
  # the event loop (e.g. a {Component::TextField}'s `on_change` raises).
806
1146
  #
@@ -862,50 +1202,39 @@ module Tuile
862
1202
  end
863
1203
  end
864
1204
 
865
- # A sizing policy for a slot whose position is managed by a parent
866
- # component (e.g. {Component::Window#footer}). Resolves one dimension at a
867
- # time via {#resolve}, so the same value works for widths and heights.
1205
+ # A width/height ratio, each a float in `0.0..1.0` the single relational
1206
+ # sizing primitive in Tuile. It exists for exactly one job: sizing a
1207
+ # {Component::Popup} against the screen. A popup has no siblings competing for
1208
+ # space and no rectangle in a tiled layout, so "half the screen, centered" is
1209
+ # the sensible default, and that wants a ratio rather than a hard-coded cell
1210
+ # count that would be wrong on the next terminal size.
868
1211
  #
869
- # Three policies exist:
1212
+ # Tiled components are *not* sized this way: their parent computes explicit
1213
+ # integer rects in its own `rect=` and hands them down. `Fraction` is
1214
+ # deliberately scoped to {Component::Popup#size=} and is not a general layout
1215
+ # primitive.
870
1216
  #
871
- # - {FILL} take everything the slot offers;
872
- # - {WRAP_CONTENT} — take the component's natural extent (its
873
- # {Component#content_size}), clamped to the slot;
874
- # - {.fixed} — take exactly the given number of cells, clamped to the slot.
1217
+ # Resolve it against a reference {Size} (the screen) to get concrete integer
1218
+ # cells:
875
1219
  #
876
- # Note that {WRAP_CONTENT} only makes sense for components that report a
877
- # natural {Component#content_size} ({Component::Label}, {Component::Button},
878
- # {Component::List}, …). Input components ({Component::TextField} et al.)
879
- # report {Size::ZERO}, so a wrap-content slot collapses to zero width —
880
- # i.e. the component becomes invisible. Use {.fixed} or {FILL} for those.
1220
+ # Fraction::HALF.resolve(Size.new(80, 24)) # => 40x12
881
1221
  #
882
- # @!attribute [r] mode
883
- # @return [Symbol] `:fill`, `:wrap_content` or `:fixed`.
884
- # @!attribute [r] amount
885
- # @return [Integer, nil] the cell count for `:fixed`; `nil` otherwise.
886
- class Sizing
887
- FILL: Sizing
888
- WRAP_CONTENT: Sizing
1222
+ # Integer arguments are coerced to float, so `Fraction.new(1, 1) == FULL`.
1223
+ class Fraction
1224
+ HALF: Fraction
1225
+ FULL: Fraction
889
1226
 
890
- # _@param_ `amount` — the number of cells to occupy; 0 or greater.
1227
+ # _@param_ `width` — fraction of the reference width, `0.0..1.0`.
891
1228
  #
892
- # _@return_a fixed-size policy.
893
- def self.fixed: (Integer amount) -> Sizing
1229
+ # _@param_ `height` fraction of the reference height, `0.0..1.0`.
1230
+ def initialize: (width: Numeric, height: Numeric) -> void
894
1231
 
895
- # Resolves one dimension of a slot.
896
- #
897
- # _@param_ `available` cells the slot offers; 0 or greater.
1232
+ # Resolves this fraction against a reference size, rounding each axis to the
1233
+ # nearest cell and flooring at 1 — so a fraction never yields a zero-size
1234
+ # result on a tiny terminal.
898
1235
  #
899
- # _@param_ `content` — the component's natural extent on this axis (one dimension of its {Component#content_size}).
900
- #
901
- # _@return_ — the resolved extent, always in `0..available`.
902
- def resolve: (Integer available, Integer content) -> Integer
903
-
904
- # _@return_ — `:fill`, `:wrap_content` or `:fixed`.
905
- attr_reader mode: Symbol
906
-
907
- # _@return_ — the cell count for `:fixed`; `nil` otherwise.
908
- attr_reader amount: Integer?
1236
+ # _@param_ `reference` — the size to take a fraction of (usually the screen).
1237
+ def resolve: (Size reference) -> Size
909
1238
  end
910
1239
 
911
1240
  # A UI component which is positioned on the screen and draws characters into
@@ -950,20 +1279,23 @@ module Tuile
950
1279
  # Only called when the component is attached.
951
1280
  def repaint: () -> void
952
1281
 
953
- # Called when a character is pressed on the keyboard.
1282
+ # Called when a character is pressed on the keyboard. The default does
1283
+ # nothing and reports the key as unhandled; input components
1284
+ # ({Component::TextField}, {Component::List}, {Component::Button}, …)
1285
+ # override it to act on keys they care about.
954
1286
  #
955
- # Also called for inactive components. Inactive component should just return
956
- # false.
1287
+ # Dispatch is owned by {ScreenPane#handle_key}: a {#key_shortcut} match
1288
+ # anywhere in the active scope is captured first (suppressed while a
1289
+ # cursor-owner is mid-edit), then the key is delivered to {Screen#focused}
1290
+ # and bubbles up its ancestor chain until some component handles it. A
1291
+ # component therefore only ever receives keys when it is on the focus chain
1292
+ # — or when app code hands it a key directly — so it acts on the key alone
1293
+ # and must never gate on its own {#active?} state.
957
1294
  #
958
- # Default implementation searches for a component with {#key_shortcut} and
959
- # focuses it. The shortcut search is suppressed while the focused component
960
- # owns the hardware cursor (e.g. a {Component::TextField} the user is
961
- # typing into) so that hotkeys don't steal printable keys from the editor.
962
- #
963
- # _@param_ `key` — a key.
1295
+ # _@param_ `_key` a key.
964
1296
  #
965
1297
  # _@return_ — true if the key was handled, false if not.
966
- def handle_key: (String key) -> bool
1298
+ def handle_key: (String _key) -> bool
967
1299
 
968
1300
  # _@param_ `key` — keyboard key to look up.
969
1301
  #
@@ -1042,20 +1374,6 @@ module Tuile
1042
1374
  # _@param_ `child` — the just-detached child.
1043
1375
  def on_child_removed: (Component child) -> void
1044
1376
 
1045
- # Called by a child component whose {#content_size} just changed (fired
1046
- # from the child's {#content_size=}). Does nothing by default — a plain
1047
- # container is not size-coupled to its children. Containers that derive
1048
- # their own natural size or child layout from a child's natural size
1049
- # override this (e.g. {Component::Window} re-lays-out a
1050
- # {Sizing::WRAP_CONTENT} footer and recomputes its own size from content;
1051
- # {Component::Popup} re-self-sizes). If the receiver's own
1052
- # {#content_size} changes as a consequence, its {#content_size=} notifies
1053
- # *its* parent in turn — so the event bubbles exactly as far as geometry
1054
- # keeps changing, and stops where it doesn't.
1055
- #
1056
- # _@param_ `child` — the resized direct child.
1057
- def on_child_content_size_changed: (Component child) -> void
1058
-
1059
1377
  # Where the hardware terminal cursor should sit when this component is the
1060
1378
  # cursor owner. Returns `nil` to indicate the cursor should be hidden. The
1061
1379
  # {Screen} positions the hardware cursor after each repaint cycle by
@@ -1127,15 +1445,6 @@ module Tuile
1127
1445
  # {#on_theme_changed=} listener keeps firing.
1128
1446
  attr_accessor on_theme_changed: Proc?
1129
1447
 
1130
- # The {Size} big enough to show the entire component contents without
1131
- # scrolling. Plain components have no intrinsic content and report
1132
- # {Size::ZERO}; content-bearing components (e.g. {Label}, {List},
1133
- # {TextView}, {Window}) maintain it eagerly via {#content_size=} from
1134
- # their mutators, so reads are O(1). Used by callers like
1135
- # {Component::Popup} to auto-size to whatever content was assigned,
1136
- # regardless of its concrete type, and by {Sizing::WRAP_CONTENT} slots.
1137
- attr_accessor content_size: Size
1138
-
1139
1448
  # A scrollable list of items with cursor support.
1140
1449
  #
1141
1450
  # Items are modeled as {StyledString}s and painted directly into the
@@ -1246,19 +1555,6 @@ module Tuile
1246
1555
  # is one, so the list snaps to the bottom on first paint.
1247
1556
  def on_width_changed: () -> void
1248
1557
 
1249
- # Natural size from scratch: longest line's display width plus the two
1250
- # single-space gutters {#pad_to_row} adds, × line count. An empty list
1251
- # is {Size::ZERO} (no gutters for no content).
1252
- def compute_content_size: () -> Size
1253
-
1254
- # Incremental {#content_size} update for appends: folds just the
1255
- # appended lines into the running maximum, keeping {#add_lines}
1256
- # O(appended) instead of re-scanning the whole list (LogWindow appends
1257
- # a line per log statement).
1258
- #
1259
- # _@param_ `appended` — the just-appended lines (already concatenated onto {@lines}).
1260
- def grow_content_size: (::Array[StyledString] appended) -> void
1261
-
1262
1558
  # Coerces and flattens a list of input entries into trimmed
1263
1559
  # {StyledString} lines. Each entry becomes a {StyledString} (String
1264
1560
  # via {StyledString.parse}, StyledString passed through, anything else
@@ -1385,9 +1681,9 @@ module Tuile
1385
1681
  #
1386
1682
  # _@param_ `scrollbar` — scrollbar instance, or nil if not shown.
1387
1683
  #
1388
- # _@return_ — paintable ANSI-encoded line exactly `rect.width`
1389
- # columns wide; highlighted if cursor is here.
1390
- def paintable_line: (Integer index, Integer row_in_viewport, VerticalScrollBar? scrollbar) -> String
1684
+ # _@return_ — paintable line exactly `rect.width` columns wide;
1685
+ # highlighted if cursor is here.
1686
+ def paintable_line: (Integer index, Integer row_in_viewport, VerticalScrollBar? scrollbar) -> StyledString
1391
1687
 
1392
1688
  # _@return_ — callback fired when an item is chosen — by pressing
1393
1689
  # Enter on the cursor's item, or by left-clicking an item. Called as
@@ -1556,7 +1852,8 @@ module Tuile
1556
1852
  # embedded ANSI is honored) or a {StyledString} directly. {#text}
1557
1853
  # always returns the {StyledString}.
1558
1854
  class Label < Component
1559
- def initialize: () -> void
1855
+ # _@param_ `text` — initial text, coerced the same way {#text=} coerces it (a `String` is parsed via {StyledString.parse}; `nil` is an empty label). Equivalent to constructing empty and assigning {#text=}.
1856
+ def initialize: (?(String | StyledString)? text) -> void
1560
1857
 
1561
1858
  # Paints the text into {#rect}.
1562
1859
  #
@@ -1568,17 +1865,12 @@ module Tuile
1568
1865
 
1569
1866
  def on_width_changed: () -> void
1570
1867
 
1571
- # Natural size: longest hard-line's display width × number of hard
1572
- # lines. Computed on the *unclipped* text — sizing is intrinsic to the
1573
- # content, not the viewport. Empty text yields {Size::ZERO}.
1574
- def compute_content_size: () -> Size
1575
-
1576
1868
  # Recomputes {@clipped_lines} for the current text and rect width.
1577
- # Each line is ellipsized to fit, padded with trailing spaces out to
1578
- # the full width, and pre-rendered to ANSI so {#repaint} is just a
1579
- # lookup + screen.print per row. {@blank_line} covers rows past the
1580
- # last text line. When {#bg} is set, every produced line (and the
1581
- # blank row) has the bg applied uniformly.
1869
+ # Each line is ellipsized to fit and padded with trailing spaces out to
1870
+ # the full width, so {#repaint} is just a lookup + {Buffer#set_line} per
1871
+ # row. {@blank_line} covers rows past the last text line. When {#bg} is
1872
+ # set, every produced line (and the blank row) has the bg applied
1873
+ # uniformly.
1582
1874
  def update_clipped_lines: () -> void
1583
1875
 
1584
1876
  # _@param_ `line`
@@ -1599,10 +1891,32 @@ module Tuile
1599
1891
  attr_accessor bg: (Color | Symbol | Integer | ::Array[Integer])?
1600
1892
  end
1601
1893
 
1602
- # A modal overlay that wraps any {Component} as its content. Popup itself
1603
- # paints nothing — it's a transparent host that handles modality
1604
- # ({#open} / {#close} / {#open?}, ESC/q to close), centers itself on the
1605
- # screen, and auto-sizes to the wrapped content.
1894
+ # An overlay that wraps any {Component} as its content. Popup itself
1895
+ # paints nothing — it's a transparent host that handles its lifecycle
1896
+ # ({#open} / {#close} / {#open?}, ESC/q to close) and holds a top-down
1897
+ # {#size} the {Screen} applies.
1898
+ #
1899
+ # The popup does *not* size itself to its content. Its box is declared by
1900
+ # {#size} — a {Fraction} (resolved against the screen every layout pass, so
1901
+ # it tracks resize) or an absolute {Size} (clamped to the screen). The
1902
+ # default is {Fraction::HALF}: half the screen, centered. The wrapped
1903
+ # content then fills that box and handles its own overflow by wrapping and
1904
+ # scrolling, so use content that can — a {Component::TextView} or
1905
+ # {Component::TextArea} — for anything longer than fits. A
1906
+ # {Component::Label} only truncates.
1907
+ #
1908
+ # Modal by default: it centers on the screen, grabs focus, eats keys, and
1909
+ # blocks clicks beneath it. Pass `modal: false` for a non-modal overlay
1910
+ # that floats above the content (still painted on top) without taking focus
1911
+ # or capturing input — the caller positions it (via {#rect=}) and drives it
1912
+ # from app code. That is the building block for an autocomplete/slash-command
1913
+ # list anchored to a {Component::TextField} or {Component::TextArea} caret:
1914
+ # typing keeps focus (and the cursor) in the input, an
1915
+ # {Component::TextInput#on_change} listener refills the list, and an
1916
+ # {Component::TextInput#on_key} interceptor forwards Up/Down/Enter to it.
1917
+ # Such a caller owns the list data, so it sizes the overlay itself
1918
+ # (`overlay.size = Size.new(longest, [items.size, 8].min)`) — still
1919
+ # caller-decides, top-down.
1606
1920
  #
1607
1921
  # The wrapped content fills the popup's full {#rect}; if you want a frame
1608
1922
  # and caption, wrap a {Component::Window} (or any subclass — including
@@ -1622,8 +1936,15 @@ module Tuile
1622
1936
  class Popup < Component
1623
1937
  include Tuile::Component::HasContent
1624
1938
 
1625
- # _@param_ `content` — initial content; can be set later via {#content=}. When provided here, the popup auto-sizes to fit.
1626
- def initialize: (?content: Component?) -> void
1939
+ # _@param_ `content` — initial content; can be set later via {#content=}. The content fills the popup's {#rect}; it does not determine the popup's size.
1940
+ #
1941
+ # _@param_ `modal` — true (default) for a centered, focus-grabbing, input-capturing modal; false for a non-modal overlay the caller positions and drives (see the class docs).
1942
+ #
1943
+ # _@param_ `size` — the popup's size, applied top-down. A {Fraction} is resolved against the screen each layout pass; a {Size} is clamped to the screen. Defaults to {Fraction::HALF}.
1944
+ def initialize: (?content: Component?, ?modal: bool, ?size: (Size | Fraction)) -> void
1945
+
1946
+ # _@return_ — whether this popup is modal. See {#initialize}.
1947
+ def modal?: () -> bool
1627
1948
 
1628
1949
  def focusable?: () -> bool
1629
1950
 
@@ -1638,17 +1959,20 @@ module Tuile
1638
1959
  # _@param_ `new_rect`
1639
1960
  def rect=: (Rect new_rect) -> void
1640
1961
 
1641
- # Mounts this popup on the {Screen}. Recomputes the popup's size from
1642
- # the current content first, so reopening a popup whose content has
1643
- # grown or shrunk while closed picks up the new size.
1962
+ # Mounts this popup on the {Screen}, re-resolving its {#size} against the
1963
+ # current screen first.
1644
1964
  def open: () -> void
1645
1965
 
1646
1966
  # Constructs and opens a popup in one call.
1647
1967
  #
1648
1968
  # _@param_ `content`
1649
1969
  #
1970
+ # _@param_ `modal` — see {#initialize}.
1971
+ #
1972
+ # _@param_ `size` — see {#initialize}.
1973
+ #
1650
1974
  # _@return_ — the opened popup.
1651
- def self.open: (?content: Component?) -> Popup
1975
+ def self.open: (?content: Component?, ?modal: bool, ?size: (Size | Fraction)) -> Popup
1652
1976
 
1653
1977
  # Removes this popup from the {Screen}. No-op if not currently open.
1654
1978
  def close: () -> void
@@ -1656,31 +1980,29 @@ module Tuile
1656
1980
  # _@return_ — true if this popup is currently mounted on the screen.
1657
1981
  def open?: () -> bool
1658
1982
 
1659
- # Recenters the popup on the screen, preserving its current width/height.
1660
- # Called automatically by the screen's layout pass and by {#content=}
1661
- # when the popup is open.
1662
- def center: () -> void
1663
-
1664
- # _@return_ — max height the popup will grow to fit its content,
1665
- # defaults to 12. Override in a subclass to allow taller popups.
1666
- def max_height: () -> Integer
1667
-
1668
- # Sets the popup's content and auto-sizes the popup to fit.
1983
+ # Re-resolves {#size} against the current screen and repositions the popup
1984
+ # *itself* (this is not laying out content — the popup's own rect): a
1985
+ # modal popup recenters; a non-modal overlay keeps its caller-assigned
1986
+ # top-left (only its size follows the screen). Called on {#open}, on
1987
+ # {#size=}, and by the screen's layout pass (so a {Fraction} size tracks
1988
+ # SIGWINCH).
1669
1989
  #
1670
- # _@param_ `new_content`
1671
- def content=: (Component? new_content) -> void
1990
+ # The final rect is computed and assigned in one step rather than sizing
1991
+ # at the origin and then centering: the intermediate origin rect rarely
1992
+ # covers the previous one, which would make {#rect=}'s shrink/move
1993
+ # detection fire a full repaint on every resize.
1994
+ def reposition: () -> void
1672
1995
 
1673
- # Re-sizes (and recenters, when open) whenever the wrapped content's
1674
- # natural size changes — e.g. a {Label}'s `text=`, a {List}'s
1675
- # `add_line`, or a nested {Window} whose own content grew (the window
1676
- # recomputes its {Component#content_size} and the change bubbles here).
1677
- #
1678
- # _@param_ `_child`
1679
- def on_child_content_size_changed: (Component _child) -> void
1996
+ # Recenters the popup on the screen, preserving its current width/height.
1997
+ def center: () -> void
1680
1998
 
1681
1999
  # Hint for the status bar: own "q Close" plus the wrapped content's hint.
1682
2000
  def keyboard_hint: () -> String
1683
2001
 
2002
+ # `q` and ESC close the popup. The popup sits on the focus chain of
2003
+ # whatever it wraps, so the key reaches here by bubbling up from the
2004
+ # focused content after that content declined to handle it.
2005
+ #
1684
2006
  # _@param_ `key`
1685
2007
  #
1686
2008
  # _@return_ — true if the key was handled.
@@ -1691,21 +2013,15 @@ module Tuile
1691
2013
  # _@param_ `content`
1692
2014
  def layout: (Component content) -> void
1693
2015
 
1694
- # Recompute width/height from {#content}'s natural size and recenter
1695
- # if currently open. Called whenever content is (re)assigned.
1696
- #
1697
- # Computes the final (centered) rect and assigns it in one step rather
1698
- # than positioning at the origin and then centering: the intermediate
1699
- # origin rect rarely covers the previous one, which would make
1700
- # {#rect=}'s shrink/move detection fire a full repaint on every resize.
1701
- def update_rect: () -> void
1702
-
1703
2016
  # _@param_ `event`
1704
2017
  def handle_mouse: (MouseEvent event) -> void
1705
2018
 
1706
2019
  def children: () -> ::Array[Component]
1707
2020
 
1708
2021
  def on_focus: () -> void
2022
+
2023
+ # _@return_ — the popup's declared size. See {#size=}.
2024
+ attr_accessor size: (Size | Fraction)
1709
2025
  end
1710
2026
 
1711
2027
  # A clickable button. Activated by Enter, Space, or a left mouse click;
@@ -1718,7 +2034,7 @@ module Tuile
1718
2034
  # {Component#handle_mouse}.
1719
2035
  #
1720
2036
  # Assign a {#rect} (typically by the surrounding {Layout}) wide enough to
1721
- # show `[ caption ]`; {#content_size} reports that natural width.
2037
+ # show `[ caption ]` that natural width is `caption.length + 4`.
1722
2038
  class Button < Component
1723
2039
  # _@param_ `caption` — the button's label.
1724
2040
  def initialize: (?String caption) -> void
@@ -1735,9 +2051,6 @@ module Tuile
1735
2051
 
1736
2052
  def repaint: () -> void
1737
2053
 
1738
- # Natural width is `caption.length + 4` to fit `[ caption ]`; height 1.
1739
- def natural_size: () -> Size
1740
-
1741
2054
  # _@return_ — the button's label.
1742
2055
  attr_accessor caption: String
1743
2056
 
@@ -1779,20 +2092,11 @@ module Tuile
1779
2092
  # _@param_ `child`
1780
2093
  def remove: (Component child) -> void
1781
2094
 
1782
- def content_size: () -> Size
1783
-
1784
2095
  # Dispatches the event to the child under the mouse cursor.
1785
2096
  #
1786
2097
  # _@param_ `event`
1787
2098
  def handle_mouse: (MouseEvent event) -> void
1788
2099
 
1789
- # Called when a character is pressed on the keyboard.
1790
- #
1791
- # _@param_ `key` — a key.
1792
- #
1793
- # _@return_ — true if the key was handled, false if not.
1794
- def handle_key: (String key) -> bool
1795
-
1796
2100
  def on_focus: () -> void
1797
2101
 
1798
2102
  # Absolute layout. Extend this class, register any children, and
@@ -1820,9 +2124,6 @@ module Tuile
1820
2124
 
1821
2125
  def children: () -> ::Array[Component]
1822
2126
 
1823
- # _@param_ `key`
1824
- def handle_key: (String key) -> bool
1825
-
1826
2127
  # _@param_ `event`
1827
2128
  def handle_mouse: (MouseEvent event) -> void
1828
2129
 
@@ -1832,22 +2133,6 @@ module Tuile
1832
2133
  # _@param_ `value`
1833
2134
  def scrollbar=: (bool value) -> void
1834
2135
 
1835
- # Sets the new content. Also recomputes the window's natural size.
1836
- #
1837
- # _@param_ `new_content`
1838
- def content=: (Component? new_content) -> void
1839
-
1840
- # Re-lays-out a {Sizing::WRAP_CONTENT} footer when the footer's natural
1841
- # size changes, and folds a content resize into the window's own
1842
- # natural size (whose change then bubbles to the window's parent — e.g.
1843
- # a {Popup} re-self-sizes). The footer deliberately does *not*
1844
- # participate in the window's {#content_size}: it is decoration
1845
- # overlaying the border, and must not drive the window's size — if it
1846
- # doesn't fit, it is clipped to the inner width.
1847
- #
1848
- # _@param_ `child`
1849
- def on_child_content_size_changed: (Component child) -> void
1850
-
1851
2136
  # Fully repaints the window: both frame and contents.
1852
2137
  #
1853
2138
  # Window deliberately paints over its entire rect (border around the
@@ -1866,43 +2151,41 @@ module Tuile
1866
2151
  # _@param_ `content`
1867
2152
  def layout: (Component content) -> void
1868
2153
 
1869
- # Paints the window border.
2154
+ # Paints the window border into the {Screen#buffer}. Title is clipped to
2155
+ # the inner width so the box never overflows {#rect}; when the window is
2156
+ # active the whole border is drawn in {Theme#active_border_color}.
1870
2157
  def repaint_border: () -> void
1871
2158
 
2159
+ # Builds the bottom border line. The corners take the border color; the
2160
+ # interior is plain dashes when a {#footer} component occupies the row
2161
+ # (it overpaints them) or when there's no chrome, otherwise it carries
2162
+ # {#footer_text} embedded at its own width — keeping the text's own
2163
+ # styling — with dashes filling the remainder up to the inner width.
2164
+ #
2165
+ # _@param_ `inner_w` — the border's interior width.
2166
+ #
2167
+ # _@param_ `fg` — the active-border color, or nil when inactive.
2168
+ def bottom_border: (Integer inner_w, Color? fg) -> StyledString
2169
+
1872
2170
  # The caption text as it appears in the rendered border, including the
1873
2171
  # shortcut prefix when {#key_shortcut} is set.
1874
2172
  def frame_caption: () -> String
1875
2173
 
1876
- # Builds the border as a single string with embedded cursor-positioning
1877
- # escapes, mirroring the layout {TTY::Box.frame} used to produce. Title
1878
- # is clipped to fit the inner width so the box never overflows {#rect}.
1879
- #
1880
- # _@param_ `caption`
1881
- def build_frame: (String caption) -> String
1882
-
1883
- # Recomputes the window's natural size: content's natural size (or the
1884
- # caption, whichever is wider) plus the 2-character border. The footer
1885
- # is deliberately excluded — see {#on_child_content_size_changed}. A
1886
- # window with no content or caption sizes to `Size.new(2, 2)` (bare
1887
- # border).
1888
- def update_content_size: () -> void
1889
-
1890
- # Positions the footer over the bottom border row, with its width
1891
- # resolved by {#footer_sizing} against the inner width. A
1892
- # {Sizing::WRAP_CONTENT} footer with zero natural width gets an empty
1893
- # rect — i.e. it is invisible, as if never assigned.
2174
+ # Positions the footer over the bottom border row, spanning the full
2175
+ # inner width (the only dimension a bottom-row widget needs — the window
2176
+ # already knows it).
1894
2177
  def layout_footer: () -> void
1895
2178
 
1896
2179
  def on_focus: () -> void
1897
2180
 
1898
- # _@return_ — optional component overlaying the bottom border
1899
- # row.
2181
+ # _@return_ — optional focusable component occupying the
2182
+ # bottom border row, always spanning the full inner width.
1900
2183
  attr_accessor footer: Component?
1901
2184
 
1902
- # _@return_ — how the footer's width is computed from the window's
1903
- # inner width; defaults to {Sizing::FILL} (the footer spans the full
1904
- # inner width). The footer's height is always 1 (the border row).
1905
- attr_accessor footer_sizing: Sizing
2185
+ # _@return_ — optional chrome embedded into the bottom border
2186
+ # line, mirroring {#caption} on the top line. Empty by default; hidden
2187
+ # whenever a {#footer} component is present.
2188
+ attr_accessor footer_text: (StyledString | String)?
1906
2189
 
1907
2190
  # _@return_ — the current caption, empty by default.
1908
2191
  attr_accessor caption: String
@@ -2351,9 +2634,6 @@ module Tuile
2351
2634
  # reader when the cache is cold. Cost is O(total spans).
2352
2635
  def build_text: () -> StyledString
2353
2636
 
2354
- # _@return_ — {#content_size} computed from {@hard_lines}.
2355
- def compute_content_size: () -> Size
2356
-
2357
2637
  # _@return_ — column width available for wrapped text — viewport
2358
2638
  # width minus the scrollbar gutter (when visible). `0` when {#rect}'s
2359
2639
  # width is non-positive, which yields a degenerate "no wrap" result.
@@ -2394,11 +2674,10 @@ module Tuile
2394
2674
  #
2395
2675
  # _@param_ `scrollbar`
2396
2676
  #
2397
- # _@return_ — paintable ANSI-encoded line exactly `rect.width`
2398
- # columns wide. Body lines come pre-padded from {#rewrap}, so this
2399
- # reduces to a memoized {StyledString#to_ansi} read plus an
2400
- # ASCII-string concat of the scrollbar glyph when one is present.
2401
- def paintable_line: (Integer index, Integer row_in_viewport, VerticalScrollBar? scrollbar) -> String
2677
+ # _@return_ — paintable line exactly `rect.width` columns wide.
2678
+ # Body lines come pre-padded from {#rewrap}, so this reduces to a lookup
2679
+ # plus a concat of the scrollbar glyph when one is present.
2680
+ def paintable_line: (Integer index, Integer row_in_viewport, VerticalScrollBar? scrollbar) -> StyledString
2402
2681
 
2403
2682
  # _@return_ — index of the first visible physical line.
2404
2683
  attr_accessor top_line: Integer
@@ -2413,13 +2692,6 @@ module Tuile
2413
2692
  # bottom and tailing resumes. Default `false`.
2414
2693
  attr_accessor auto_scroll: bool
2415
2694
 
2416
- # _@return_ — longest hard-line's display width × number of hard
2417
- # lines. Reported on the *unwrapped* text — wrap-aware sizing would
2418
- # be circular (width depends on width). Empty text returns
2419
- # `Size.new(0, 0)`. Maintained incrementally by {#text=} and
2420
- # {#append}, so reads are O(1).
2421
- attr_reader content_size: Size
2422
-
2423
2695
  # A logical section of a {TextView}'s text — a contiguous run of
2424
2696
  # hard lines the app wants to address as a unit (e.g. an LLM's
2425
2697
  # "thinking" output vs. its assistant message). The view always
@@ -2682,22 +2954,24 @@ module Tuile
2682
2954
 
2683
2955
  def tab_stop?: () -> bool
2684
2956
 
2685
- # Handles a key. Returns false when the component is inactive. Otherwise
2686
- # first runs the {Component#handle_key} shortcut search via `super`, then
2687
- # delegates to {#handle_text_input_key}.
2957
+ # Handles a key. An {#on_key} interceptor (if set) gets first refusal —
2958
+ # a truthy return consumes the key otherwise it delegates to
2959
+ # {#handle_text_input_key}. Dispatch ({ScreenPane#handle_key}) only routes
2960
+ # keys here when this input is on the focus chain, so there is no
2961
+ # {#active?} gate.
2688
2962
  #
2689
2963
  # _@param_ `key`
2690
2964
  def handle_key: (String key) -> bool
2691
2965
 
2692
2966
  # Renders `text` on the field's background well, looked up from the
2693
- # current {Screen#theme} at paint time: {Theme#active_bg} when this
2694
- # input is on the active (focus) chain, {Theme#input_bg} otherwise —
2967
+ # current {Screen#theme} at paint time: {Theme#active_bg_color} when this
2968
+ # input is on the active (focus) chain, {Theme#input_bg_color} otherwise —
2695
2969
  # visibly a field either way, distinctly highlighted when active.
2696
2970
  #
2697
2971
  # _@param_ `text`
2698
2972
  #
2699
- # _@return_ — ANSI-rendered text.
2700
- def background: (String text) -> String
2973
+ # _@return_ — text on the field's background well.
2974
+ def background: (String text) -> StyledString
2701
2975
 
2702
2976
  # Input filter for {#text=}. Subclasses override to truncate or reject
2703
2977
  # invalid input. Default coerces to String.
@@ -2760,6 +3034,21 @@ module Tuile
2760
3034
  # _@return_ — one-arg callable, or nil.
2761
3035
  attr_accessor on_change: (Proc | Method)?
2762
3036
 
3037
+ # Optional interceptor consulted before the input's own key handling.
3038
+ # Receives the pressed key; return a truthy value to consume it (the
3039
+ # input then ignores that key), falsy to let normal editing proceed.
3040
+ #
3041
+ # The keyboard analog of {#on_change}: it lets app code layer behavior
3042
+ # onto an input without subclassing. The motivating case is an
3043
+ # autocomplete / slash-command overlay (a non-modal {Component::Popup}):
3044
+ # while it is open the interceptor claims Up/Down/Enter/ESC and forwards
3045
+ # them to the overlay's list, but lets ordinary characters fall through
3046
+ # so typing keeps editing the field (and {#on_change} keeps refilling the
3047
+ # list).
3048
+ #
3049
+ # _@return_ — one-arg callable, or nil.
3050
+ attr_accessor on_key: (Proc | Method)?
3051
+
2763
3052
  # Callback fired when ESC is pressed. Defaults to a closure that clears
2764
3053
  # focus (`screen.focused = nil`) so ESC visibly cancels text entry instead
2765
3054
  # of bubbling to the parent — and, in particular, instead of reaching the
@@ -2774,11 +3063,6 @@ module Tuile
2774
3063
  # provide a protected `layout(content)` method which repositions the
2775
3064
  # content component; the mixin manages `@content` itself.
2776
3065
  module HasContent
2777
- # _@param_ `key` — a key.
2778
- #
2779
- # _@return_ — true if the key was handled, false if not.
2780
- def handle_key: (String key) -> bool
2781
-
2782
3066
  # _@param_ `event`
2783
3067
  def handle_mouse: (MouseEvent event) -> void
2784
3068
 
@@ -2810,8 +3094,10 @@ module Tuile
2810
3094
  #
2811
3095
  # _@param_ `lines` — the content, may contain formatting.
2812
3096
  #
3097
+ # _@param_ `size` — the popup's size, applied top-down; the list wraps and scrolls within it. Defaults to {Fraction::HALF}.
3098
+ #
2813
3099
  # _@return_ — the opened popup.
2814
- def self.open: (String caption, ::Array[String] lines) -> Popup
3100
+ def self.open: (String caption, ::Array[String] lines, ?size: (Size | Fraction)) -> Popup
2815
3101
  end
2816
3102
 
2817
3103
  # A {Window} that lists options identified by single keyboard keys, asks
@@ -2828,6 +3114,11 @@ module Tuile
2828
3114
  # _@param_ `options` — pairs of keyboard key and option caption. No Rainbow formatting must be used.
2829
3115
  def initialize: (String caption, ::Array[[String, String]] options) ?{ (String key) -> void } -> void
2830
3116
 
3117
+ # Handles an option-key press. Reached by bubbling: the inner {List}
3118
+ # (the focused component) sees the key first and handles cursor/Enter
3119
+ # picks; anything it declines bubbles up here, where a key matching an
3120
+ # option's `key` picks that option.
3121
+ #
2831
3122
  # _@param_ `key`
2832
3123
  def handle_key: (String key) -> bool
2833
3124
 
@@ -2959,10 +3250,13 @@ module Tuile
2959
3250
  # Awaits until the event queue is empty (all events have been processed).
2960
3251
  def await_empty: () -> void
2961
3252
 
2962
- # Schedules `block` to fire on the event-loop thread roughly `fps` times
2963
- # per second, passing a 0-based monotonically increasing tick counter. Use
2964
- # it for animations (e.g. a `/-\|` spinner in a {Component::Label}) or
2965
- # periodic UI refresh from a background task.
3253
+ # Schedules `block` to fire on the event-loop thread every `seconds`,
3254
+ # passing a 0-based monotonically increasing tick counter. The interval is
3255
+ # in **seconds** the conventional scheduling unit (`sleep`,
3256
+ # `Concurrent::TimerTask#execution_interval`, …) so `tick(0.2)` fires five
3257
+ # times a second. Use it for periodic UI refresh from a background task
3258
+ # (poll a status, redraw a clock). For animation, where frames-per-second
3259
+ # is the natural unit, {#tick_fps} reads better.
2966
3260
  #
2967
3261
  # The returned {Ticker} controls the schedule — call {Ticker#cancel} to
2968
3262
  # stop it.
@@ -2976,8 +3270,15 @@ module Tuile
2976
3270
  # ({Concurrent}.global_timer_set) — adding more tickers does not add more
2977
3271
  # threads, just more work on the shared scheduler.
2978
3272
  #
2979
- # _@param_ `fps` — firings per second, must be positive. Fractional values are fine (`fps: 0.5` ⇒ one tick every two seconds).
2980
- def tick: (Numeric fps) ?{ (Integer tick) -> void } -> Ticker
3273
+ # _@param_ `seconds` — interval between firings, must be positive. Fractional values are fine (`tick(0.05)` ⇒ ~20 firings a second).
3274
+ def tick: (Numeric seconds) ?{ (Integer tick) -> void } -> Ticker
3275
+
3276
+ # Frames-per-second convenience over {#tick}: `tick_fps(15)` is exactly
3277
+ # `tick(1.0 / 15)`. Reads naturally for animation (a `/-\|` spinner, a
3278
+ # progress pulse) where you think in frames, not intervals.
3279
+ #
3280
+ # _@param_ `fps` — firings per second, must be positive. Fractional values are fine (`tick_fps(0.5)` ⇒ one firing every two seconds).
3281
+ def tick_fps: (Numeric fps) ?{ (Integer tick) -> void } -> Ticker
2981
3282
 
2982
3283
  # Runs the event loop and blocks. Must be run from at most one thread at the
2983
3284
  # same time. Blocks until some thread calls {#stop}. Calls block for all
@@ -3098,10 +3399,10 @@ module Tuile
3098
3399
  class Ticker
3099
3400
  # _@param_ `event_queue` — queue to dispatch tick calls onto.
3100
3401
  #
3101
- # _@param_ `fps` — firings per second (positive).
3402
+ # _@param_ `interval` — seconds between firings (positive).
3102
3403
  #
3103
3404
  # _@param_ `block` — called as `block.call(tick_count)` on each fire.
3104
- def initialize: (EventQueue event_queue, Numeric fps, Proc block) -> void
3405
+ def initialize: (EventQueue event_queue, Numeric interval, Proc block) -> void
3105
3406
 
3106
3407
  # _@return_ — true once {#cancel} has been called.
3107
3408
  def cancelled?: () -> bool
@@ -3148,6 +3449,13 @@ module Tuile
3148
3449
  # _@param_ `args`
3149
3450
  def print: (*String args) -> void
3150
3451
 
3452
+ # Captures the assembled repaint frame instead of writing to the test
3453
+ # runner's TTY. Lands in {#prints} so cursor/sync escapes can be asserted;
3454
+ # painted content is read from {#buffer}.
3455
+ #
3456
+ # _@param_ `str`
3457
+ def emit: (String str) -> void
3458
+
3151
3459
  # _@param_ `component` — the component to check.
3152
3460
  def invalidated?: (Component component) -> bool
3153
3461
 
@@ -3158,7 +3466,10 @@ module Tuile
3158
3466
  # steal its input) and pin the deterministic default.
3159
3467
  def detect_scheme: () -> Symbol
3160
3468
 
3161
- # _@return_ — whatever {#print} printed so far.
3469
+ # _@return_ — whatever {#print} / {#emit} produced so far.
3470
+ # Component painting lands in {#buffer}, not here — assert on
3471
+ # {Buffer#row_text} / {Buffer#row_ansi} / {Buffer#cell} for content, and
3472
+ # on `prints` for cursor and housekeeping escapes.
3162
3473
  attr_reader prints: ::Array[String]
3163
3474
  end
3164
3475
 
@@ -3236,7 +3547,11 @@ module Tuile
3236
3547
  # status bar last.
3237
3548
  def children: () -> ::Array[Component]
3238
3549
 
3239
- # Adds a popup, centers it, focuses it, and invalidates it for repaint.
3550
+ # Adds a popup and invalidates it for repaint. A modal popup is centered
3551
+ # and grabs focus; a non-modal overlay ({Component::Popup#modal?} false) is
3552
+ # left wherever the caller positions it and does *not* take focus, so the
3553
+ # component that was focused keeps the cursor and keeps receiving keys —
3554
+ # the overlay floats above the content, driven from app code.
3240
3555
  #
3241
3556
  # _@param_ `window`
3242
3557
  def add_popup: (Component::Popup window) -> void
@@ -3253,26 +3568,54 @@ module Tuile
3253
3568
  # _@return_ — true if this pane currently hosts the popup.
3254
3569
  def has_popup?: (Component window) -> bool
3255
3570
 
3571
+ # _@return_ — the topmost *modal* popup, or nil when
3572
+ # only non-modal overlays (or no popups) are open. This is the "modal
3573
+ # owner": the popup that scopes key dispatch, blocks mouse clicks, owns
3574
+ # the status bar, and confines Tab cycling. Non-modal overlays are
3575
+ # excluded — they float above the content without capturing input.
3576
+ def modal_popup: () -> Component::Popup?
3577
+
3256
3578
  # Re-lays out children whenever the pane's own rect changes.
3257
3579
  #
3258
3580
  # _@param_ `new_rect`
3259
3581
  def rect=: (Rect new_rect) -> void
3260
3582
 
3261
3583
  # Lays out content (full pane minus the bottom row) and the status bar
3262
- # (bottom row). Popups self-position via {Component::Popup#center}.
3584
+ # (bottom row). Each popup re-resolves its {Component::Popup#size} against
3585
+ # the new screen via {Component::Popup#reposition} — so a {Fraction} size
3586
+ # tracks resize — repositioning itself (modal popups recenter; non-modal
3587
+ # overlays keep the top-left their owner assigned).
3263
3588
  def layout: () -> void
3264
3589
 
3265
3590
  # Pane paints nothing itself; its children paint over the entire rect.
3266
3591
  def repaint: () -> void
3267
3592
 
3268
- # Topmost popup is modal: it eats keys. Falls through to content only
3269
- # when no popup is open.
3593
+ # Dispatches a key in two phases, both scoped to the topmost *modal* popup
3594
+ # (when one is open) or else the tiled {#content}. Non-modal overlays are
3595
+ # never the scope: focus stays in the content beneath them, and the overlay
3596
+ # is driven by app code (which forwards keys to it explicitly), so it
3597
+ # doesn't appear in this path at all.
3598
+ #
3599
+ # 1. *Capture* — a {Component#key_shortcut} match anywhere in the scope
3600
+ # focuses that component and consumes the key. Suppressed while a
3601
+ # cursor-owner ({Screen#cursor_position}) is mid-edit, so typing into a
3602
+ # {Component::TextField} isn't hijacked by a sibling's shortcut.
3603
+ # 2. *Delivery* — the key is handed to {Screen#focused} and bubbles up its
3604
+ # ancestor chain to the scope root; the first component to return true
3605
+ # wins. Focus that is nil or sits outside the scope receives nothing,
3606
+ # which is what keeps an open modal popup modal.
3607
+ #
3608
+ # _@param_ `key`
3609
+ #
3610
+ # _@return_ — true if the key was handled.
3270
3611
  def handle_key: (String key) -> bool
3271
3612
 
3272
3613
  # Mouse events check popups in reverse stacking order (topmost first), and
3273
- # fall through to content only when no popup is hit *and* there are no
3274
- # popups open. This preserves modal click-blocking: an open popup eats
3275
- # clicks even outside its rect.
3614
+ # fall through to content only when no popup is hit *and* no modal popup is
3615
+ # open. This preserves modal click-blocking an open modal eats clicks
3616
+ # even outside its rect — while a non-modal overlay blocks nothing: clicks
3617
+ # inside it route to it (e.g. click-to-select), clicks elsewhere reach the
3618
+ # content beneath.
3276
3619
  #
3277
3620
  # _@param_ `event`
3278
3621
  def handle_mouse: (MouseEvent event) -> void
@@ -3280,7 +3623,7 @@ module Tuile
3280
3623
  # Focus repair when a child detaches. Default {Component#on_child_removed}
3281
3624
  # would refocus to `self` (the pane), which isn't a useful focus target.
3282
3625
  # Instead, route focus to the first interactable widget in the now-topmost
3283
- # popup; falling back to the focus snapshotted when this popup was opened
3626
+ # modal popup; falling back to the focus snapshotted when this popup was opened
3284
3627
  # (if still attached and still focusable); then to the first interactable
3285
3628
  # widget in {#content}; then to {#content} itself; then nil.
3286
3629
  #
@@ -3292,6 +3635,19 @@ module Tuile
3292
3635
  # _@param_ `child`
3293
3636
  def on_child_removed: (Component child) -> void
3294
3637
 
3638
+ # Delivers `key` to {Screen#focused} and bubbles it up the ancestor chain,
3639
+ # stopping at (and including) `scope`. Delivers to no one — returning false
3640
+ # — when focus is nil or sits outside `scope`; the latter is what makes an
3641
+ # open popup modal, since focus is always inside it and content beneath
3642
+ # never receives keys.
3643
+ #
3644
+ # _@param_ `key`
3645
+ #
3646
+ # _@param_ `scope` — the modal scope root (topmost popup or content).
3647
+ #
3648
+ # _@return_ — true if some component on the chain handled the key.
3649
+ def bubble_key: (String key, Component scope) -> bool
3650
+
3295
3651
  # First {Component#tab_stop?} in `root`'s subtree (pre-order), falling
3296
3652
  # back to `root` itself when the subtree has no tab stops. Returns `nil`
3297
3653
  # if `root` is `nil`.
@@ -3302,8 +3658,9 @@ module Tuile
3302
3658
  # _@return_ — the tiled content component.
3303
3659
  attr_accessor content: Component?
3304
3660
 
3305
- # _@return_ — modal popups in stacking order; last is
3306
- # topmost. The array must not be mutated by callers.
3661
+ # _@return_ — overlay popups in stacking order; last is
3662
+ # topmost. Holds both modal popups and non-modal overlays
3663
+ # ({Component::Popup#modal?}). The array must not be mutated by callers.
3307
3664
  attr_reader popups: ::Array[Component]
3308
3665
 
3309
3666
  # _@return_ — the bottom status bar.
@@ -3496,19 +3853,6 @@ module Tuile
3496
3853
  # _@param_ `spans`
3497
3854
  def normalize: (::Array[Span] spans) -> ::Array[Span]
3498
3855
 
3499
- # _@param_ `from`
3500
- #
3501
- # _@param_ `to`
3502
- def sgr_diff: (Style from, Style to) -> String
3503
-
3504
- # _@param_ `color`
3505
- #
3506
- # _@param_ `target` — `:fg` or `:bg`.
3507
- #
3508
- # _@return_ — SGR codes; `[39]` / `[49]` for the "default" reset
3509
- # when `color` is `nil`, otherwise delegated to {Color#sgr_codes}.
3510
- def color_codes: (Color? color, target: Symbol) -> ::Array[Integer]
3511
-
3512
3856
  # _@param_ `start_or_range`
3513
3857
  #
3514
3858
  # _@param_ `len`
@@ -3608,6 +3952,27 @@ module Tuile
3608
3952
  # _@param_ `overrides`
3609
3953
  def merge: (**::Hash[Symbol, Object] overrides) -> Style
3610
3954
 
3955
+ # Minimal SGR escape that transitions a terminal already showing `self`
3956
+ # into `other`: only the attributes that differ are emitted. Returns
3957
+ # `""` when the styles are identical (nothing to do), and {Ansi::RESET}
3958
+ # (`\e[0m`, one code) when `other` is the default style — shorter than
3959
+ # turning each attribute off individually.
3960
+ #
3961
+ # Shared by {StyledString#to_ansi} (diffing span-to-span from the default
3962
+ # style) and {Buffer}'s flush (diffing cell-to-cell against the style the
3963
+ # terminal currently holds), so both emit identical minimal sequences.
3964
+ #
3965
+ # _@param_ `other` — the style to transition to.
3966
+ def sgr_to: (Style other) -> String
3967
+
3968
+ # _@param_ `color`
3969
+ #
3970
+ # _@param_ `target` — either `:fg` or `:bg`.
3971
+ #
3972
+ # _@return_ — SGR codes; `[39]` / `[49]` for the "default"
3973
+ # reset when `color` is `nil`, otherwise delegated to {Color#sgr_codes}.
3974
+ def color_codes: (Color? color, target: Symbol) -> ::Array[Integer]
3975
+
3611
3976
  attr_reader fg: Color?
3612
3977
 
3613
3978
  attr_reader bg: Color?
@@ -3723,12 +4088,18 @@ module Tuile
3723
4088
  def post: (Object event) -> void
3724
4089
 
3725
4090
  # Mirrors {EventQueue#tick} but timeless: returns a {FakeTicker} that
3726
- # only fires when a test calls {#tick_once}. The `fps` argument is
4091
+ # only fires when a test calls {#tick_once}. The `seconds` argument is
3727
4092
  # validated the same way the real queue validates it, then discarded —
3728
4093
  # the fake has no clock, so frame cadence is up to the test.
3729
4094
  #
3730
- # _@param_ `fps` — firings per second, must be positive. Validated for parity with {EventQueue#tick}; otherwise unused.
3731
- def tick: (Numeric fps) ?{ (Integer tick) -> void } -> FakeTicker
4095
+ # _@param_ `seconds` — interval between firings, must be positive. Validated for parity with {EventQueue#tick}; otherwise unused.
4096
+ def tick: (Numeric seconds) ?{ (Integer tick) -> void } -> FakeTicker
4097
+
4098
+ # Mirrors {EventQueue#tick_fps}: validates `fps` for parity, then delegates
4099
+ # to {#tick} (the fake discards the interval regardless).
4100
+ #
4101
+ # _@param_ `fps` — firings per second, must be positive.
4102
+ def tick_fps: (Numeric fps) ?{ (Integer tick) -> void } -> FakeTicker
3732
4103
 
3733
4104
  # Test helper: fires every live ticker's user block once and prunes
3734
4105
  # cancelled tickers. No-op when no tickers are registered. Pumps once