tuile 0.7.0 → 0.8.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.
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,286 @@ 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. See `ideas/back-buffer.md`.
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
+ class Buffer
571
+ DEFAULT_STYLE: StyledString::Style
572
+
573
+ # _@param_ `size` — grid dimensions in columns × rows.
574
+ def initialize: (Size size) -> void
575
+
576
+ # _@return_ — grid dimensions.
577
+ def size: () -> Size
578
+
579
+ # _@param_ `x` — column.
580
+ #
581
+ # _@param_ `y` — row.
582
+ #
583
+ # _@return_ — the live cell at `(x, y)` (do not mutate — paint via
584
+ # {#set_char} / {#set_line} so dirty tracking stays correct), or nil when
585
+ # out of bounds.
586
+ def cell: (Integer x, Integer y) -> Cell?
587
+
588
+ # _@return_ — true if any cell has changed since the last {#flush}.
589
+ def dirty?: () -> bool
590
+
591
+ # Writes one grapheme cluster at `(x, y)`. A 2-column glyph also writes a
592
+ # continuation cell at `(x + 1, y)`; a wide glyph that would overflow the
593
+ # last column is replaced by a blank (terminals can't render a half-clipped
594
+ # wide glyph). Zero-width input (a lone combining mark) is ignored — it has
595
+ # no cell of its own. Out-of-bounds writes are dropped.
596
+ #
597
+ # _@param_ `x` — column.
598
+ #
599
+ # _@param_ `y` — row.
600
+ #
601
+ # _@param_ `grapheme` — one grapheme cluster.
602
+ #
603
+ # _@param_ `style`
604
+ def set_char: (
605
+ Integer x,
606
+ Integer y,
607
+ String grapheme,
608
+ ?StyledString::Style style
609
+ ) -> void
610
+
611
+ # Writes a {StyledString} starting at `(x, y)`, advancing by each grapheme's
612
+ # display width and clipping at the right edge. The workhorse that replaces
613
+ # the old `screen.print(TTY::Cursor.move_to(x, y), styled.to_ansi)` per-row
614
+ # paint. Newlines in the string are not handled — pass one physical line.
615
+ #
616
+ # _@param_ `x` — starting column.
617
+ #
618
+ # _@param_ `y` — row.
619
+ #
620
+ # _@param_ `styled`
621
+ def set_line: (Integer x, Integer y, StyledString styled) -> void
622
+
623
+ # Fills the intersection of `rect` and the buffer with blank cells in
624
+ # `style` — the cell-grid equivalent of clearing a background. Only `bg`
625
+ # shows; the grapheme is a space.
626
+ #
627
+ # _@param_ `rect`
628
+ #
629
+ # _@param_ `style`
630
+ def fill: (Rect rect, ?StyledString::Style style) -> void
631
+
632
+ # Blanks the entire buffer in `style`. A flat pass over every cell — no
633
+ # rect math or nested loops, since it covers the whole grid. Only cells
634
+ # that actually change are marked dirty (and their rows), so a {#flush}
635
+ # after clearing an already-blank buffer emits nothing.
636
+ #
637
+ # _@param_ `style`
638
+ def clear: (?StyledString::Style style) -> void
639
+
640
+ # Marks every cell dirty, so the next {#flush} re-emits the whole grid.
641
+ # Used after a resize and whenever the terminal contents become unknown
642
+ # (e.g. the screen was cleared underneath us).
643
+ def mark_all_dirty: () -> void
644
+
645
+ # Resizes the grid to `size`, reallocating blank cells and marking the
646
+ # whole buffer dirty — after a resize the terminal contents are undefined,
647
+ # so the next flush redraws from scratch.
648
+ #
649
+ # _@param_ `size`
650
+ def resize: (Size size) -> void
651
+
652
+ # Emits the minimal escape sequence that updates a terminal — already
653
+ # matching this buffer as of the previous flush — to the current contents,
654
+ # then clears the dirty flags. Returns `""` when nothing changed.
655
+ #
656
+ # Scans only dirty rows; within a row, consecutive dirty cells form one run
657
+ # (one `TTY::Cursor.move_to` followed by their graphemes), with a running
658
+ # {StyledString::Style#sgr_to} diff so only changed attributes are sent
659
+ # (continuation cells emit nothing). The sequence always ends in the default
660
+ # style ({Ansi::RESET} when needed), the invariant the next flush relies on:
661
+ # the terminal's SGR state is default at flush boundaries.
662
+ #
663
+ # _@return_ — the escape sequence to write to the terminal.
664
+ def flush: () -> String
665
+
666
+ # _@param_ `y` — row.
667
+ #
668
+ # _@return_ — the plain text of row `y` (continuation cells contribute
669
+ # nothing, so wide glyphs read as their single cluster). Intended for
670
+ # tests; see {FakeScreen}.
671
+ def row_text: (Integer y) -> String
672
+
673
+ # _@param_ `y` — row.
674
+ #
675
+ # _@return_ — row `y` rendered to ANSI across its full width — the
676
+ # minimal-SGR encoding of its cells, equivalent to what a component's
677
+ # `set_line` of the whole row would have printed. Intended for tests that
678
+ # assert on styled output (see {FakeScreen}); empty for an out-of-range row.
679
+ def row_ansi: (Integer y) -> String
680
+
681
+ # _@param_ `rect`
682
+ #
683
+ # _@return_ — the plain text of each row within `rect`'s column
684
+ # range, top to bottom. The region equivalent of {#row_text}, for asserting
685
+ # what a component painted into its own rect. Intended for tests.
686
+ def region_text: (Rect rect) -> ::Array[String]
687
+
688
+ # _@param_ `rect`
689
+ #
690
+ # _@return_ — each row within `rect` rendered to ANSI, top to
691
+ # bottom — byte-identical to what a component's per-row `set_line` over
692
+ # that rect emitted. The region equivalent of {#row_ansi}. Intended for
693
+ # tests asserting styled output.
694
+ def region_ansi: (Rect rect) -> ::Array[String]
695
+
696
+ # (Re)allocates a blank grid of `size` with clean dirty state. Callers
697
+ # follow with {#mark_all_dirty} when the terminal doesn't match the new
698
+ # grid — construction and {#resize} both do.
699
+ #
700
+ # _@param_ `size`
701
+ def allocate_grid: (Size size) -> void
702
+
703
+ # Emits the dirty cells of row `y` into `out`, breaking a run at each clean
704
+ # cell, and returns the running style at the end of the row.
705
+ #
706
+ # _@param_ `out` — accumulator.
707
+ #
708
+ # _@param_ `y`
709
+ #
710
+ # _@param_ `style` — style the terminal currently holds.
711
+ def flush_row: (String _out, Integer y, StyledString::Style style) -> StyledString::Style
712
+
713
+ # _@param_ `rect`
714
+ #
715
+ # _@return_ — cells within `rect`, row-major, clamped to
716
+ # the grid (out-of-bounds positions yield a blank cell).
717
+ def region_cells: (Rect rect) -> ::Array[::Array[Cell]]
718
+
719
+ # _@param_ `x` — column
720
+ #
721
+ # _@param_ `y` — row
722
+ #
723
+ # _@return_ — flat-array index for `(x, y)`.
724
+ def index: (Integer x, Integer y) -> Integer
725
+
726
+ # _@param_ `x` — column
727
+ #
728
+ # _@param_ `y` — row
729
+ #
730
+ # _@return_ — true when `(x, y)` falls within the grid.
731
+ def in_bounds?: (Integer x, Integer y) -> bool
732
+
733
+ # Rewrites the cell at `(x, y)` in place, marking it (and its row) dirty
734
+ # only when grapheme or style actually changes. Caller guarantees `(x, y)`
735
+ # is in bounds.
736
+ #
737
+ # _@param_ `x` — column
738
+ #
739
+ # _@param_ `y` — row
740
+ #
741
+ # _@param_ `grapheme` — the new grapheme cluster
742
+ #
743
+ # _@param_ `style` — the new style
744
+ def write_cell: (
745
+ Integer x,
746
+ Integer y,
747
+ String grapheme,
748
+ StyledString::Style style
749
+ ) -> void
750
+
751
+ # If `(x, y)` is half of a wide glyph, blanks the *other* half, so a write
752
+ # that lands on either half doesn't strand the remaining one.
753
+ #
754
+ # _@param_ `x` — column
755
+ #
756
+ # _@param_ `y` — row
757
+ def repair_orphans: (Integer x, Integer y) -> void
758
+
759
+ attr_reader width: Integer
760
+
761
+ attr_reader height: Integer
762
+
763
+ # One screen cell: a single grapheme cluster, the {StyledString::Style} it's
764
+ # drawn in, and a dirty flag. Mutable by design (see {Buffer} "Dirty
765
+ # tracking") — the grid rewrites cells in place. A continuation cell (right
766
+ # half of a wide glyph) carries an empty grapheme — see {#continuation?}.
767
+ class Cell
768
+ # _@param_ `grapheme`
769
+ #
770
+ # _@param_ `style`
771
+ def initialize: (String grapheme, StyledString::Style style) -> void
772
+
773
+ # _@return_ — true if this is the right half of a wide glyph, which
774
+ # {Buffer#flush} skips (the glyph to the left already moved the cursor
775
+ # past it).
776
+ def continuation?: () -> bool
777
+
778
+ # Sets the cell's content, flipping {#dirty} on when grapheme or style
779
+ # actually changes (an already-dirty cell stays dirty). Returns the
780
+ # resulting dirty flag, so callers can aggregate row/buffer dirty state in
781
+ # one step. The single mutation path behind {Buffer#set_char} / {#fill} /
782
+ # {#clear}.
783
+ #
784
+ # _@param_ `grapheme`
785
+ #
786
+ # _@param_ `style`
787
+ #
788
+ # _@return_ — {#dirty} after the write.
789
+ def set: (String grapheme, StyledString::Style style) -> bool
790
+
791
+ # Content equality (grapheme + style); the dirty flag is bookkeeping and
792
+ # is deliberately excluded.
793
+ #
794
+ # _@param_ `other`
795
+ def ==: (Object other) -> bool
796
+
797
+ # Read-only: mutate content through {#set} so dirty tracking stays correct.
798
+ #
799
+ # _@return_ — one grapheme cluster, `" "` for blank, or `""` for a
800
+ # wide-glyph continuation.
801
+ attr_reader grapheme: String
802
+
803
+ attr_reader style: StyledString::Style
804
+
805
+ # _@return_ — true if this cell changed since the last {Buffer#flush}.
806
+ # {Buffer} flips it (off as it flushes, on via {Buffer#mark_all_dirty}).
807
+ attr_accessor dirty: bool
808
+ end
809
+ end
810
+
529
811
  # The TTY screen. There is exactly one screen per app.
530
812
  #
531
813
  # A screen runs the event loop; call {#run_event_loop} to do that.
@@ -698,23 +980,12 @@ module Tuile
698
980
 
699
981
  def self.close: () -> void
700
982
 
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.
983
+ # Writes terminal-housekeeping escapes straight to stdout: {#clear},
984
+ # mouse-tracking start/stop, the color-scheme notify toggles, cursor-show
985
+ # on teardown. Component painting does *not* go through here anymore — it
986
+ # writes into {#buffer}, which {#repaint} diffs and {#emit}s. {FakeScreen}
987
+ # overrides this (and {#emit}) to capture into `@prints` instead of the
988
+ # test runner's stdout.
718
989
  #
719
990
  # _@param_ `args` — stuff to print.
720
991
  def print: (*String args) -> void
@@ -757,14 +1028,16 @@ module Tuile
757
1028
  # _@return_ — true if focus moved.
758
1029
  def cycle_focus: (forward: bool) -> bool
759
1030
 
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]
1031
+ # The escape sequence positioning the hardware cursor for the current focus
1032
+ # state: hidden when nothing owns it, else moved to the focused component's
1033
+ # {Component#cursor_position} and shown. Appended to each frame's flush.
1034
+ def cursor_sequence: () -> String
765
1035
 
766
- # Hides or moves the hardware cursor based on the current focus state.
767
- def position_cursor: () -> void
1036
+ # Writes an assembled frame (escape string) to the terminal. The single
1037
+ # sink for repaint output; {FakeScreen} overrides it to capture instead.
1038
+ #
1039
+ # _@param_ `str`
1040
+ def emit: (String str) -> void
768
1041
 
769
1042
  # Recalculates positions of all windows, and repaints the scene.
770
1043
  # Automatically called whenever terminal size changes. Call when the app
@@ -781,10 +1054,12 @@ module Tuile
781
1054
  # doesn't trap them.
782
1055
  # 2. App-level shortcuts from {#register_global_shortcut}. An entry
783
1056
  # 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.
1057
+ # default `over_popups: false` fires only when no modal popup is open
1058
+ # (otherwise the modal popup receives the key normally). A non-modal
1059
+ # overlay doesn't suppress global shortcuts.
1060
+ # 3. {ScreenPane#handle_key}, which captures a matching {#key_shortcut}
1061
+ # in the active scope, then delivers the key to {#focused} and bubbles
1062
+ # it up the focus chain.
788
1063
  #
789
1064
  # _@param_ `key`
790
1065
  #
@@ -801,6 +1076,10 @@ module Tuile
801
1076
  # _@return_ — the structural root of the component tree.
802
1077
  attr_reader pane: ScreenPane
803
1078
 
1079
+ # _@return_ — the back buffer components paint into
1080
+ # ({Buffer#set_line} / {Buffer#fill} / {Buffer#set_char}).
1081
+ attr_reader buffer: Buffer
1082
+
804
1083
  # Handler invoked when a {StandardError} escapes an event handler inside
805
1084
  # the event loop (e.g. a {Component::TextField}'s `on_change` raises).
806
1085
  #
@@ -950,20 +1229,23 @@ module Tuile
950
1229
  # Only called when the component is attached.
951
1230
  def repaint: () -> void
952
1231
 
953
- # Called when a character is pressed on the keyboard.
954
- #
955
- # Also called for inactive components. Inactive component should just return
956
- # false.
1232
+ # Called when a character is pressed on the keyboard. The default does
1233
+ # nothing and reports the key as unhandled; input components
1234
+ # ({Component::TextField}, {Component::List}, {Component::Button}, …)
1235
+ # override it to act on keys they care about.
957
1236
  #
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.
1237
+ # Dispatch is owned by {ScreenPane#handle_key}: a {#key_shortcut} match
1238
+ # anywhere in the active scope is captured first (suppressed while a
1239
+ # cursor-owner is mid-edit), then the key is delivered to {Screen#focused}
1240
+ # and bubbles up its ancestor chain until some component handles it. A
1241
+ # component therefore only ever receives keys when it is on the focus chain
1242
+ # — or when app code hands it a key directly — so it acts on the key alone
1243
+ # and must never gate on its own {#active?} state.
962
1244
  #
963
- # _@param_ `key` — a key.
1245
+ # _@param_ `_key` — a key.
964
1246
  #
965
1247
  # _@return_ — true if the key was handled, false if not.
966
- def handle_key: (String key) -> bool
1248
+ def handle_key: (String _key) -> bool
967
1249
 
968
1250
  # _@param_ `key` — keyboard key to look up.
969
1251
  #
@@ -1069,6 +1351,18 @@ module Tuile
1069
1351
  # topmost popup. Empty by default; override to advertise shortcuts.
1070
1352
  def keyboard_hint: () -> String
1071
1353
 
1354
+ # Advice to a wrapping {Component::Popup} on the minimum height this
1355
+ # component prefers to occupy when shown in a popup. `nil` (the default)
1356
+ # means no preference — the popup uses its own {Component::Popup#min_height}.
1357
+ # Override in a content component that should not collapse to a couple of
1358
+ # rows when sparse (e.g. {Component::LogWindow}).
1359
+ def popup_min_height: () -> Integer?
1360
+
1361
+ # Advice to a wrapping {Component::Popup} on the maximum height this
1362
+ # component may grow to when shown in a popup. `nil` (the default) means
1363
+ # no preference — the popup uses its own {Component::Popup#max_height}.
1364
+ def popup_max_height: () -> Integer?
1365
+
1072
1366
  # Called whenever the component width changes. Does nothing by default.
1073
1367
  def on_width_changed: () -> void
1074
1368
 
@@ -1385,9 +1679,9 @@ module Tuile
1385
1679
  #
1386
1680
  # _@param_ `scrollbar` — scrollbar instance, or nil if not shown.
1387
1681
  #
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
1682
+ # _@return_ — paintable line exactly `rect.width` columns wide;
1683
+ # highlighted if cursor is here.
1684
+ def paintable_line: (Integer index, Integer row_in_viewport, VerticalScrollBar? scrollbar) -> StyledString
1391
1685
 
1392
1686
  # _@return_ — callback fired when an item is chosen — by pressing
1393
1687
  # Enter on the cursor's item, or by left-clicking an item. Called as
@@ -1574,11 +1868,11 @@ module Tuile
1574
1868
  def compute_content_size: () -> Size
1575
1869
 
1576
1870
  # 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.
1871
+ # Each line is ellipsized to fit and padded with trailing spaces out to
1872
+ # the full width, so {#repaint} is just a lookup + {Buffer#set_line} per
1873
+ # row. {@blank_line} covers rows past the last text line. When {#bg} is
1874
+ # set, every produced line (and the blank row) has the bg applied
1875
+ # uniformly.
1582
1876
  def update_clipped_lines: () -> void
1583
1877
 
1584
1878
  # _@param_ `line`
@@ -1599,10 +1893,20 @@ module Tuile
1599
1893
  attr_accessor bg: (Color | Symbol | Integer | ::Array[Integer])?
1600
1894
  end
1601
1895
 
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.
1896
+ # An overlay that wraps any {Component} as its content. Popup itself
1897
+ # paints nothing — it's a transparent host that handles its lifecycle
1898
+ # ({#open} / {#close} / {#open?}, ESC/q to close) and auto-sizes to the
1899
+ # wrapped content.
1900
+ #
1901
+ # Modal by default: it centers on the screen, grabs focus, eats keys, and
1902
+ # blocks clicks beneath it. Pass `modal: false` for a non-modal overlay
1903
+ # that floats above the content (still painted on top, still auto-sized)
1904
+ # without taking focus or capturing input — the caller positions it (via
1905
+ # {#rect=}) and drives it from app code. That is the building block for an
1906
+ # autocomplete/slash-command list anchored to a {Component::TextField} or
1907
+ # {Component::TextArea} caret: typing keeps focus (and the cursor) in the
1908
+ # input, an {Component::TextInput#on_change} listener refills the list, and
1909
+ # an {Component::TextInput#on_key} interceptor forwards Up/Down/Enter to it.
1606
1910
  #
1607
1911
  # The wrapped content fills the popup's full {#rect}; if you want a frame
1608
1912
  # and caption, wrap a {Component::Window} (or any subclass — including
@@ -1623,7 +1927,12 @@ module Tuile
1623
1927
  include Tuile::Component::HasContent
1624
1928
 
1625
1929
  # _@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
1930
+ #
1931
+ # _@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).
1932
+ def initialize: (?content: Component?, ?modal: bool) -> void
1933
+
1934
+ # _@return_ — whether this popup is modal. See {#initialize}.
1935
+ def modal?: () -> bool
1627
1936
 
1628
1937
  def focusable?: () -> bool
1629
1938
 
@@ -1661,10 +1970,21 @@ module Tuile
1661
1970
  # when the popup is open.
1662
1971
  def center: () -> void
1663
1972
 
1664
- # _@return_ — max height the popup will grow to fit its content,
1665
- # defaults to 12. Override in a subclass to allow taller popups.
1973
+ # _@return_ — max height the popup will grow to fit its content.
1974
+ # Defers to the content's {Component#popup_max_height} advice when it
1975
+ # gives one, else defaults to 12. Override in a subclass to allow
1976
+ # taller popups regardless of content.
1666
1977
  def max_height: () -> Integer
1667
1978
 
1979
+ # _@return_ — min height the popup occupies even when its content
1980
+ # is shorter. Defers to the content's {Component#popup_min_height}
1981
+ # advice when it gives one, else defaults to 0 (size purely to
1982
+ # content) — so a {Component::LogWindow} stays readable while only a
1983
+ # few lines are in without callers wiring up a subclass. Override in a
1984
+ # subclass to keep any popup from collapsing to a couple of rows.
1985
+ # Capped at the same 4/5-of-screen ceiling {#update_rect} applies.
1986
+ def min_height: () -> Integer
1987
+
1668
1988
  # Sets the popup's content and auto-sizes the popup to fit.
1669
1989
  #
1670
1990
  # _@param_ `new_content`
@@ -1681,6 +2001,10 @@ module Tuile
1681
2001
  # Hint for the status bar: own "q Close" plus the wrapped content's hint.
1682
2002
  def keyboard_hint: () -> String
1683
2003
 
2004
+ # `q` and ESC close the popup. The popup sits on the focus chain of
2005
+ # whatever it wraps, so the key reaches here by bubbling up from the
2006
+ # focused content after that content declined to handle it.
2007
+ #
1684
2008
  # _@param_ `key`
1685
2009
  #
1686
2010
  # _@return_ — true if the key was handled.
@@ -1786,13 +2110,6 @@ module Tuile
1786
2110
  # _@param_ `event`
1787
2111
  def handle_mouse: (MouseEvent event) -> void
1788
2112
 
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
2113
  def on_focus: () -> void
1797
2114
 
1798
2115
  # Absolute layout. Extend this class, register any children, and
@@ -1820,9 +2137,6 @@ module Tuile
1820
2137
 
1821
2138
  def children: () -> ::Array[Component]
1822
2139
 
1823
- # _@param_ `key`
1824
- def handle_key: (String key) -> bool
1825
-
1826
2140
  # _@param_ `event`
1827
2141
  def handle_mouse: (MouseEvent event) -> void
1828
2142
 
@@ -1866,20 +2180,15 @@ module Tuile
1866
2180
  # _@param_ `content`
1867
2181
  def layout: (Component content) -> void
1868
2182
 
1869
- # Paints the window border.
2183
+ # Paints the window border into the {Screen#buffer}. Title is clipped to
2184
+ # the inner width so the box never overflows {#rect}; when the window is
2185
+ # active the whole border is drawn in {Theme#active_border_color}.
1870
2186
  def repaint_border: () -> void
1871
2187
 
1872
2188
  # The caption text as it appears in the rendered border, including the
1873
2189
  # shortcut prefix when {#key_shortcut} is set.
1874
2190
  def frame_caption: () -> String
1875
2191
 
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
2192
  # Recomputes the window's natural size: content's natural size (or the
1884
2193
  # caption, whichever is wider) plus the 2-character border. The footer
1885
2194
  # is deliberately excluded — see {#on_child_content_size_changed}. A
@@ -2394,11 +2703,10 @@ module Tuile
2394
2703
  #
2395
2704
  # _@param_ `scrollbar`
2396
2705
  #
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
2706
+ # _@return_ — paintable line exactly `rect.width` columns wide.
2707
+ # Body lines come pre-padded from {#rewrap}, so this reduces to a lookup
2708
+ # plus a concat of the scrollbar glyph when one is present.
2709
+ def paintable_line: (Integer index, Integer row_in_viewport, VerticalScrollBar? scrollbar) -> StyledString
2402
2710
 
2403
2711
  # _@return_ — index of the first visible physical line.
2404
2712
  attr_accessor top_line: Integer
@@ -2564,6 +2872,18 @@ module Tuile
2564
2872
  # _@param_ `caption`
2565
2873
  def initialize: (?String caption) -> void
2566
2874
 
2875
+ # Keep the log pane at least half the screen tall even when only a few
2876
+ # lines have been logged: a {Component::Popup} sizes to its content, which
2877
+ # would collapse a near-empty log to two or three rows. Advice consulted
2878
+ # by {Component::Popup#min_height} when this window is a popup's content.
2879
+ def popup_min_height: () -> Integer
2880
+
2881
+ # Let a busy log grow past the popup's base 12-row cap (up to the
2882
+ # 4/5-of-screen ceiling {Component::Popup#update_rect} applies) so the
2883
+ # diagnostic stream stays scrollable in a tall window. Advice consulted
2884
+ # by {Component::Popup#max_height} when this window is a popup's content.
2885
+ def popup_max_height: () -> Integer
2886
+
2567
2887
  # Appends given line to the log. Can be called from any thread. Does nothing if nil is passed in.
2568
2888
  #
2569
2889
  # _@param_ `string` — the line (or multiple lines) to log.
@@ -2682,22 +3002,24 @@ module Tuile
2682
3002
 
2683
3003
  def tab_stop?: () -> bool
2684
3004
 
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}.
3005
+ # Handles a key. An {#on_key} interceptor (if set) gets first refusal —
3006
+ # a truthy return consumes the key otherwise it delegates to
3007
+ # {#handle_text_input_key}. Dispatch ({ScreenPane#handle_key}) only routes
3008
+ # keys here when this input is on the focus chain, so there is no
3009
+ # {#active?} gate.
2688
3010
  #
2689
3011
  # _@param_ `key`
2690
3012
  def handle_key: (String key) -> bool
2691
3013
 
2692
3014
  # 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 —
3015
+ # current {Screen#theme} at paint time: {Theme#active_bg_color} when this
3016
+ # input is on the active (focus) chain, {Theme#input_bg_color} otherwise —
2695
3017
  # visibly a field either way, distinctly highlighted when active.
2696
3018
  #
2697
3019
  # _@param_ `text`
2698
3020
  #
2699
- # _@return_ — ANSI-rendered text.
2700
- def background: (String text) -> String
3021
+ # _@return_ — text on the field's background well.
3022
+ def background: (String text) -> StyledString
2701
3023
 
2702
3024
  # Input filter for {#text=}. Subclasses override to truncate or reject
2703
3025
  # invalid input. Default coerces to String.
@@ -2760,6 +3082,21 @@ module Tuile
2760
3082
  # _@return_ — one-arg callable, or nil.
2761
3083
  attr_accessor on_change: (Proc | Method)?
2762
3084
 
3085
+ # Optional interceptor consulted before the input's own key handling.
3086
+ # Receives the pressed key; return a truthy value to consume it (the
3087
+ # input then ignores that key), falsy to let normal editing proceed.
3088
+ #
3089
+ # The keyboard analog of {#on_change}: it lets app code layer behavior
3090
+ # onto an input without subclassing. The motivating case is an
3091
+ # autocomplete / slash-command overlay (a non-modal {Component::Popup}):
3092
+ # while it is open the interceptor claims Up/Down/Enter/ESC and forwards
3093
+ # them to the overlay's list, but lets ordinary characters fall through
3094
+ # so typing keeps editing the field (and {#on_change} keeps refilling the
3095
+ # list).
3096
+ #
3097
+ # _@return_ — one-arg callable, or nil.
3098
+ attr_accessor on_key: (Proc | Method)?
3099
+
2763
3100
  # Callback fired when ESC is pressed. Defaults to a closure that clears
2764
3101
  # focus (`screen.focused = nil`) so ESC visibly cancels text entry instead
2765
3102
  # of bubbling to the parent — and, in particular, instead of reaching the
@@ -2774,11 +3111,6 @@ module Tuile
2774
3111
  # provide a protected `layout(content)` method which repositions the
2775
3112
  # content component; the mixin manages `@content` itself.
2776
3113
  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
3114
  # _@param_ `event`
2783
3115
  def handle_mouse: (MouseEvent event) -> void
2784
3116
 
@@ -2828,6 +3160,11 @@ module Tuile
2828
3160
  # _@param_ `options` — pairs of keyboard key and option caption. No Rainbow formatting must be used.
2829
3161
  def initialize: (String caption, ::Array[[String, String]] options) ?{ (String key) -> void } -> void
2830
3162
 
3163
+ # Handles an option-key press. Reached by bubbling: the inner {List}
3164
+ # (the focused component) sees the key first and handles cursor/Enter
3165
+ # picks; anything it declines bubbles up here, where a key matching an
3166
+ # option's `key` picks that option.
3167
+ #
2831
3168
  # _@param_ `key`
2832
3169
  def handle_key: (String key) -> bool
2833
3170
 
@@ -3148,6 +3485,13 @@ module Tuile
3148
3485
  # _@param_ `args`
3149
3486
  def print: (*String args) -> void
3150
3487
 
3488
+ # Captures the assembled repaint frame instead of writing to the test
3489
+ # runner's TTY. Lands in {#prints} so cursor/sync escapes can be asserted;
3490
+ # painted content is read from {#buffer}.
3491
+ #
3492
+ # _@param_ `str`
3493
+ def emit: (String str) -> void
3494
+
3151
3495
  # _@param_ `component` — the component to check.
3152
3496
  def invalidated?: (Component component) -> bool
3153
3497
 
@@ -3158,7 +3502,10 @@ module Tuile
3158
3502
  # steal its input) and pin the deterministic default.
3159
3503
  def detect_scheme: () -> Symbol
3160
3504
 
3161
- # _@return_ — whatever {#print} printed so far.
3505
+ # _@return_ — whatever {#print} / {#emit} produced so far.
3506
+ # Component painting lands in {#buffer}, not here — assert on
3507
+ # {Buffer#row_text} / {Buffer#row_ansi} / {Buffer#cell} for content, and
3508
+ # on `prints` for cursor and housekeeping escapes.
3162
3509
  attr_reader prints: ::Array[String]
3163
3510
  end
3164
3511
 
@@ -3236,7 +3583,11 @@ module Tuile
3236
3583
  # status bar last.
3237
3584
  def children: () -> ::Array[Component]
3238
3585
 
3239
- # Adds a popup, centers it, focuses it, and invalidates it for repaint.
3586
+ # Adds a popup and invalidates it for repaint. A modal popup is centered
3587
+ # and grabs focus; a non-modal overlay ({Component::Popup#modal?} false) is
3588
+ # left wherever the caller positions it and does *not* take focus, so the
3589
+ # component that was focused keeps the cursor and keeps receiving keys —
3590
+ # the overlay floats above the content, driven from app code.
3240
3591
  #
3241
3592
  # _@param_ `window`
3242
3593
  def add_popup: (Component::Popup window) -> void
@@ -3253,26 +3604,52 @@ module Tuile
3253
3604
  # _@return_ — true if this pane currently hosts the popup.
3254
3605
  def has_popup?: (Component window) -> bool
3255
3606
 
3607
+ # _@return_ — the topmost *modal* popup, or nil when
3608
+ # only non-modal overlays (or no popups) are open. This is the "modal
3609
+ # owner": the popup that scopes key dispatch, blocks mouse clicks, owns
3610
+ # the status bar, and confines Tab cycling. Non-modal overlays are
3611
+ # excluded — they float above the content without capturing input.
3612
+ def modal_popup: () -> Component::Popup?
3613
+
3256
3614
  # Re-lays out children whenever the pane's own rect changes.
3257
3615
  #
3258
3616
  # _@param_ `new_rect`
3259
3617
  def rect=: (Rect new_rect) -> void
3260
3618
 
3261
3619
  # Lays out content (full pane minus the bottom row) and the status bar
3262
- # (bottom row). Popups self-position via {Component::Popup#center}.
3620
+ # (bottom row). Modal popups self-recenter via {Component::Popup#center};
3621
+ # non-modal overlays keep the position their owner assigned.
3263
3622
  def layout: () -> void
3264
3623
 
3265
3624
  # Pane paints nothing itself; its children paint over the entire rect.
3266
3625
  def repaint: () -> void
3267
3626
 
3268
- # Topmost popup is modal: it eats keys. Falls through to content only
3269
- # when no popup is open.
3627
+ # Dispatches a key in two phases, both scoped to the topmost *modal* popup
3628
+ # (when one is open) or else the tiled {#content}. Non-modal overlays are
3629
+ # never the scope: focus stays in the content beneath them, and the overlay
3630
+ # is driven by app code (which forwards keys to it explicitly), so it
3631
+ # doesn't appear in this path at all.
3632
+ #
3633
+ # 1. *Capture* — a {Component#key_shortcut} match anywhere in the scope
3634
+ # focuses that component and consumes the key. Suppressed while a
3635
+ # cursor-owner ({Screen#cursor_position}) is mid-edit, so typing into a
3636
+ # {Component::TextField} isn't hijacked by a sibling's shortcut.
3637
+ # 2. *Delivery* — the key is handed to {Screen#focused} and bubbles up its
3638
+ # ancestor chain to the scope root; the first component to return true
3639
+ # wins. Focus that is nil or sits outside the scope receives nothing,
3640
+ # which is what keeps an open modal popup modal.
3641
+ #
3642
+ # _@param_ `key`
3643
+ #
3644
+ # _@return_ — true if the key was handled.
3270
3645
  def handle_key: (String key) -> bool
3271
3646
 
3272
3647
  # 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.
3648
+ # fall through to content only when no popup is hit *and* no modal popup is
3649
+ # open. This preserves modal click-blocking an open modal eats clicks
3650
+ # even outside its rect — while a non-modal overlay blocks nothing: clicks
3651
+ # inside it route to it (e.g. click-to-select), clicks elsewhere reach the
3652
+ # content beneath.
3276
3653
  #
3277
3654
  # _@param_ `event`
3278
3655
  def handle_mouse: (MouseEvent event) -> void
@@ -3280,7 +3657,7 @@ module Tuile
3280
3657
  # Focus repair when a child detaches. Default {Component#on_child_removed}
3281
3658
  # would refocus to `self` (the pane), which isn't a useful focus target.
3282
3659
  # 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
3660
+ # modal popup; falling back to the focus snapshotted when this popup was opened
3284
3661
  # (if still attached and still focusable); then to the first interactable
3285
3662
  # widget in {#content}; then to {#content} itself; then nil.
3286
3663
  #
@@ -3292,6 +3669,19 @@ module Tuile
3292
3669
  # _@param_ `child`
3293
3670
  def on_child_removed: (Component child) -> void
3294
3671
 
3672
+ # Delivers `key` to {Screen#focused} and bubbles it up the ancestor chain,
3673
+ # stopping at (and including) `scope`. Delivers to no one — returning false
3674
+ # — when focus is nil or sits outside `scope`; the latter is what makes an
3675
+ # open popup modal, since focus is always inside it and content beneath
3676
+ # never receives keys.
3677
+ #
3678
+ # _@param_ `key`
3679
+ #
3680
+ # _@param_ `scope` — the modal scope root (topmost popup or content).
3681
+ #
3682
+ # _@return_ — true if some component on the chain handled the key.
3683
+ def bubble_key: (String key, Component scope) -> bool
3684
+
3295
3685
  # First {Component#tab_stop?} in `root`'s subtree (pre-order), falling
3296
3686
  # back to `root` itself when the subtree has no tab stops. Returns `nil`
3297
3687
  # if `root` is `nil`.
@@ -3302,8 +3692,9 @@ module Tuile
3302
3692
  # _@return_ — the tiled content component.
3303
3693
  attr_accessor content: Component?
3304
3694
 
3305
- # _@return_ — modal popups in stacking order; last is
3306
- # topmost. The array must not be mutated by callers.
3695
+ # _@return_ — overlay popups in stacking order; last is
3696
+ # topmost. Holds both modal popups and non-modal overlays
3697
+ # ({Component::Popup#modal?}). The array must not be mutated by callers.
3307
3698
  attr_reader popups: ::Array[Component]
3308
3699
 
3309
3700
  # _@return_ — the bottom status bar.
@@ -3496,19 +3887,6 @@ module Tuile
3496
3887
  # _@param_ `spans`
3497
3888
  def normalize: (::Array[Span] spans) -> ::Array[Span]
3498
3889
 
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
3890
  # _@param_ `start_or_range`
3513
3891
  #
3514
3892
  # _@param_ `len`
@@ -3608,6 +3986,27 @@ module Tuile
3608
3986
  # _@param_ `overrides`
3609
3987
  def merge: (**::Hash[Symbol, Object] overrides) -> Style
3610
3988
 
3989
+ # Minimal SGR escape that transitions a terminal already showing `self`
3990
+ # into `other`: only the attributes that differ are emitted. Returns
3991
+ # `""` when the styles are identical (nothing to do), and {Ansi::RESET}
3992
+ # (`\e[0m`, one code) when `other` is the default style — shorter than
3993
+ # turning each attribute off individually.
3994
+ #
3995
+ # Shared by {StyledString#to_ansi} (diffing span-to-span from the default
3996
+ # style) and {Buffer}'s flush (diffing cell-to-cell against the style the
3997
+ # terminal currently holds), so both emit identical minimal sequences.
3998
+ #
3999
+ # _@param_ `other` — the style to transition to.
4000
+ def sgr_to: (Style other) -> String
4001
+
4002
+ # _@param_ `color`
4003
+ #
4004
+ # _@param_ `target` — either `:fg` or `:bg`.
4005
+ #
4006
+ # _@return_ — SGR codes; `[39]` / `[49]` for the "default"
4007
+ # reset when `color` is `nil`, otherwise delegated to {Color#sgr_codes}.
4008
+ def color_codes: (Color? color, target: Symbol) -> ::Array[Integer]
4009
+
3611
4010
  attr_reader fg: Color?
3612
4011
 
3613
4012
  attr_reader bg: Color?