tuile 0.6.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
@@ -136,6 +138,13 @@ module Tuile
136
138
  # _@param_ `point`
137
139
  def contains?: (Point point) -> bool
138
140
 
141
+ # _@param_ `other` — another rectangle.
142
+ #
143
+ # _@return_ — true if `other` lies entirely within this rectangle.
144
+ # Uses the same half-open edges as {#contains?} (right/bottom exclusive).
145
+ # An {#empty? empty} `other` covers no cells, so it is trivially contained.
146
+ def contains_rect?: (Rect other) -> bool
147
+
139
148
  def size: () -> Size
140
149
 
141
150
  def top_left: () -> Point
@@ -519,6 +528,286 @@ module Tuile
519
528
  attr_reader custom: ::Hash[Symbol, Color]
520
529
  end
521
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
+
522
811
  # The TTY screen. There is exactly one screen per app.
523
812
  #
524
813
  # A screen runs the event loop; call {#run_event_loop} to do that.
@@ -665,6 +954,15 @@ module Tuile
665
954
  # _@param_ `window`
666
955
  def remove_popup: (Component::Popup window) -> void
667
956
 
957
+ # Invalidates the entire attached tree, forcing every component to repaint
958
+ # on the next cycle. Needed whenever something overdraws the scene without
959
+ # clipping and then exposes what was underneath — a closing popup
960
+ # ({#remove_popup}), or a popup that shrinks or moves so its new {#rect} no
961
+ # longer covers the cells it previously painted ({Component::Popup#rect=}).
962
+ # The popup-only fast path in {#repaint} can't clear those vacated cells on
963
+ # its own, so we accept the cost of a full repaint.
964
+ def needs_full_repaint: () -> void
965
+
668
966
  # Internal — use {Component::Popup#open?} instead.
669
967
  #
670
968
  # _@param_ `window`
@@ -682,23 +980,12 @@ module Tuile
682
980
 
683
981
  def self.close: () -> void
684
982
 
685
- # Prints given strings. While {#repaint} is running, writes are
686
- # accumulated into a frame buffer and flushed to the terminal as a
687
- # single `$stdout.write` at the end of the cycle. This stops the
688
- # emulator from rendering half-finished frames (e.g. a layout's
689
- # clear-background pass before its children have re-painted), which
690
- # was visible as a brief flicker when the auto-clear path triggers.
691
- #
692
- # Outside repaint, writes go straight to stdout. We deliberately
693
- # don't raise on a "print outside repaint" — that would be a useful
694
- # guardrail against components painting outside the repaint loop,
695
- # but it'd force terminal-housekeeping writes (`Screen#clear`,
696
- # mouse-tracking start/stop, cursor-show on teardown) to bypass
697
- # this method entirely and write directly to `$stdout`. {FakeScreen}
698
- # overrides `print` to capture every byte into its `@prints` array,
699
- # and tests that exercise `run_event_loop` against a real {Screen}
700
- # would otherwise leak escape sequences to the test runner's stdout.
701
- # 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.
702
989
  #
703
990
  # _@param_ `args` — stuff to print.
704
991
  def print: (*String args) -> void
@@ -741,24 +1028,22 @@ module Tuile
741
1028
  # _@return_ — true if focus moved.
742
1029
  def cycle_focus: (forward: bool) -> bool
743
1030
 
744
- # Collects a component and all its descendants in tree order
745
- # (parent before children).
746
- #
747
- # _@param_ `component`
748
- 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
749
1035
 
750
- # Hides or moves the hardware cursor based on the current focus state.
751
- 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
752
1041
 
753
1042
  # Recalculates positions of all windows, and repaints the scene.
754
1043
  # Automatically called whenever terminal size changes. Call when the app
755
1044
  # starts. {#size} provides correct size of the terminal.
756
1045
  def layout: () -> void
757
1046
 
758
- # Called after a popup is closed. Since a popup can cover any window,
759
- # top-level component or other popups, we need to redraw everything.
760
- def needs_full_repaint: () -> void
761
-
762
1047
  # A key has been pressed on the keyboard. Handle it, or forward to active
763
1048
  # window.
764
1049
  #
@@ -769,10 +1054,12 @@ module Tuile
769
1054
  # doesn't trap them.
770
1055
  # 2. App-level shortcuts from {#register_global_shortcut}. An entry
771
1056
  # registered with `over_popups: true` always fires; one with the
772
- # default `over_popups: false` fires only when no popup is open
773
- # (otherwise the popup receives the key normally).
774
- # 3. {ScreenPane#handle_key}, which routes to the topmost popup or
775
- # 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.
776
1063
  #
777
1064
  # _@param_ `key`
778
1065
  #
@@ -789,6 +1076,10 @@ module Tuile
789
1076
  # _@return_ — the structural root of the component tree.
790
1077
  attr_reader pane: ScreenPane
791
1078
 
1079
+ # _@return_ — the back buffer components paint into
1080
+ # ({Buffer#set_line} / {Buffer#fill} / {Buffer#set_char}).
1081
+ attr_reader buffer: Buffer
1082
+
792
1083
  # Handler invoked when a {StandardError} escapes an event handler inside
793
1084
  # the event loop (e.g. a {Component::TextField}'s `on_change` raises).
794
1085
  #
@@ -938,20 +1229,23 @@ module Tuile
938
1229
  # Only called when the component is attached.
939
1230
  def repaint: () -> void
940
1231
 
941
- # Called when a character is pressed on the keyboard.
942
- #
943
- # Also called for inactive components. Inactive component should just return
944
- # 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.
945
1236
  #
946
- # Default implementation searches for a component with {#key_shortcut} and
947
- # focuses it. The shortcut search is suppressed while the focused component
948
- # owns the hardware cursor (e.g. a {Component::TextField} the user is
949
- # 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.
950
1244
  #
951
- # _@param_ `key` — a key.
1245
+ # _@param_ `_key` — a key.
952
1246
  #
953
1247
  # _@return_ — true if the key was handled, false if not.
954
- def handle_key: (String key) -> bool
1248
+ def handle_key: (String _key) -> bool
955
1249
 
956
1250
  # _@param_ `key` — keyboard key to look up.
957
1251
  #
@@ -1057,6 +1351,18 @@ module Tuile
1057
1351
  # topmost popup. Empty by default; override to advertise shortcuts.
1058
1352
  def keyboard_hint: () -> String
1059
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
+
1060
1366
  # Called whenever the component width changes. Does nothing by default.
1061
1367
  def on_width_changed: () -> void
1062
1368
 
@@ -1141,6 +1447,12 @@ module Tuile
1141
1447
  class List < Component
1142
1448
  def initialize: () -> void
1143
1449
 
1450
+ # _@return_ — whether {#auto_scroll} is currently tailing. True
1451
+ # while the viewport sits at the last line; flips to false the moment
1452
+ # the user scrolls up, and back to true once they scroll to the bottom
1453
+ # again. Only consulted when {#auto_scroll} is enabled.
1454
+ def following?: () -> bool
1455
+
1144
1456
  # Sets new lines. Each entry is coerced into a {StyledString} (a
1145
1457
  # `String` is parsed via {StyledString.parse}, so embedded ANSI is
1146
1458
  # honored; a {StyledString} is used as-is; anything else is stringified
@@ -1309,6 +1621,10 @@ module Tuile
1309
1621
  # _@return_ — the max value of {#top_line}.
1310
1622
  def top_line_max: () -> Integer
1311
1623
 
1624
+ # _@return_ — whether the viewport is pinned to the last line.
1625
+ # Drives {#following?}: re-evaluated on every {#top_line=}.
1626
+ def at_bottom?: () -> bool
1627
+
1312
1628
  # _@return_ — the number of visible lines.
1313
1629
  def viewport_lines: () -> Integer
1314
1630
 
@@ -1325,6 +1641,11 @@ module Tuile
1325
1641
  # which would leave `top_line` past the last item once a real rect
1326
1642
  # arrives. {#on_width_changed} re-runs this hook when the rect grows so
1327
1643
  # the snap-to-bottom intent is preserved.
1644
+ #
1645
+ # Gated on {#following?}: once the user scrolls up off the bottom the
1646
+ # cursor snap and viewport pin are both skipped, so reading older
1647
+ # content is not interrupted by incoming lines. {#top_line=} re-arms
1648
+ # `@follow` when the viewport returns to the bottom.
1328
1649
  def update_top_line_if_auto_scroll: () -> void
1329
1650
 
1330
1651
  # _@return_ — whether the scrollbar should be drawn right now.
@@ -1358,9 +1679,9 @@ module Tuile
1358
1679
  #
1359
1680
  # _@param_ `scrollbar` — scrollbar instance, or nil if not shown.
1360
1681
  #
1361
- # _@return_ — paintable ANSI-encoded line exactly `rect.width`
1362
- # columns wide; highlighted if cursor is here.
1363
- 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
1364
1685
 
1365
1686
  # _@return_ — callback fired when an item is chosen — by pressing
1366
1687
  # Enter on the cursor's item, or by left-clicking an item. Called as
@@ -1380,7 +1701,10 @@ module Tuile
1380
1701
  attr_accessor on_cursor_changed: Proc?
1381
1702
 
1382
1703
  # _@return_ — if true and a line is added or new content is set,
1383
- # auto-scrolls to the bottom.
1704
+ # auto-scrolls to the bottom — but only while the viewport is already
1705
+ # pinned to the last line (see {#following?}). Scroll up to read older
1706
+ # content and appends stop yanking you back down; scroll back to the
1707
+ # bottom and tailing resumes.
1384
1708
  attr_accessor auto_scroll: bool
1385
1709
 
1386
1710
  # _@return_ — top line of the viewport. 0 or positive.
@@ -1544,11 +1868,11 @@ module Tuile
1544
1868
  def compute_content_size: () -> Size
1545
1869
 
1546
1870
  # Recomputes {@clipped_lines} for the current text and rect width.
1547
- # Each line is ellipsized to fit, padded with trailing spaces out to
1548
- # the full width, and pre-rendered to ANSI so {#repaint} is just a
1549
- # lookup + screen.print per row. {@blank_line} covers rows past the
1550
- # last text line. When {#bg} is set, every produced line (and the
1551
- # 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.
1552
1876
  def update_clipped_lines: () -> void
1553
1877
 
1554
1878
  # _@param_ `line`
@@ -1569,10 +1893,20 @@ module Tuile
1569
1893
  attr_accessor bg: (Color | Symbol | Integer | ::Array[Integer])?
1570
1894
  end
1571
1895
 
1572
- # A modal overlay that wraps any {Component} as its content. Popup itself
1573
- # paints nothing — it's a transparent host that handles modality
1574
- # ({#open} / {#close} / {#open?}, ESC/q to close), centers itself on the
1575
- # 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.
1576
1910
  #
1577
1911
  # The wrapped content fills the popup's full {#rect}; if you want a frame
1578
1912
  # and caption, wrap a {Component::Window} (or any subclass — including
@@ -1593,10 +1927,26 @@ module Tuile
1593
1927
  include Tuile::Component::HasContent
1594
1928
 
1595
1929
  # _@param_ `content` — initial content; can be set later via {#content=}. When provided here, the popup auto-sizes to fit.
1596
- 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
1597
1936
 
1598
1937
  def focusable?: () -> bool
1599
1938
 
1939
+ # Reassigns the popup's rect, escalating to a full scene repaint when an
1940
+ # open popup shrinks or moves so its new rect no longer covers the cells
1941
+ # it previously painted. A popup overdraws the scene without clipping and
1942
+ # nothing clears underneath it, so {Screen#repaint}'s popup-only fast path
1943
+ # would repaint into the new rect and leave the vacated cells showing
1944
+ # stale content. When the new rect fully covers the old one (the popup
1945
+ # only grew), the fast path is correct and the full repaint is skipped.
1946
+ #
1947
+ # _@param_ `new_rect`
1948
+ def rect=: (Rect new_rect) -> void
1949
+
1600
1950
  # Mounts this popup on the {Screen}. Recomputes the popup's size from
1601
1951
  # the current content first, so reopening a popup whose content has
1602
1952
  # grown or shrunk while closed picks up the new size.
@@ -1620,10 +1970,21 @@ module Tuile
1620
1970
  # when the popup is open.
1621
1971
  def center: () -> void
1622
1972
 
1623
- # _@return_ — max height the popup will grow to fit its content,
1624
- # 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.
1625
1977
  def max_height: () -> Integer
1626
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
+
1627
1988
  # Sets the popup's content and auto-sizes the popup to fit.
1628
1989
  #
1629
1990
  # _@param_ `new_content`
@@ -1640,6 +2001,10 @@ module Tuile
1640
2001
  # Hint for the status bar: own "q Close" plus the wrapped content's hint.
1641
2002
  def keyboard_hint: () -> String
1642
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
+ #
1643
2008
  # _@param_ `key`
1644
2009
  #
1645
2010
  # _@return_ — true if the key was handled.
@@ -1652,6 +2017,11 @@ module Tuile
1652
2017
 
1653
2018
  # Recompute width/height from {#content}'s natural size and recenter
1654
2019
  # if currently open. Called whenever content is (re)assigned.
2020
+ #
2021
+ # Computes the final (centered) rect and assigns it in one step rather
2022
+ # than positioning at the origin and then centering: the intermediate
2023
+ # origin rect rarely covers the previous one, which would make
2024
+ # {#rect=}'s shrink/move detection fire a full repaint on every resize.
1655
2025
  def update_rect: () -> void
1656
2026
 
1657
2027
  # _@param_ `event`
@@ -1659,9 +2029,6 @@ module Tuile
1659
2029
 
1660
2030
  def children: () -> ::Array[Component]
1661
2031
 
1662
- # _@param_ `rect`
1663
- def rect=: (Rect rect) -> void
1664
-
1665
2032
  def on_focus: () -> void
1666
2033
  end
1667
2034
 
@@ -1743,13 +2110,6 @@ module Tuile
1743
2110
  # _@param_ `event`
1744
2111
  def handle_mouse: (MouseEvent event) -> void
1745
2112
 
1746
- # Called when a character is pressed on the keyboard.
1747
- #
1748
- # _@param_ `key` — a key.
1749
- #
1750
- # _@return_ — true if the key was handled, false if not.
1751
- def handle_key: (String key) -> bool
1752
-
1753
2113
  def on_focus: () -> void
1754
2114
 
1755
2115
  # Absolute layout. Extend this class, register any children, and
@@ -1777,9 +2137,6 @@ module Tuile
1777
2137
 
1778
2138
  def children: () -> ::Array[Component]
1779
2139
 
1780
- # _@param_ `key`
1781
- def handle_key: (String key) -> bool
1782
-
1783
2140
  # _@param_ `event`
1784
2141
  def handle_mouse: (MouseEvent event) -> void
1785
2142
 
@@ -1823,20 +2180,15 @@ module Tuile
1823
2180
  # _@param_ `content`
1824
2181
  def layout: (Component content) -> void
1825
2182
 
1826
- # 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}.
1827
2186
  def repaint_border: () -> void
1828
2187
 
1829
2188
  # The caption text as it appears in the rendered border, including the
1830
2189
  # shortcut prefix when {#key_shortcut} is set.
1831
2190
  def frame_caption: () -> String
1832
2191
 
1833
- # Builds the border as a single string with embedded cursor-positioning
1834
- # escapes, mirroring the layout {TTY::Box.frame} used to produce. Title
1835
- # is clipped to fit the inner width so the box never overflows {#rect}.
1836
- #
1837
- # _@param_ `caption`
1838
- def build_frame: (String caption) -> String
1839
-
1840
2192
  # Recomputes the window's natural size: content's natural size (or the
1841
2193
  # caption, whichever is wider) plus the 2-character border. The footer
1842
2194
  # is deliberately excluded — see {#on_child_content_size_changed}. A
@@ -1985,6 +2337,12 @@ module Tuile
1985
2337
  # O(total spans).
1986
2338
  def text: () -> StyledString
1987
2339
 
2340
+ # _@return_ — whether {#auto_scroll} is currently tailing. True
2341
+ # while the viewport sits at the last line; flips to false the moment
2342
+ # the user scrolls up, and back to true once they scroll to the bottom
2343
+ # again. Only consulted when {#auto_scroll} is enabled.
2344
+ def following?: () -> bool
2345
+
1988
2346
  # Replaces the text. Embedded `\n` characters become hard line breaks.
1989
2347
  # A `String` is parsed via {StyledString.parse} (so embedded ANSI is
1990
2348
  # honored); a `StyledString` is used as-is; `nil` is coerced to an
@@ -2316,8 +2674,16 @@ module Tuile
2316
2674
  # _@param_ `target` — desired top line; clamped to `[0, top_line_max]`.
2317
2675
  def move_top_line_to: (Integer target) -> void
2318
2676
 
2677
+ # Gated on {#following?}: once the user scrolls up off the bottom the
2678
+ # viewport pin is skipped, so reading older content is not interrupted
2679
+ # by incoming lines. {#top_line=} re-arms `@follow` when the viewport
2680
+ # returns to the bottom.
2319
2681
  def update_top_line_if_auto_scroll: () -> void
2320
2682
 
2683
+ # _@return_ — whether the viewport is pinned to the last line.
2684
+ # Drives {#following?}: re-evaluated on every {#top_line=}.
2685
+ def at_bottom?: () -> bool
2686
+
2321
2687
  def scrollbar_visible?: () -> bool
2322
2688
 
2323
2689
  # Pads `line` with trailing default-styled spaces out to `width` display
@@ -2337,11 +2703,10 @@ module Tuile
2337
2703
  #
2338
2704
  # _@param_ `scrollbar`
2339
2705
  #
2340
- # _@return_ — paintable ANSI-encoded line exactly `rect.width`
2341
- # columns wide. Body lines come pre-padded from {#rewrap}, so this
2342
- # reduces to a memoized {StyledString#to_ansi} read plus an
2343
- # ASCII-string concat of the scrollbar glyph when one is present.
2344
- 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
2345
2710
 
2346
2711
  # _@return_ — index of the first visible physical line.
2347
2712
  attr_accessor top_line: Integer
@@ -2350,7 +2715,10 @@ module Tuile
2350
2715
  attr_accessor scrollbar_visibility: Symbol
2351
2716
 
2352
2717
  # _@return_ — if true, mutating the text scrolls the viewport so
2353
- # the last line stays in view. Default `false`.
2718
+ # the last line stays in view but only while the viewport is already
2719
+ # pinned to the last line (see {#following?}). Scroll up to read older
2720
+ # content and appends stop yanking you back down; scroll back to the
2721
+ # bottom and tailing resumes. Default `false`.
2354
2722
  attr_accessor auto_scroll: bool
2355
2723
 
2356
2724
  # _@return_ — longest hard-line's display width × number of hard
@@ -2504,6 +2872,18 @@ module Tuile
2504
2872
  # _@param_ `caption`
2505
2873
  def initialize: (?String caption) -> void
2506
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
+
2507
2887
  # Appends given line to the log. Can be called from any thread. Does nothing if nil is passed in.
2508
2888
  #
2509
2889
  # _@param_ `string` — the line (or multiple lines) to log.
@@ -2622,22 +3002,24 @@ module Tuile
2622
3002
 
2623
3003
  def tab_stop?: () -> bool
2624
3004
 
2625
- # Handles a key. Returns false when the component is inactive. Otherwise
2626
- # first runs the {Component#handle_key} shortcut search via `super`, then
2627
- # 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.
2628
3010
  #
2629
3011
  # _@param_ `key`
2630
3012
  def handle_key: (String key) -> bool
2631
3013
 
2632
3014
  # Renders `text` on the field's background well, looked up from the
2633
- # current {Screen#theme} at paint time: {Theme#active_bg} when this
2634
- # 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 —
2635
3017
  # visibly a field either way, distinctly highlighted when active.
2636
3018
  #
2637
3019
  # _@param_ `text`
2638
3020
  #
2639
- # _@return_ — ANSI-rendered text.
2640
- def background: (String text) -> String
3021
+ # _@return_ — text on the field's background well.
3022
+ def background: (String text) -> StyledString
2641
3023
 
2642
3024
  # Input filter for {#text=}. Subclasses override to truncate or reject
2643
3025
  # invalid input. Default coerces to String.
@@ -2700,6 +3082,21 @@ module Tuile
2700
3082
  # _@return_ — one-arg callable, or nil.
2701
3083
  attr_accessor on_change: (Proc | Method)?
2702
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
+
2703
3100
  # Callback fired when ESC is pressed. Defaults to a closure that clears
2704
3101
  # focus (`screen.focused = nil`) so ESC visibly cancels text entry instead
2705
3102
  # of bubbling to the parent — and, in particular, instead of reaching the
@@ -2714,11 +3111,6 @@ module Tuile
2714
3111
  # provide a protected `layout(content)` method which repositions the
2715
3112
  # content component; the mixin manages `@content` itself.
2716
3113
  module HasContent
2717
- # _@param_ `key` — a key.
2718
- #
2719
- # _@return_ — true if the key was handled, false if not.
2720
- def handle_key: (String key) -> bool
2721
-
2722
3114
  # _@param_ `event`
2723
3115
  def handle_mouse: (MouseEvent event) -> void
2724
3116
 
@@ -2768,6 +3160,11 @@ module Tuile
2768
3160
  # _@param_ `options` — pairs of keyboard key and option caption. No Rainbow formatting must be used.
2769
3161
  def initialize: (String caption, ::Array[[String, String]] options) ?{ (String key) -> void } -> void
2770
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
+ #
2771
3168
  # _@param_ `key`
2772
3169
  def handle_key: (String key) -> bool
2773
3170
 
@@ -3088,6 +3485,13 @@ module Tuile
3088
3485
  # _@param_ `args`
3089
3486
  def print: (*String args) -> void
3090
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
+
3091
3495
  # _@param_ `component` — the component to check.
3092
3496
  def invalidated?: (Component component) -> bool
3093
3497
 
@@ -3098,7 +3502,10 @@ module Tuile
3098
3502
  # steal its input) and pin the deterministic default.
3099
3503
  def detect_scheme: () -> Symbol
3100
3504
 
3101
- # _@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.
3102
3509
  attr_reader prints: ::Array[String]
3103
3510
  end
3104
3511
 
@@ -3176,7 +3583,11 @@ module Tuile
3176
3583
  # status bar last.
3177
3584
  def children: () -> ::Array[Component]
3178
3585
 
3179
- # 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.
3180
3591
  #
3181
3592
  # _@param_ `window`
3182
3593
  def add_popup: (Component::Popup window) -> void
@@ -3193,26 +3604,52 @@ module Tuile
3193
3604
  # _@return_ — true if this pane currently hosts the popup.
3194
3605
  def has_popup?: (Component window) -> bool
3195
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
+
3196
3614
  # Re-lays out children whenever the pane's own rect changes.
3197
3615
  #
3198
3616
  # _@param_ `new_rect`
3199
3617
  def rect=: (Rect new_rect) -> void
3200
3618
 
3201
3619
  # Lays out content (full pane minus the bottom row) and the status bar
3202
- # (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.
3203
3622
  def layout: () -> void
3204
3623
 
3205
3624
  # Pane paints nothing itself; its children paint over the entire rect.
3206
3625
  def repaint: () -> void
3207
3626
 
3208
- # Topmost popup is modal: it eats keys. Falls through to content only
3209
- # 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.
3210
3645
  def handle_key: (String key) -> bool
3211
3646
 
3212
3647
  # Mouse events check popups in reverse stacking order (topmost first), and
3213
- # fall through to content only when no popup is hit *and* there are no
3214
- # popups open. This preserves modal click-blocking: an open popup eats
3215
- # 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.
3216
3653
  #
3217
3654
  # _@param_ `event`
3218
3655
  def handle_mouse: (MouseEvent event) -> void
@@ -3220,7 +3657,7 @@ module Tuile
3220
3657
  # Focus repair when a child detaches. Default {Component#on_child_removed}
3221
3658
  # would refocus to `self` (the pane), which isn't a useful focus target.
3222
3659
  # Instead, route focus to the first interactable widget in the now-topmost
3223
- # 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
3224
3661
  # (if still attached and still focusable); then to the first interactable
3225
3662
  # widget in {#content}; then to {#content} itself; then nil.
3226
3663
  #
@@ -3232,6 +3669,19 @@ module Tuile
3232
3669
  # _@param_ `child`
3233
3670
  def on_child_removed: (Component child) -> void
3234
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
+
3235
3685
  # First {Component#tab_stop?} in `root`'s subtree (pre-order), falling
3236
3686
  # back to `root` itself when the subtree has no tab stops. Returns `nil`
3237
3687
  # if `root` is `nil`.
@@ -3242,8 +3692,9 @@ module Tuile
3242
3692
  # _@return_ — the tiled content component.
3243
3693
  attr_accessor content: Component?
3244
3694
 
3245
- # _@return_ — modal popups in stacking order; last is
3246
- # 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.
3247
3698
  attr_reader popups: ::Array[Component]
3248
3699
 
3249
3700
  # _@return_ — the bottom status bar.
@@ -3252,7 +3703,7 @@ module Tuile
3252
3703
 
3253
3704
  # An immutable string-with-styling, modeled as a sequence of {Span}s where
3254
3705
  # each span carries a complete {Style} (`fg`, `bg`, `bold`, `italic`,
3255
- # `underline`). Spans are non-overlapping and fully tile the string — every
3706
+ # `underline`, `strikethrough`). Spans are non-overlapping and fully tile the string — every
3256
3707
  # character has exactly one resolved style, no overlay layers to merge.
3257
3708
  #
3258
3709
  # Where this differs from threading SGR escapes through a plain `String`:
@@ -3292,12 +3743,21 @@ module Tuile
3292
3743
  #
3293
3744
  # ## Parser
3294
3745
  #
3295
- # {.parse} is strict by design: it recognizes only the SGR codes
3746
+ # {.parse} is strict by default: it recognizes only the SGR codes
3296
3747
  # corresponding to {Style}'s supported attributes (fg/bg/bold/italic/
3297
- # underline). Anything else — unmodeled attributes (dim, blink, reverse,
3298
- # strike, conceal, double-underline, overline, ...), unknown SGR codes, or
3748
+ # underline/strikethrough). Anything else — unmodeled attributes (dim, blink,
3749
+ # reverse, conceal, double-underline, overline, ...), unknown SGR codes, or
3299
3750
  # non-SGR escapes (cursor moves, OSC) — raises {ParseError}. This keeps the
3300
3751
  # round-trip parse(to_ansi(x)) == x contract honest.
3752
+ #
3753
+ # Pass `lenient: true` to instead **discard** everything the parser can't
3754
+ # model and keep going — recognized fg/bg/bold/italic/underline/strikethrough codes still
3755
+ # apply, and any unmodeled SGR code, malformed extended color, non-SGR CSI
3756
+ # (cursor moves, `\e[K`), OSC/DCS/string sequence, or stray escape is
3757
+ # silently dropped. This is the mode for piping in colored output you don't
3758
+ # control (e.g. `git --color` through a pager): "give me the colors, throw
3759
+ # the rest away." It is lossy by design — `parse(x, lenient: true)` does not
3760
+ # round-trip back to `x`.
3301
3761
  class StyledString
3302
3762
  EMPTY: StyledString
3303
3763
 
@@ -3317,7 +3777,9 @@ module Tuile
3317
3777
  # default-styled span.
3318
3778
  #
3319
3779
  # _@param_ `input`
3320
- def self.parse: ((String | StyledString)? input) -> StyledString
3780
+ #
3781
+ # _@param_ `lenient` — when true, unmodeled SGR codes and non-SGR escapes are discarded instead of raising — see {StyledString} "## Parser". Lossy: the result no longer round-trips to `input`.
3782
+ def self.parse: ((String | StyledString)? input, ?lenient: bool) -> StyledString
3321
3783
 
3322
3784
  # _@param_ `spans`
3323
3785
  def initialize: (?::Array[Span] spans) -> void
@@ -3404,7 +3866,7 @@ module Tuile
3404
3866
 
3405
3867
  # Returns a new {StyledString} with `bg` applied to every span, preserving
3406
3868
  # each span's text and other style attributes (`fg`, `bold`, `italic`,
3407
- # `underline`). Useful for row-level highlights — the new bg overlays
3869
+ # `underline`, `strikethrough`). Useful for row-level highlights — the new bg overlays
3408
3870
  # without dropping foreground colors the original styling carried.
3409
3871
  #
3410
3872
  # _@param_ `bg` — background color, coerced via {Color.coerce}. `nil` clears bg back to the terminal default.
@@ -3412,7 +3874,7 @@ module Tuile
3412
3874
 
3413
3875
  # Returns a new {StyledString} with `fg` applied to every span, preserving
3414
3876
  # each span's text and other style attributes (`bg`, `bold`, `italic`,
3415
- # `underline`). The new fg overlays without dropping background colors or
3877
+ # `underline`, `strikethrough`). The new fg overlays without dropping background colors or
3416
3878
  # text attributes the original styling carried.
3417
3879
  #
3418
3880
  # _@param_ `fg` — foreground color, coerced via {Color.coerce}. `nil` clears fg back to the terminal default.
@@ -3425,19 +3887,6 @@ module Tuile
3425
3887
  # _@param_ `spans`
3426
3888
  def normalize: (::Array[Span] spans) -> ::Array[Span]
3427
3889
 
3428
- # _@param_ `from`
3429
- #
3430
- # _@param_ `to`
3431
- def sgr_diff: (Style from, Style to) -> String
3432
-
3433
- # _@param_ `color`
3434
- #
3435
- # _@param_ `target` — `:fg` or `:bg`.
3436
- #
3437
- # _@return_ — SGR codes; `[39]` / `[49]` for the "default" reset
3438
- # when `color` is `nil`, otherwise delegated to {Color#sgr_codes}.
3439
- def color_codes: (Color? color, target: Symbol) -> ::Array[Integer]
3440
-
3441
3890
  # _@param_ `start_or_range`
3442
3891
  #
3443
3892
  # _@param_ `len`
@@ -3505,6 +3954,8 @@ module Tuile
3505
3954
  # @return [Boolean]
3506
3955
  # @!attribute [r] underline
3507
3956
  # @return [Boolean]
3957
+ # @!attribute [r] strikethrough
3958
+ # @return [Boolean]
3508
3959
  class Style
3509
3960
  DEFAULT: Style
3510
3961
 
@@ -3517,12 +3968,15 @@ module Tuile
3517
3968
  # _@param_ `italic`
3518
3969
  #
3519
3970
  # _@param_ `underline`
3971
+ #
3972
+ # _@param_ `strikethrough`
3520
3973
  def self.new: (
3521
3974
  ?fg: (Color | Symbol | Integer | ::Array[Integer])?,
3522
3975
  ?bg: (Color | Symbol | Integer | ::Array[Integer])?,
3523
3976
  ?bold: bool,
3524
3977
  ?italic: bool,
3525
- ?underline: bool
3978
+ ?underline: bool,
3979
+ ?strikethrough: bool
3526
3980
  ) -> Style
3527
3981
 
3528
3982
  def default?: () -> bool
@@ -3532,6 +3986,27 @@ module Tuile
3532
3986
  # _@param_ `overrides`
3533
3987
  def merge: (**::Hash[Symbol, Object] overrides) -> Style
3534
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
+
3535
4010
  attr_reader fg: Color?
3536
4011
 
3537
4012
  attr_reader bg: Color?
@@ -3541,6 +4016,8 @@ module Tuile
3541
4016
  attr_reader italic: bool
3542
4017
 
3543
4018
  attr_reader underline: bool
4019
+
4020
+ attr_reader strikethrough: bool
3544
4021
  end
3545
4022
 
3546
4023
  # A maximal run of text sharing a single {Style}. `text` is plain — it
@@ -3566,14 +4043,18 @@ module Tuile
3566
4043
  # @api private
3567
4044
  # Hand-rolled SGR parser. State machine over a {StringScanner}: plain
3568
4045
  # text accumulates into the current span; each `\e[...m` flushes the
3569
- # current span and updates the running {Style}. Anything outside the
3570
- # supported SGR alphabet raises {ParseError}.
4046
+ # current span and updates the running {Style}. In strict mode anything
4047
+ # outside the supported SGR alphabet raises {ParseError}; in lenient mode
4048
+ # it is consumed and discarded (see {StyledString} "## Parser").
3571
4049
  class Parser
3572
4050
  STANDARD_COLORS: ::Array[Symbol]
3573
4051
  BRIGHT_COLORS: ::Array[Symbol]
4052
+ STRING_INTRODUCERS: ::Array[String]
3574
4053
 
3575
4054
  # _@param_ `input`
3576
- def initialize: (String input) -> void
4055
+ #
4056
+ # _@param_ `lenient` — when true, discard unmodeled SGR codes and non-SGR escapes instead of raising {ParseError}.
4057
+ def initialize: (String input, ?lenient: bool) -> void
3577
4058
 
3578
4059
  def parse: () -> StyledString
3579
4060
 
@@ -3581,6 +4062,27 @@ module Tuile
3581
4062
 
3582
4063
  def consume_escape: () -> void
3583
4064
 
4065
+ # Consumes a CSI sequence (`\e[` already eaten). A well-formed SGR
4066
+ # (`\e[...m` with numeric/`;` params and no intermediates) is applied;
4067
+ # anything else is a non-SGR or malformed CSI — raises in strict mode,
4068
+ # swallowed in lenient. Scans the full CSI grammar (parameter bytes
4069
+ # `\x30-\x3F`, intermediate bytes `\x20-\x2F`, final byte) so lenient
4070
+ # mode consumes the whole sequence even for private-marker forms like
4071
+ # `\e[?25l`.
4072
+ def consume_csi: () -> void
4073
+
4074
+ # Lenient-only: discards a non-CSI escape (`\e` and `intro` already
4075
+ # eaten). OSC/DCS/string sequences run to their string terminator; an
4076
+ # nF escape (`\e( B`) eats its intermediates plus one final byte; any
4077
+ # other Fe/Fp/Fs escape was complete in `intro` alone.
4078
+ #
4079
+ # _@param_ `intro` — the byte after `\e` (never `"["`).
4080
+ def consume_non_csi: (String intro) -> void
4081
+
4082
+ # Lenient-only: swallows an OSC/DCS/string-sequence payload up to and
4083
+ # including its terminator (BEL, or ST `\e\\`), or to EOS if unterminated.
4084
+ def consume_string_sequence: () -> void
4085
+
3584
4086
  # _@param_ `params_str`
3585
4087
  def apply_sgr: (String params_str) -> void
3586
4088
 
@@ -3590,7 +4092,11 @@ module Tuile
3590
4092
  #
3591
4093
  # _@param_ `target` — either `:fg` or `:bg`.
3592
4094
  #
3593
- # _@return_ — how many SGR codes were consumed (3 for 256-color, 5 for RGB).
4095
+ # _@return_ — how many SGR codes were consumed. In lenient mode a
4096
+ # malformed color is skipped rather than applied, but the same count is
4097
+ # returned (3 for 256-color, 5 for RGB) so the running index advances
4098
+ # past its operands; an unknown selector skips just `38`/`48` + the
4099
+ # selector byte (2), letting the rest be reprocessed.
3594
4100
  def consume_extended_color: (::Array[Integer] codes, Integer index, Symbol target) -> Integer
3595
4101
 
3596
4102
  def flush: () -> void