tuile 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -0
  3. data/DECISIONS.md +1970 -14
  4. data/README.md +136 -491
  5. data/TERMINOLOGY.md +70 -0
  6. data/book/01-first-app.md +22 -17
  7. data/book/02-repaint.md +19 -6
  8. data/book/03-layout.md +12 -11
  9. data/book/05-focus.md +133 -18
  10. data/book/06-theming.md +6 -3
  11. data/book/07-components.md +498 -38
  12. data/book/08-testing.md +18 -4
  13. data/book/README.md +7 -5
  14. data/examples/file_commander.rb +27 -20
  15. data/examples/hello_world.rb +17 -5
  16. data/examples/sampler.rb +422 -66
  17. data/ideas/arrow-key-navigation.md +16 -0
  18. data/ideas/new-components.md +16 -10
  19. data/lib/tuile/ansi.rb +10 -0
  20. data/lib/tuile/buffer.rb +7 -7
  21. data/lib/tuile/component/abstract_string_field.rb +36 -0
  22. data/lib/tuile/component/button.rb +1 -1
  23. data/lib/tuile/component/checkbox.rb +1 -1
  24. data/lib/tuile/component/checkbox_group.rb +31 -26
  25. data/lib/tuile/component/combo_box.rb +13 -8
  26. data/lib/tuile/component/info_window.rb +1 -1
  27. data/lib/tuile/component/label.rb +14 -14
  28. data/lib/tuile/component/list.rb +313 -216
  29. data/lib/tuile/component/list_dropdown.rb +100 -10
  30. data/lib/tuile/component/menu_bar/cascade.rb +255 -0
  31. data/lib/tuile/component/menu_bar.rb +582 -0
  32. data/lib/tuile/component/notification.rb +320 -0
  33. data/lib/tuile/component/picker_window.rb +3 -8
  34. data/lib/tuile/component/popup.rb +83 -19
  35. data/lib/tuile/component/progress_bar.rb +1 -1
  36. data/lib/tuile/component/radio_group.rb +32 -30
  37. data/lib/tuile/component/select.rb +10 -8
  38. data/lib/tuile/component/tab_sheet.rb +242 -0
  39. data/lib/tuile/component/tabs.rb +528 -0
  40. data/lib/tuile/component/text_area/wrapped_text.rb +320 -0
  41. data/lib/tuile/component/text_area.rb +84 -277
  42. data/lib/tuile/component/text_field.rb +24 -7
  43. data/lib/tuile/component/text_view.rb +197 -180
  44. data/lib/tuile/component/window.rb +8 -8
  45. data/lib/tuile/component.rb +43 -18
  46. data/lib/tuile/event_queue.rb +25 -1
  47. data/lib/tuile/fake_screen.rb +14 -0
  48. data/lib/tuile/keys.rb +65 -0
  49. data/lib/tuile/screen.rb +95 -78
  50. data/lib/tuile/screen_pane.rb +109 -27
  51. data/lib/tuile/styled_string.rb +52 -12
  52. data/lib/tuile/version.rb +1 -1
  53. data/lib/tuile/vertical_scroll_bar.rb +6 -6
  54. data/sig/tuile.rbs +2307 -516
  55. metadata +9 -3
  56. data/mise.toml +0 -2
data/sig/tuile.rbs CHANGED
@@ -23,6 +23,7 @@ module Tuile
23
23
  # same form.
24
24
  module Ansi
25
25
  RESET: String
26
+ BEL: String
26
27
  SYNC_BEGIN: String
27
28
  SYNC_END: String
28
29
  end
@@ -78,6 +79,10 @@ module Tuile
78
79
  ENTER: String
79
80
  TAB: String
80
81
  SHIFT_TAB: String
82
+ BRACKETED_PASTE_ON: String
83
+ BRACKETED_PASTE_OFF: String
84
+ PASTE_START: String
85
+ PASTE_END: String
81
86
 
82
87
  # True iff `key` is a single printable character — a one-character string
83
88
  # whose codepoint is not in Unicode's C (Other) category. Rejects multi-
@@ -99,6 +104,43 @@ module Tuile
99
104
  #
100
105
  # _@return_ — key, such as {DOWN_ARROW}.
101
106
  def self.getkey: () -> String
107
+
108
+ # Reads the body of a bracketed paste, having just read {PASTE_START}, and
109
+ # returns it {.normalize_paste}d:
110
+ #
111
+ # Keys.getkey # => "\e[200~"
112
+ # Keys.read_paste # => "one\ntwo" (terminator consumed)
113
+ #
114
+ # Reads **raw**, one byte at a time, rather than looping on {.getkey}: a
115
+ # pasted `\e` would send `getkey` gulping five bytes of the *payload* as an
116
+ # escape tail, surfacing them as phantom keypresses. One byte at a time is
117
+ # also what keeps the terminator from being over-read — nothing past
118
+ # {PASTE_END} is consumed, so typing that lands behind a paste survives.
119
+ #
120
+ # Blocks until the terminator arrives; returns what it has at EOF.
121
+ #
122
+ # _@return_ — the pasted text, UTF-8, without the brackets.
123
+ def self.read_paste: () -> String
124
+
125
+ # Rewrites a raw paste payload into the one convention callers see: `\n`
126
+ # line endings, valid UTF-8.
127
+ #
128
+ # Keys.normalize_paste("a\r\nb\rc") # => "a\nb\nc"
129
+ #
130
+ # Both are terminal-layer artifacts, not text: a terminal with bracketed
131
+ # paste *off* rewrites the clipboard's `\n` to `\r` so a paste looks like
132
+ # typing, and several keep doing it inside the brackets — so the byte a
133
+ # line break arrives as is not something a component should have to know.
134
+ # Invalid bytes are scrubbed to `U+FFFD`, which is what lets
135
+ # {Component#handle_paste} take any clipboard, a binary file included,
136
+ # without the grapheme-cluster walk raising downstream.
137
+ #
138
+ # Control characters other than `\n` are left alone: they are content, and
139
+ # what a *text buffer* may hold is {Component::AbstractStringField}'s call,
140
+ # not this layer's.
141
+ #
142
+ # _@param_ `text` — raw payload.
143
+ def self.normalize_paste: (String text) -> String
102
144
  end
103
145
 
104
146
  # A rectangle, with integer `left`, `top`, `width` and `height`, all 0-based.
@@ -550,7 +592,7 @@ module Tuile
550
592
 
551
593
  # An in-memory grid of styled cells mirroring the terminal screen. This is
552
594
  # the back buffer behind flicker-free rendering: components paint into it
553
- # (via {#set_line} / {#set_char} / {#fill}) instead of writing escape
595
+ # (via {#set_text} / {#set_char} / {#fill}) instead of writing escape
554
596
  # sequences straight to the terminal, and {#flush} emits the minimal escape
555
597
  # string needed to bring a terminal — one that already matches the buffer's
556
598
  # state as of the previous flush — up to date. Only cells that actually
@@ -603,7 +645,7 @@ module Tuile
603
645
  # _@param_ `y` — row.
604
646
  #
605
647
  # _@return_ — the live cell at `(x, y)` (do not mutate — paint via
606
- # {#set_char} / {#set_line} so dirty tracking stays correct), or nil when
648
+ # {#set_char} / {#set_text} so dirty tracking stays correct), or nil when
607
649
  # out of bounds.
608
650
  def cell: (Integer x, Integer y) -> Cell?
609
651
 
@@ -632,14 +674,14 @@ module Tuile
632
674
 
633
675
  # Writes a {StyledString} starting at `(x, y)`, advancing by each grapheme's
634
676
  # display width and clipping at the right edge. Newlines are not handled —
635
- # pass one physical line.
677
+ # pass the text of one row.
636
678
  #
637
679
  # _@param_ `x` — starting column.
638
680
  #
639
681
  # _@param_ `y` — row.
640
682
  #
641
683
  # _@param_ `styled`
642
- def set_line: (Integer x, Integer y, StyledString styled) -> void
684
+ def set_text: (Integer x, Integer y, StyledString styled) -> void
643
685
 
644
686
  # Fills the intersection of `rect` and the buffer with blank cells in
645
687
  # `style` — the cell-grid equivalent of clearing a background. Only `bg`
@@ -695,7 +737,7 @@ module Tuile
695
737
  #
696
738
  # _@return_ — row `y` rendered to ANSI across its full width — the
697
739
  # minimal-SGR encoding of its cells, equivalent to what a component's
698
- # `set_line` of the whole row would have printed. Intended for tests that
740
+ # `set_text` of the whole row would have printed. Intended for tests that
699
741
  # assert on styled output (see {FakeScreen}); empty for an out-of-range row.
700
742
  def row_ansi: (Integer y) -> String
701
743
 
@@ -709,13 +751,13 @@ module Tuile
709
751
  # _@param_ `rect`
710
752
  #
711
753
  # _@return_ — each row within `rect` rendered to ANSI, top to
712
- # bottom — byte-identical to what a component's per-row `set_line` over
754
+ # bottom — byte-identical to what a component's per-row `set_text` over
713
755
  # that rect emitted. The region equivalent of {#row_ansi}. Intended for
714
756
  # tests asserting styled output.
715
757
  def region_ansi: (Rect rect) -> ::Array[String]
716
758
 
717
759
  # Core of {#set_char} with the grapheme's display width already known.
718
- # {#set_line} computes each width once while advancing the column and passes
760
+ # {#set_text} computes each width once while advancing the column and passes
719
761
  # it straight through, so the paint hot path measures every grapheme exactly
720
762
  # once (and that once is a {.display_width} memo read). See {#set_char} for
721
763
  # the wide-glyph / clipping / out-of-bounds contract.
@@ -879,10 +921,15 @@ module Tuile
879
921
  # Everything on screen hangs off {#pane} (a {ScreenPane}): the tiled
880
922
  # {#content} (set via {#content=}, filling the whole terminal and laying
881
923
  # out its own children), the modal/overlay {#popups} stack (opened via
882
- # {Component::Popup#open}, drawn on top of the content), and the bottom
883
- # status bar. Popups are *not* sized from their content — each carries its
884
- # own top-down {Component::Popup#size} — and they deliberately overdraw the
885
- # content without clipping.
924
+ # {Component::Popup#open}, drawn on top of the content). Popups are *not*
925
+ # sized from their content — each carries its own top-down
926
+ # {Component::Popup#size} — and they deliberately overdraw the content
927
+ # without clipping.
928
+ #
929
+ # Tuile draws no chrome of its own: there is no status bar and no reserved
930
+ # row, so {#content} gets the whole terminal. An app that wants a status line
931
+ # builds one into its own layout and drives it from {#on_focus_changed=}
932
+ # (`D-status-bar`).
886
933
  #
887
934
  # ## Repaint model
888
935
  #
@@ -957,22 +1004,6 @@ module Tuile
957
1004
  # _@param_ `component`
958
1005
  def invalidate: (Component component) -> void
959
1006
 
960
- # Rebuild the status-bar text from the current focus and global-shortcut
961
- # registry. Called from {#focused=} and whenever the global registry
962
- # changes. Popups own their own "q Close" prefix in `#keyboard_hint`;
963
- # for the tiled case Screen tacks on the global "q quit" instead.
964
- # Global-shortcut hints get spliced in too — see {#global_shortcut_hints}
965
- # for the over_popups filter rule.
966
- def refresh_status_bar: () -> void
967
-
968
- # Status-bar hints from currently-registered global shortcuts.
969
- # When a popup is open, only `over_popups: true` shortcuts contribute —
970
- # the rest don't fire in that context, so showing them would be a lie.
971
- # Insertion order is preserved (Hash iteration order).
972
- #
973
- # _@param_ `popup_open`
974
- def global_shortcut_hints: (popup_open: bool) -> ::Array[String]
975
-
976
1007
  # Internal — use {Component::Popup#open} instead. Adds the popup to
977
1008
  # {#pane}, centers and focuses it.
978
1009
  #
@@ -988,7 +1019,9 @@ module Tuile
988
1019
  # ownership reverts to the creating thread once it returns.
989
1020
  #
990
1021
  # _@param_ `capture_mouse` — when true (default), enables xterm mouse tracking so clicks and scroll wheel arrive as {MouseEvent}s and feed {Component#handle_mouse}. When false, no tracking escape sequence is written: the terminal keeps its native click handling, which is what you want if the app benefits more from select-to-copy than from click-to-focus. Components' `handle_mouse` is simply never invoked from the loop in that mode (the terminal stops sending the bytes).
991
- def run_event_loop: (?capture_mouse: bool) -> void
1022
+ #
1023
+ # _@param_ `bracketed_paste` — when true (default), enables DEC private mode 2004 so pasted text arrives whole, as {Component#handle_paste}, instead of as one keystroke per character — which is the only way a pasted line break can be told from a typed Enter. When false, a paste streams in as keys again and a component that gives ENTER a meaning fires it once per pasted line. Turn it off only for a terminal that mishandles the mode.
1024
+ def run_event_loop: (?capture_mouse: bool, ?bracketed_paste: bool) -> void
992
1025
 
993
1026
  # Advances focus to the next {Component#tab_stop?} in tree order, wrapping
994
1027
  # around. Scope is the topmost popup if one is open, otherwise {#content}
@@ -1021,18 +1054,14 @@ module Tuile
1021
1054
  # - **{EDITING_KEYS}** — `ENTER`, `BACKSPACE`, `DELETE` and the arrows,
1022
1055
  # which every editable widget needs.
1023
1056
  #
1024
- # screen.register_global_shortcut(Keys::CTRL_L,
1025
- # over_popups: true,
1026
- # hint: "^L #{screen.theme.hint("log")}") do
1057
+ # screen.register_global_shortcut(Keys::CTRL_L, over_popups: true) do
1027
1058
  # log_popup.open
1028
1059
  # end
1029
1060
  #
1030
1061
  # _@param_ `key` — unprintable key (e.g. {Keys::CTRL_L}, {Keys::ESC}).
1031
1062
  #
1032
1063
  # _@param_ `over_popups` — when true, fires even while a modal popup is open (pre-empting the popup); when false (default), suppressed while any popup is open so the popup gets the key.
1033
- #
1034
- # _@param_ `hint` — preformatted status-bar hint; nil (default) is silent. Colors are baked in — re-register after a {#theme=} to recolor.
1035
- def register_global_shortcut: (String key, ?over_popups: bool, ?hint: String?) -> void
1064
+ def register_global_shortcut: (String key, ?over_popups: bool) -> void
1036
1065
 
1037
1066
  # Removes a shortcut previously installed by {#register_global_shortcut}.
1038
1067
  # No-op if `key` was not registered.
@@ -1040,9 +1069,6 @@ module Tuile
1040
1069
  # _@param_ `key`
1041
1070
  def unregister_global_shortcut: (String key) -> void
1042
1071
 
1043
- # _@return_ — current active tiled component.
1044
- def active_window: () -> Component?
1045
-
1046
1072
  # Internal — use {Component::Popup#close} instead. Removes the popup
1047
1073
  # from {#pane}, repairs focus, and repaints the scene.
1048
1074
  #
@@ -1090,6 +1116,22 @@ module Tuile
1090
1116
  # _@param_ `args` — stuff to print.
1091
1117
  def print: (*String args) -> void
1092
1118
 
1119
+ # Rings the terminal bell ({Ansi::BEL}) — the signal for a keystroke that
1120
+ # went nowhere, e.g. a letter matching no menu mnemonic while a menu is
1121
+ # open.
1122
+ #
1123
+ # return true if activate_mnemonic(key)
1124
+ #
1125
+ # screen.beep # no match: the key is swallowed, say so
1126
+ # true
1127
+ #
1128
+ # Writes **immediately** rather than riding the next frame: a beep is not
1129
+ # part of a frame, and the keystrokes worth beeping at are precisely the
1130
+ # ones that invalidate nothing, so {#repaint} may never emit at all. Whether
1131
+ # the user hears anything is the terminal's setting to make, so there is no
1132
+ # Tuile-level enable/disable knob.
1133
+ def beep: () -> void
1134
+
1093
1135
  # Repaints the screen; tries to be as effective as possible, by only
1094
1136
  # considering invalidated components and flushing just the changed cells
1095
1137
  # of {#buffer}. Called once per event-loop tick (on {EventQueue::EmptyQueueEvent});
@@ -1173,6 +1215,19 @@ module Tuile
1173
1215
  # _@param_ `event`
1174
1216
  def handle_mouse: (MouseEvent event) -> void
1175
1217
 
1218
+ # Delivers pasted text down the focus chain ({ScreenPane#handle_paste}).
1219
+ #
1220
+ # Deliberately *not* the key ladder: a paste is not a keystroke, so it
1221
+ # skips Tab traversal and the global-shortcut registry entirely and goes
1222
+ # straight to delivery. Unhandled text is dropped — there is no fallback
1223
+ # that replays it as keys, which would put back the very ambiguity mode
1224
+ # 2004 exists to remove.
1225
+ #
1226
+ # _@param_ `text`
1227
+ #
1228
+ # _@return_ — true if some component consumed it.
1229
+ def handle_paste: (String text) -> bool
1230
+
1176
1231
  def event_loop: () -> void
1177
1232
 
1178
1233
  # _@return_ — the structural root of the component tree.
@@ -1182,7 +1237,7 @@ module Tuile
1182
1237
  attr_reader color_scheme: Symbol
1183
1238
 
1184
1239
  # _@return_ — the back buffer components paint into
1185
- # ({Buffer#set_line} / {Buffer#fill} / {Buffer#set_char}).
1240
+ # ({Buffer#set_text} / {Buffer#fill} / {Buffer#set_char}).
1186
1241
  attr_reader buffer: Buffer
1187
1242
 
1188
1243
  # Handler invoked when a {StandardError} escapes an event handler inside
@@ -1231,8 +1286,33 @@ module Tuile
1231
1286
  # _@return_ — currently focused component.
1232
1287
  attr_accessor focused: Component?
1233
1288
 
1234
- # Entry in the global shortcut registry: the block to run, whether it
1235
- # pre-empts open popups, and an optional preformatted status-bar hint.
1289
+ # Called after the focused component *changes* — including to and from
1290
+ # `nil`, and including the focus repair that runs when a popup closes.
1291
+ # Takes no arguments; read {#focused} (and walk its `parent` chain) for the
1292
+ # new state.
1293
+ #
1294
+ # This is the hook an app drives its own status line from. Tuile owns no
1295
+ # status bar and reserves no row: build a {Component::Label} into your own
1296
+ # layout and fill it here (`D-status-bar`).
1297
+ #
1298
+ # screen.on_focus_changed = -> { bar.text = hint_for(screen.focused) }
1299
+ #
1300
+ # **Edge-triggered**, like {Component#on_attached}: re-assigning the
1301
+ # component that already has focus fires nothing, so a callback can be as
1302
+ # expensive as rebuilding a hint string without a `did it really change?`
1303
+ # guard of its own. That matters more than it looks — `ScreenPane#content=`
1304
+ # clears focus on every content swap, which on a level-triggered hook would
1305
+ # fire a nil→nil notification during assembly.
1306
+ #
1307
+ # It runs *after* the active-flag cascade and `on_focus`, so the tree is
1308
+ # settled. Two things a callback must tolerate: {#focused} being `nil`, and
1309
+ # firing during {#close} — teardown clears focus, exactly as it fires
1310
+ # {Component#on_detached}. A raising callback propagates out of {#focused=}
1311
+ # and leaves focus assigned; keep it trivial, as with the attach hooks.
1312
+ attr_accessor on_focus_changed: Proc?
1313
+
1314
+ # Entry in the global shortcut registry: the block to run, and whether it
1315
+ # pre-empts open popups.
1236
1316
  # @api private
1237
1317
  class Shortcut < Data
1238
1318
  # Returns the value of attribute block
@@ -1240,9 +1320,6 @@ module Tuile
1240
1320
 
1241
1321
  # Returns the value of attribute over_popups
1242
1322
  attr_reader over_popups: Object
1243
-
1244
- # Returns the value of attribute hint
1245
- attr_reader hint: Object
1246
1323
  end
1247
1324
  end
1248
1325
 
@@ -1301,14 +1378,24 @@ module Tuile
1301
1378
  def effective_bg_color: () -> Color?
1302
1379
 
1303
1380
  # Repaints the component. The default does the bookkeeping most components
1304
- # need: it clears the background, and for a container whose children leave
1305
- # gaps in {#rect} it re-invalidates those children so they repaint over the
1306
- # cleared area (what makes mixed-width form layouts safe). A container whose
1307
- # children fully tile {#rect} is left alone — the children cover everything.
1381
+ # need: it clears the background — unless the direct children already tile
1382
+ # {#rect}, in which case there is no gap to wipe and blanking cells they are
1383
+ # about to repaint would only make them dirty — and then re-invalidates
1384
+ # those children so they paint over the cleared area. That is what makes
1385
+ # mixed-width form layouts safe.
1308
1386
  #
1309
1387
  # Call `super` from your own `repaint` to inherit this. Skip it only if you
1310
1388
  # paint the whole {#rect} yourself ({Window}'s border, {Component::List}'s
1311
1389
  # row-by-row paint). Never draw outside {#rect}. Only called when attached.
1390
+ #
1391
+ # **The children are re-invalidated whether or not they tile.** A container
1392
+ # that paints nothing of its own can only redraw its area *through* them, so
1393
+ # a tiling container that skipped this would be a dead end in the cascade: an
1394
+ # ancestor's `clear_background` wipes the whole ancestor rect — siblings and
1395
+ # grandchildren included — and re-invalidates only its *direct* children, so
1396
+ # the notice has to keep travelling down or the cleared cells are never
1397
+ # repainted. Cheap by construction: repainting the same glyphs leaves
1398
+ # {Buffer::Cell} unchanged, so nothing extra reaches the wire.
1312
1399
  def repaint: () -> void
1313
1400
 
1314
1401
  # Called when a key is pressed; override to act on keys you care about (the
@@ -1322,6 +1409,27 @@ module Tuile
1322
1409
  # _@return_ — true if the key was handled, false if not.
1323
1410
  def handle_key: (String _key) -> bool
1324
1411
 
1412
+ # Called when text is pasted while this component is on the focus chain;
1413
+ # override to accept it (the default reports every paste unhandled, and
1414
+ # unhandled text is dropped). Arrives whole and `\n`-normalized, so
1415
+ # `text.lines.size` is the paste's line count and a single mutation can
1416
+ # absorb it:
1417
+ #
1418
+ # def handle_paste(text)
1419
+ # self.caption = "[Pasted #{text.lines.size} lines]"
1420
+ # true
1421
+ # end
1422
+ #
1423
+ # Reaching here means the terminal said "this came from the clipboard" —
1424
+ # {Component::AbstractStringField} inserts it at the caret, which is why a
1425
+ # subclass that rebinds ENTER to submit needs no paste handling of its own
1426
+ # to stop firing once per pasted line.
1427
+ #
1428
+ # _@param_ `_text` — the pasted text.
1429
+ #
1430
+ # _@return_ — true if the paste was consumed.
1431
+ def handle_paste: (String _text) -> bool
1432
+
1325
1433
  # Handles mouse event. Default implementation focuses this component when
1326
1434
  # clicked (if {#focusable?}).
1327
1435
  #
@@ -1399,15 +1507,10 @@ module Tuile
1399
1507
  # _@return_ — absolute screen coordinates, or nil to hide.
1400
1508
  def cursor_position: () -> Point?
1401
1509
 
1402
- # _@return_ — formatted keyboard hint surfaced in the status bar by
1403
- # {Screen} when this component is the active tiled window or the
1404
- # topmost popup. Empty by default; override to advertise shortcuts.
1405
- def keyboard_hint: () -> String
1406
-
1407
1510
  # Adopts `child`: places it in {#children} and wires its parent pointer.
1408
1511
  #
1409
- # add_child(@status_bar) # paints last
1410
- # add_child(popup, at: @children.index(@status_bar)) # …just before it
1512
+ # add_child(content, at: 0) # the tiled layer, painted beneath …
1513
+ # add_child(@footer) # … and chrome appended, painted over it
1411
1514
  #
1412
1515
  # _@param_ `child` — must not already have a parent.
1413
1516
  #
@@ -1517,7 +1620,7 @@ module Tuile
1517
1620
  # _@param_ `area` — the region to blank; defaults to the whole {#rect}.
1518
1621
  def clear_background: (?Rect area) -> void
1519
1622
 
1520
- # {Buffer#set_line} wrapper that fills {#effective_bg_color} behind any span
1623
+ # {Buffer#set_text} wrapper that fills {#effective_bg_color} behind any span
1521
1624
  # with no bg of its own (via {StyledString#under_bg}), so an inherited
1522
1625
  # {#bg_color} shows through the content a component paints. A no-op layer
1523
1626
  # when nothing is inherited. Self-painters (those skipping the {#repaint}
@@ -1528,9 +1631,9 @@ module Tuile
1528
1631
  # _@param_ `y` — row.
1529
1632
  #
1530
1633
  # _@param_ `styled`
1531
- def draw_line: (Integer x, Integer y, StyledString styled) -> void
1634
+ def draw_text: (Integer x, Integer y, StyledString styled) -> void
1532
1635
 
1533
- # {#draw_line}'s single-grapheme counterpart: writes `grapheme` at `(x, y)`,
1636
+ # {#draw_text}'s single-grapheme counterpart: writes `grapheme` at `(x, y)`,
1534
1637
  # filling {#effective_bg_color} when `style` carries no bg of its own.
1535
1638
  #
1536
1639
  # _@param_ `x` — column.
@@ -1576,7 +1679,7 @@ module Tuile
1576
1679
  # Called on every attached component (pre-order, popups included) when
1577
1680
  # {Screen#theme} changes — at {Screen#theme=} / {Screen#theme_def=} and on
1578
1681
  # OS appearance flips. The hook exists for app *content* whose colors were
1579
- # baked in from the old theme (a {Label#text} / {List#lines} {StyledString}
1682
+ # baked in from the old theme (a {Label#text} / {List#lines=} {StyledString}
1580
1683
  # styled with `theme[:accent]`); rebuild it here by re-running the code that
1581
1684
  # rendered it. See book ch6 for why built-in accents need no such handling.
1582
1685
  #
@@ -1586,62 +1689,84 @@ module Tuile
1586
1689
  # {#on_theme_changed=} listener keeps firing.
1587
1690
  attr_accessor on_theme_changed: Proc?
1588
1691
 
1589
- # A scrollable list of items with cursor support.
1692
+ # A scrollable list of typed items, one row each, with cursor support.
1693
+ #
1694
+ # list = Component::List.new
1695
+ # list.items = people
1696
+ # list.renderer = ->(p) { StyledString.plain(p.name) + screen.theme.hint(" #{p.email}") }
1697
+ # list.cursor = List::Cursor.new # a bare list has none
1698
+ # list.on_item_chosen = ->(index, person) { open(person) }
1590
1699
  #
1591
- # Items are modeled as {StyledString}s and painted directly into the
1592
- # component's {#rect}. Lines wider than the viewport are ellipsized via
1593
- # {StyledString#ellipsize} with span styles preserved across the cut.
1594
- # Vertical scrolling is via {#top_line}; enable {#auto_scroll} to keep the
1595
- # bottom in view.
1700
+ # The {#renderer} turns an item into one row; the default renders an item
1701
+ # as itself, so a list of `String`s or {StyledString}s needs none — which
1702
+ # is what {#lines=} and {#build_lines} are, items that are their own
1703
+ # rendering (split on `\n`, one row per line).
1596
1704
  #
1597
- # Cursor is supported; call {#cursor=} to change cursor behavior. The
1598
- # cursor responds to arrows, `jk`, Home/End, Ctrl+U/D and scrolls the
1599
- # list automatically. The cursor highlight overlays
1600
- # {Theme#active_bg_color} while preserving each span's foreground color.
1705
+ # There are no appenders. The items are always assigned whole — an app that
1706
+ # grows a list keeps its own array and re-assigns it (`list.items = mine`) —
1707
+ # so a `List` stays a *snapshot of a collection*, which is the only shape a
1708
+ # lazily-sourced provider could ever fill.
1709
+ #
1710
+ # Rows wider than the viewport are ellipsized via {StyledString#ellipsize}
1711
+ # with span styles preserved across the cut. Vertical scrolling is via
1712
+ # {#scroll_top_row}; enable {#auto_scroll} to keep the bottom in view. The cursor
1713
+ # responds to arrows, `jk`, Home/End, Ctrl+U/D and scrolls the list
1714
+ # automatically; its highlight overlays {Theme#active_bg_color} while
1715
+ # preserving each span's foreground color.
1716
+ #
1717
+ # == Implementation details
1718
+ # Rendering is lazy: only the rows in the viewport are rendered, each
1719
+ # memoized until {#items=}, {#renderer=} or a width change drops the cache.
1720
+ # So a renderer runs *at paint time*, on any frame — keep it pure and
1721
+ # cheap; work that reaches a service belongs in the item, not in the
1722
+ # renderer. {#select_next} deliberately renders without memoizing: one
1723
+ # failed scan would otherwise cache a row per item.
1601
1724
  class List < Component
1725
+ DEFAULT_RENDERER: Proc
1726
+
1602
1727
  def initialize: () -> void
1603
1728
 
1604
1729
  # _@return_ — whether {#auto_scroll} is currently tailing. True
1605
- # while the viewport sits at the last line; flips to false the moment
1730
+ # while the viewport sits at the last row; flips to false the moment
1606
1731
  # the user scrolls up, and back to true once they scroll to the bottom
1607
1732
  # again. Only consulted when {#auto_scroll} is enabled.
1608
1733
  def following?: () -> bool
1609
1734
 
1610
- # Sets new lines. Each entry is coerced into a {StyledString} (a
1611
- # `String` is parsed via {StyledString.parse}, so embedded ANSI is
1612
- # honored; a {StyledString} is used as-is; anything else is stringified
1613
- # via `#to_s` first), then split on `\n` into separate lines via
1614
- # {StyledString#lines}, with trailing empty pieces dropped and trailing
1615
- # ASCII whitespace stripped — symmetric with {#add_lines}, so the
1616
- # stored `@lines` is always `Array<StyledString>`.
1735
+ # Re-renders every row — for a {#renderer} whose *inputs* changed while
1736
+ # it and {#items} stayed the same, e.g. one prefixing a marker read from
1737
+ # a selection it closes over:
1738
+ #
1739
+ # def value=(new_value) # RadioGroup: the marked row moved
1740
+ # super
1741
+ # content.refresh_rows
1742
+ # end
1743
+ def refresh_rows: () -> void
1744
+
1745
+ # Sets the items from line-flavored input: each entry is coerced into a
1746
+ # {StyledString} (a `String` is parsed via {StyledString.parse}, so
1747
+ # embedded ANSI is honored; a {StyledString} is used as-is; anything else
1748
+ # is stringified via `#to_s` first), then split on `\n` into separate
1749
+ # lines via {StyledString#lines}, with trailing empty pieces dropped and
1750
+ # trailing ASCII whitespace stripped. The resulting {StyledString}s
1751
+ # *are* the {#items}, so under the
1752
+ # {DEFAULT_RENDERER} each is its own row.
1617
1753
  #
1618
1754
  # _@param_ `lines` — entries are `String`, `StyledString`, or anything that responds to `#to_s`.
1619
1755
  def lines=: (::Array[untyped] lines) -> void
1620
1756
 
1621
- # Without a block, returns the current lines. With a block, fully
1622
- # re-populates the list:
1757
+ # Fully re-populates the list, line-flavored: yields a fresh buffer and
1758
+ # assigns it through {#lines=}, so each entry is coerced, split and
1759
+ # rstripped exactly as there. The buffer is a plain `Array`, which a
1760
+ # builder can read mid-build — recording the row a {Cursor::Limited} may
1761
+ # land on is the case this exists for:
1623
1762
  # ```ruby
1624
- # list.lines do |buffer|
1625
- # buffer << "Hello!"
1763
+ # list.build_lines do |lines|
1764
+ # cursor_positions << lines.size
1765
+ # lines << format_overview(vm)
1766
+ # lines << format_detail(vm)
1626
1767
  # end
1627
1768
  # ```
1628
- #
1629
- # _@return_ — current lines (when called without a
1630
- # block).
1631
- def lines: () ?{ (::Array[untyped] buffer) -> void } -> ::Array[StyledString]
1632
-
1633
- # sord duck - #to_s looks like a duck type with an equivalent RBS interface, replacing with _ToS
1634
- # Adds a line.
1635
- #
1636
- # _@param_ `line`
1637
- def add_line: ((String | StyledString | _ToS) line) -> void
1638
-
1639
- # Appends given lines. Each entry is parsed the same way as in
1640
- # {#lines=}: coerced to a {StyledString}, split on `\n`, with trailing
1641
- # empty pieces dropped and trailing ASCII whitespace stripped.
1642
- #
1643
- # _@param_ `lines` — entries are `String`, `StyledString`, or anything that responds to `#to_s`.
1644
- def add_lines: (::Array[untyped] lines) -> void
1769
+ def build_lines: () ?{ (::Array[untyped] buffer) -> void } -> void
1645
1770
 
1646
1771
  def focusable?: () -> bool
1647
1772
 
@@ -1652,15 +1777,15 @@ module Tuile
1652
1777
  # _@return_ — true if the key was handled.
1653
1778
  def handle_key: (String key) -> bool
1654
1779
 
1655
- # Moves the cursor to the next line whose text contains `query`
1780
+ # Moves the cursor to the next item whose text contains `query`
1656
1781
  # (case-insensitive substring match). Search wraps around the end of the
1657
- # list. Only lines reachable by the current {#cursor} are considered.
1658
- # Matching uses the line's plain text — span styles do not affect the
1782
+ # list. Only items reachable by the current {#cursor} are considered.
1783
+ # Matching uses the rendered row's plain text — span styles do not affect the
1659
1784
  # match.
1660
1785
  #
1661
1786
  # _@param_ `query` — substring to match. Empty query never matches.
1662
1787
  #
1663
- # _@param_ `include_current` — when true, the current cursor position is eligible (useful when the query has just changed and the current line may still match); when false, the search starts after the current position (useful for "find next" key bindings that should advance past the current).
1788
+ # _@param_ `include_current` — when true, the current cursor position is eligible (useful when the query has just changed and the current item may still match); when false, the search starts after the current position (useful for "find next" key bindings that should advance past the current).
1664
1789
  #
1665
1790
  # _@return_ — true if a match was found.
1666
1791
  def select_next: (String query, ?include_current: bool) -> bool
@@ -1674,25 +1799,42 @@ module Tuile
1674
1799
  # _@return_ — true if a match was found.
1675
1800
  def select_prev: (String query, ?include_current: bool) -> bool
1676
1801
 
1802
+ # Moves the cursor to the item at `index`, scrolling it into view and
1803
+ # firing {#on_cursor_changed} — the positional member of the
1804
+ # {#select_next} / {#select_prev} family, for a caller that already knows
1805
+ # *which* item it wants:
1806
+ #
1807
+ # list.select(items.index(chosen))
1808
+ #
1809
+ # Refuses an index the current {#cursor} can't reach — out of range, or
1810
+ # anything at all under {Cursor::None} — rather than stranding the cursor
1811
+ # off-content.
1812
+ #
1813
+ # _@param_ `index`
1814
+ #
1815
+ # _@return_ — whether the cursor moved there.
1816
+ def select: (Integer index) -> bool
1817
+
1677
1818
  # _@param_ `event`
1678
1819
  def handle_mouse: (MouseEvent event) -> void
1679
1820
 
1680
- # Paints the list items into {#rect}.
1821
+ # Paints the visible items into {#rect}, rendering the ones not already
1822
+ # cached.
1681
1823
  #
1682
1824
  # Skips the {Component#repaint} default's auto-clear: every row of
1683
1825
  # {#rect} is painted below (with blank padding past the last item),
1684
1826
  # so the parent contract — "fully draw over your rect" — is met
1685
- # without an upfront wipe. Rows go through {Component#draw_line}, so
1827
+ # without an upfront wipe. Rows go through {Component#draw_text}, so
1686
1828
  # content *and* blank filler inherit {Component#effective_bg_color}
1687
1829
  # (a {#bg_color} set here or on an ancestor); the cursor row's
1688
1830
  # {Theme#active_bg_color} highlight composes on top of it.
1689
1831
  def repaint: () -> void
1690
1832
 
1691
- # Rebuilds pre-padded lines when the wrap width changes. The wrap width
1692
- # depends on {#rect}`.width` and the scrollbar gutter, both of which
1693
- # trigger this hook. Also re-evaluates {#auto_scroll}: if items were
1694
- # appended while the rect was empty (e.g. a {Popup}-wrapped list got
1695
- # `add_line` calls before the popup was opened), the auto-scroll update
1833
+ # Drops the rendered-row cache when the wrap width changes. The wrap
1834
+ # width depends on {#rect}`.width` and the scrollbar gutter, both of
1835
+ # which trigger this hook. Also re-evaluates {#auto_scroll}: if items were
1836
+ # assigned while the rect was empty (e.g. a {Popup}-wrapped list was
1837
+ # populated before the popup was opened), the auto-scroll update
1696
1838
  # was skipped because there was no viewport — re-run it now that there
1697
1839
  # is one, so the list snaps to the bottom on first paint.
1698
1840
  def on_width_changed: () -> void
@@ -1702,7 +1844,7 @@ module Tuile
1702
1844
  # via {StyledString.parse}, StyledString passed through, anything else
1703
1845
  # via `#to_s`), then split on `\n` via {StyledString#lines} — with
1704
1846
  # trailing empty pieces dropped (matching `String#split("\n")`'s
1705
- # default behavior, so `add_line ""` is a no-op) — and trailing ASCII
1847
+ # default behavior, so a lone `""` entry adds no row) — and trailing ASCII
1706
1848
  # whitespace stripped on each resulting line.
1707
1849
  #
1708
1850
  # _@param_ `entries`
@@ -1719,16 +1861,16 @@ module Tuile
1719
1861
  # _@param_ `line`
1720
1862
  def rstrip_styled: (StyledString line) -> StyledString
1721
1863
 
1722
- # _@return_ — true if the cursor sits on a real content line.
1864
+ # _@return_ — true if the cursor sits on a real item.
1723
1865
  def cursor_on_item?: () -> bool
1724
1866
 
1725
- # Calls {#on_item_chosen} with the cursor's current `(index, line)`.
1867
+ # Calls {#on_item_chosen} with the cursor's current `(index, item)`.
1726
1868
  # Caller must ensure {#cursor_on_item?}.
1727
1869
  def fire_item_chosen: () -> void
1728
1870
 
1729
- # _@return_ — `[position, line_at_position]`, with `line` nil when the cursor is
1871
+ # _@return_ — `[position, item_at_position]`, with the item nil when the cursor is
1730
1872
  # off-content.
1731
- def cursor_state: () -> [[Integer, StyledString, NilClass]]
1873
+ def cursor_state: () -> [[Integer, Object, NilClass]]
1732
1874
 
1733
1875
  # Fires {#on_cursor_changed} if {#cursor_state} differs from the last
1734
1876
  # fired state. Idempotent — safe to call after any mutation.
@@ -1762,97 +1904,115 @@ module Tuile
1762
1904
  # Scrolls the viewport so the cursor is visible.
1763
1905
  def move_viewport_to_cursor: () -> void
1764
1906
 
1765
- # _@return_ — the max value of {#top_line}.
1766
- def top_line_max: () -> Integer
1907
+ # _@return_ — the max value of {#scroll_top_row}.
1908
+ def scroll_top_row_max: () -> Integer
1767
1909
 
1768
- # _@return_ — whether the viewport is pinned to the last line.
1769
- # Drives {#following?}: re-evaluated on every {#top_line=}.
1910
+ # _@return_ — whether the viewport is pinned to the last row.
1911
+ # Drives {#following?}: re-evaluated on every {#scroll_top_row=}.
1770
1912
  def at_bottom?: () -> bool
1771
1913
 
1772
- # _@return_ — the number of visible lines.
1773
- def viewport_lines: () -> Integer
1914
+ # _@return_ — the number of visible rows.
1915
+ def viewport_rows: () -> Integer
1774
1916
 
1775
1917
  # Scrolls the list.
1776
1918
  #
1777
1919
  # _@param_ `delta` — negative scrolls up, positive scrolls down.
1778
- def move_top_line_by: (Integer delta) -> void
1920
+ def move_scroll_top_row_by: (Integer delta) -> void
1779
1921
 
1780
- # If auto-scrolling, recalculate the top line and snap the cursor to the
1922
+ # If auto-scrolling, recalculate the top row and snap the cursor to the
1781
1923
  # last reachable position. Without the cursor snap the viewport gets
1782
1924
  # yanked back to wherever the cursor sat on the next arrow press,
1783
1925
  # negating the auto-scroll. Skipped when {#rect} is empty: without a
1784
- # viewport the "lines minus viewport" formula yields `@lines.size`,
1785
- # which would leave `top_line` past the last item once a real rect
1926
+ # viewport the "items minus viewport" formula yields `@items.size`,
1927
+ # which would leave `scroll_top_row` past the last item once a real rect
1786
1928
  # arrives. {#on_width_changed} re-runs this hook when the rect grows so
1787
1929
  # the snap-to-bottom intent is preserved.
1788
1930
  #
1789
1931
  # Gated on {#following?}: once the user scrolls up off the bottom the
1790
1932
  # cursor snap and viewport pin are both skipped, so reading older
1791
- # content is not interrupted by incoming lines. {#top_line=} re-arms
1933
+ # content is not interrupted by incoming items. {#scroll_top_row=} re-arms
1792
1934
  # `@follow` when the viewport returns to the bottom.
1793
- def update_top_line_if_auto_scroll: () -> void
1935
+ def update_scroll_top_row_if_auto_scroll: () -> void
1794
1936
 
1795
1937
  # _@return_ — whether the scrollbar should be drawn right now.
1796
1938
  def scrollbar_visible?: () -> bool
1797
1939
 
1798
- # _@return_ — column width available for line content (rect width
1940
+ # _@return_ — column width available for row content (rect width
1799
1941
  # minus the scrollbar gutter, when visible). `0` when {#rect}'s width
1800
1942
  # is non-positive.
1801
1943
  def content_width: () -> Integer
1802
1944
 
1803
- # Recomputes {@padded_lines} for the current rect width and scrollbar
1804
- # visibility. Each line is ellipsized to fit and pre-padded with
1805
- # single-space gutters on each side, so {#paintable_line} only has to
1806
- # apply the cursor highlight (if any) and append the scrollbar glyph.
1807
- def rebuild_padded_lines: () -> void
1945
+ # Discards every rendered row, so the next paint re-renders the viewport
1946
+ # against the current items, renderer and width.
1947
+ def drop_row_cache: () -> void
1948
+
1949
+ # _@param_ `index` — 0-based index into {#items}.
1950
+ #
1951
+ # _@return_ — the item's padded row, rendered on first use and
1952
+ # memoized until {#drop_row_cache}.
1953
+ def padded_row: (Integer index) -> StyledString
1954
+
1955
+ # _@return_ — the blank row painted past the last item.
1956
+ def blank_row: () -> StyledString
1957
+
1958
+ # Renders one item, *without* populating the row cache — {#search_and_go}
1959
+ # scans with this, and caching a failed scan would grow the cache to one
1960
+ # row per item.
1961
+ #
1962
+ # _@param_ `item`
1963
+ #
1964
+ # _@return_ — one row: the {#renderer}'s output coerced to a
1965
+ # {StyledString}, cut to its first line since a `\n` reaching the buffer
1966
+ # would corrupt the frame.
1967
+ def render: (Object item) -> StyledString
1808
1968
 
1809
- # Pads `line` to one full row of the viewport (scrollbar gutter
1810
- # excluded). Lines wider than the content area are ellipsized via
1969
+ # Pads `row` to one full row of the viewport (scrollbar gutter
1970
+ # excluded). Rows wider than the content area are ellipsized via
1811
1971
  # {StyledString#ellipsize} (span styles survive the cut); shorter
1812
- # lines are padded with default-styled spaces.
1972
+ # ones are padded with default-styled spaces.
1813
1973
  #
1814
- # _@param_ `line`
1974
+ # _@param_ `row`
1815
1975
  #
1816
1976
  # _@return_ — exactly {#content_width} display columns wide
1817
1977
  # (or {StyledString::EMPTY} when content_width is non-positive).
1818
- def pad_to_row: (StyledString line) -> StyledString
1978
+ def pad_to_row: (StyledString row) -> StyledString
1819
1979
 
1820
- # _@param_ `index` — 0-based index into {#lines}.
1980
+ # _@param_ `index` — 0-based index into {#items}.
1821
1981
  #
1822
1982
  # _@param_ `row_in_viewport` — 0-based row within the viewport.
1823
1983
  #
1824
1984
  # _@param_ `scrollbar` — scrollbar instance, or nil if not shown.
1825
1985
  #
1826
- # _@return_ — paintable line exactly `rect.width` columns wide;
1986
+ # _@return_ — paintable row exactly `rect.width` columns wide;
1827
1987
  # highlighted if cursor is here.
1828
- def paintable_line: (Integer index, Integer row_in_viewport, VerticalScrollBar? scrollbar) -> StyledString
1988
+ def paintable_row: (Integer index, Integer row_in_viewport, VerticalScrollBar? scrollbar) -> StyledString
1829
1989
 
1830
1990
  # _@return_ — callback fired when an item is chosen — by pressing
1831
- # Enter on the cursor's item, or by left-clicking an item. Called as
1832
- # `proc.call(index, line)` with the chosen 0-based index and its
1833
- # {StyledString} line. Never fires when the cursor's position is
1834
- # outside the content (e.g. {Cursor::None}, or empty content).
1991
+ # Enter on the cursor's item, or by left-clicking it. Called as
1992
+ # `proc.call(index, item)` with the chosen 0-based index and the item
1993
+ # itself. Never fires when the cursor's position is outside the content
1994
+ # (e.g. {Cursor::None}, or empty content).
1835
1995
  attr_accessor on_item_chosen: Proc?
1836
1996
 
1837
- # _@return_ — callback fired when the `(index, line)` tuple under
1838
- # the cursor changes. Called as `proc.call(index, line)` where `line`
1839
- # is the {StyledString} at the cursor, or `nil` when the cursor is
1997
+ # _@return_ — callback fired when the `(index, item)` tuple under
1998
+ # the cursor changes (items compared with `==`). Called as
1999
+ # `proc.call(index, item)`, with `item` `nil` when the cursor is
1840
2000
  # off-content ({Cursor::None}, empty list, or `index` past the last
1841
- # line). Fires on cursor moves (key, mouse, search), on {#cursor=},
1842
- # and on {#lines=}/{#add_lines} when the line at the cursor's index
2001
+ # item). Fires on cursor moves (key, mouse, search), on {#cursor=},
2002
+ # and on {#items=} when the item at the cursor's index
1843
2003
  # changes (or its in-range/out-of-range status flips). Useful for
1844
2004
  # keeping a details pane in sync with the highlighted row.
1845
2005
  attr_accessor on_cursor_changed: Proc?
1846
2006
 
1847
- # _@return_ — if true and a line is added or new content is set,
1848
- # auto-scrolls to the bottom — but only while the viewport is already
1849
- # pinned to the last line (see {#following?}). Scroll up to read older
1850
- # content and appends stop yanking you back down; scroll back to the
1851
- # bottom and tailing resumes.
2007
+ # _@return_ — if true and new content is set, auto-scrolls to the
2008
+ # bottom — but only while the viewport is already pinned to the last
2009
+ # row (see {#following?}). Scroll up to read older content and
2010
+ # incoming rows stop yanking you back down; scroll back to the bottom
2011
+ # and tailing resumes.
1852
2012
  attr_accessor auto_scroll: bool
1853
2013
 
1854
- # _@return_ — top line of the viewport. 0 or positive.
1855
- attr_accessor top_line: Integer
2014
+ # _@return_ — top row of the viewport. 0 or positive.
2015
+ attr_accessor scroll_top_row: Integer
1856
2016
 
1857
2017
  # _@return_ — the list's cursor.
1858
2018
  attr_accessor cursor: Cursor
@@ -1865,34 +2025,42 @@ module Tuile
1865
2025
  # Defaults to false.
1866
2026
  attr_accessor show_cursor_when_inactive: bool
1867
2027
 
2028
+ # _@return_ — the items, one row each.
2029
+ attr_accessor items: ::Array[untyped]
2030
+
2031
+ # _@return_ — item -> row: a {StyledString}, a `String` (parsed,
2032
+ # so embedded ANSI is honored), or anything with `#to_s`. Only the first
2033
+ # line of a multi-line rendering is kept — one item is one row.
2034
+ attr_accessor renderer: (Proc | Method)
2035
+
1868
2036
  # Tracks cursor position within the list.
1869
2037
  class Cursor
1870
2038
  # _@param_ `position` — the initial cursor position.
1871
2039
  def initialize: (?position: Integer) -> void
1872
2040
 
1873
- # _@param_ `line_count` — number of lines in the list.
2041
+ # _@param_ `item_count` — number of items in the list.
1874
2042
  #
1875
2043
  # _@return_ — positions the cursor can land on, in
1876
2044
  # ascending order.
1877
- def candidate_positions: (Integer line_count) -> ::Array[Integer]
2045
+ def candidate_positions: (Integer item_count) -> ::Array[Integer]
1878
2046
 
1879
2047
  # _@param_ `key` — pressed keyboard key.
1880
2048
  #
1881
- # _@param_ `line_count` — number of lines in the list.
2049
+ # _@param_ `item_count` — number of items in the list.
1882
2050
  #
1883
- # _@param_ `viewport_lines` — number of visible lines.
2051
+ # _@param_ `viewport_rows` — number of visible rows.
1884
2052
  #
1885
2053
  # _@return_ — true if the cursor moved.
1886
- def handle_key: (String key, Integer line_count, Integer viewport_lines) -> bool
2054
+ def handle_key: (String key, Integer item_count, Integer viewport_rows) -> bool
1887
2055
 
1888
- # _@param_ `line` — cursor is hovering over this line.
2056
+ # _@param_ `item_index` — the item the cursor is hovering over.
1889
2057
  #
1890
2058
  # _@param_ `event` — the event.
1891
2059
  #
1892
- # _@param_ `line_count` — number of lines in the list.
2060
+ # _@param_ `item_count` — number of items in the list.
1893
2061
  #
1894
2062
  # _@return_ — true if the event was handled.
1895
- def handle_mouse: (Integer line, MouseEvent event, Integer line_count) -> bool
2063
+ def handle_mouse: (Integer item_index, MouseEvent event, Integer item_count) -> bool
1896
2064
 
1897
2065
  # Moves the cursor to the new position. Public only because of testing.
1898
2066
  #
@@ -1902,25 +2070,25 @@ module Tuile
1902
2070
  def go: (Integer new_position) -> bool
1903
2071
 
1904
2072
  # Moves the cursor to the last reachable position. For base {Cursor},
1905
- # the last line; {Limited} clamps to the last allowed position; {None}
2073
+ # the last item; {Limited} clamps to the last allowed position; {None}
1906
2074
  # is a no-op.
1907
2075
  #
1908
- # _@param_ `line_count` — number of lines in the list.
2076
+ # _@param_ `item_count` — number of items in the list.
1909
2077
  #
1910
2078
  # _@return_ — true if the position changed.
1911
- def go_to_last: (Integer line_count) -> bool
2079
+ def go_to_last: (Integer item_count) -> bool
1912
2080
 
1913
- # _@param_ `lines`
2081
+ # _@param_ `count`
1914
2082
  #
1915
- # _@param_ `line_count`
1916
- def go_down_by: (Integer lines, Integer line_count) -> bool
2083
+ # _@param_ `item_count`
2084
+ def go_down_by: (Integer count, Integer item_count) -> bool
1917
2085
 
1918
- # _@param_ `lines`
1919
- def go_up_by: (Integer lines) -> bool
2086
+ # _@param_ `count`
2087
+ def go_up_by: (Integer count) -> bool
1920
2088
 
1921
2089
  def go_to_first: () -> bool
1922
2090
 
1923
- # _@return_ — 0-based line index of the current cursor position.
2091
+ # _@return_ — 0-based item index of the current cursor position.
1924
2092
  attr_reader position: Integer
1925
2093
 
1926
2094
  # No cursor — cursor is disabled.
@@ -1929,20 +2097,20 @@ module Tuile
1929
2097
 
1930
2098
  # _@param_ `_key`
1931
2099
  #
1932
- # _@param_ `_line_count`
2100
+ # _@param_ `_item_count`
1933
2101
  #
1934
- # _@param_ `_viewport_lines`
1935
- def handle_key: (String _key, Integer _line_count, Integer _viewport_lines) -> bool
2102
+ # _@param_ `_viewport_rows`
2103
+ def handle_key: (String _key, Integer _item_count, Integer _viewport_rows) -> bool
1936
2104
 
1937
- # _@param_ `_line`
2105
+ # _@param_ `_item_index`
1938
2106
  #
1939
2107
  # _@param_ `_event`
1940
2108
  #
1941
- # _@param_ `_line_count`
1942
- def handle_mouse: (Integer _line, MouseEvent _event, Integer _line_count) -> bool
2109
+ # _@param_ `_item_count`
2110
+ def handle_mouse: (Integer _item_index, MouseEvent _event, Integer _item_count) -> bool
1943
2111
 
1944
- # _@param_ `_line_count`
1945
- def candidate_positions: (Integer _line_count) -> ::Array[Integer]
2112
+ # _@param_ `_item_count`
2113
+ def candidate_positions: (Integer _item_count) -> ::Array[Integer]
1946
2114
 
1947
2115
  # Overridden so all movement funnels — base {Cursor#go_to_last},
1948
2116
  # {Cursor#go_to_first}, etc., which all call {#go} — become safe
@@ -1955,39 +2123,352 @@ module Tuile
1955
2123
  def go: (Integer _new_position) -> bool
1956
2124
  end
1957
2125
 
1958
- # Cursor which can only land on specific allowed lines.
2126
+ # Cursor which can only land on specific allowed items.
1959
2127
  class Limited < Cursor
1960
2128
  # _@param_ `positions` — allowed positions. Must not be empty.
1961
2129
  #
1962
2130
  # _@param_ `position` — initial position.
1963
2131
  def initialize: (::Array[Integer] positions, ?position: Integer) -> void
1964
2132
 
1965
- # _@param_ `line`
2133
+ # _@param_ `item_index`
1966
2134
  #
1967
2135
  # _@param_ `event`
1968
2136
  #
1969
- # _@param_ `_line_count`
1970
- def handle_mouse: (Integer line, MouseEvent event, Integer _line_count) -> bool
2137
+ # _@param_ `_item_count`
2138
+ def handle_mouse: (Integer item_index, MouseEvent event, Integer _item_count) -> bool
1971
2139
 
1972
- # _@param_ `line_count`
1973
- def candidate_positions: (Integer line_count) -> ::Array[Integer]
2140
+ # _@param_ `item_count`
2141
+ def candidate_positions: (Integer item_count) -> ::Array[Integer]
1974
2142
 
1975
- # _@param_ `_line_count`
1976
- def go_to_last: (Integer _line_count) -> bool
2143
+ # _@param_ `_item_count`
2144
+ def go_to_last: (Integer _item_count) -> bool
1977
2145
 
1978
- # _@param_ `lines`
2146
+ # _@param_ `count`
1979
2147
  #
1980
- # _@param_ `line_count`
1981
- def go_down_by: (Integer lines, Integer line_count) -> bool
2148
+ # _@param_ `item_count`
2149
+ def go_down_by: (Integer count, Integer item_count) -> bool
1982
2150
 
1983
- # _@param_ `lines`
1984
- def go_up_by: (Integer lines) -> bool
2151
+ # _@param_ `count`
2152
+ def go_up_by: (Integer count) -> bool
1985
2153
 
1986
2154
  def go_to_first: () -> bool
1987
2155
  end
1988
2156
  end
1989
2157
  end
1990
2158
 
2159
+ # A one-row strip of captions with exactly one of them selected — the map of
2160
+ # where the user is. Knows nothing about content: pair it with
2161
+ # {Component::TabSheet} to swap panes, or swap views yourself from
2162
+ # {#on_tab_selected}.
2163
+ #
2164
+ # ␣Details␣│␣Payment␣│␣Shipping␣
2165
+ # ^^^^^^^ selected: bold, and highlighted while the strip has focus
2166
+ #
2167
+ # tabs = Component::Tabs.new
2168
+ # tabs.add_tab("Details") # the first tab is selected
2169
+ # payment = tabs.add_tab("Payment")
2170
+ # tabs.on_tab_selected = ->(index, tab) { show(index) }
2171
+ # tabs.selected = payment # fires the listener
2172
+ # payment.caption = "Payment ⚠" # repaints the strip
2173
+ #
2174
+ # LEFT / RIGHT switch tabs immediately — no cursor to move first, no Enter
2175
+ # to confirm — clamping at both ends rather than wrapping; a left click
2176
+ # selects the tab under the pointer. Everything else bubbles to an ancestor,
2177
+ # Enter, Space, Up, Down, Home and End included, so a form's default button
2178
+ # and the app's own keys keep working while the strip has focus.
2179
+ #
2180
+ # One tab stop for the whole strip: Tab moves *past* it, never between its
2181
+ # tabs. For a key of your own that switches tabs from elsewhere in the app,
2182
+ # bind it yourself and call {#select_next} / {#select_previous}.
2183
+ #
2184
+ # {Tab} handles are minted by {#add_tab} and owned by the strip. There is no
2185
+ # `items=`: a tab is identity plus its own state, so the set grows and
2186
+ # shrinks one tab at a time. See book ch7 and `DECISIONS.md` `D-tabs`.
2187
+ #
2188
+ # == Sizing
2189
+ # Assign a {#rect} (typically from the surrounding {Layout}). One wider than
2190
+ # {#extent}`.width` leaves a dead tail; a narrower one **scrolls**. The strip
2191
+ # keeps the selected segment whole in view, moving its window by the minimum
2192
+ # needed, so arrowing into an off-screen tab brings that tab on screen — and
2193
+ # a click on a half-visible segment at an edge selects it and pulls it into
2194
+ # view. A `<` or `>` painted over an edge column says there is more strip
2195
+ # that way, as does the cut caption underneath it. The one thing that cannot
2196
+ # be shown whole is a caption wider than the entire rect: it shows its head
2197
+ # and clips its tail. The scroll offset itself is not API — the invariant is.
2198
+ #
2199
+ # == Implementation details
2200
+ # A segment is one space of padding, the caption, one space of padding, and
2201
+ # segments are joined by a single {DEFAULT_SEPARATOR} column. The padding
2202
+ # belongs to the segment: the highlight covers it and a click on it selects
2203
+ # the tab, while the separator column is chrome and selects nothing, like
2204
+ # the blank tail past {#extent}. One private `segments` method is the sole
2205
+ # source of that arithmetic — both the paint and the hit test read it, and
2206
+ # both offset it by the same scroll column, so a click cannot land on a tab
2207
+ # other than the one drawn under it — and it is
2208
+ # derived from the captions on each call rather than recorded during the
2209
+ # last paint, so a hit test is correct before the first paint.
2210
+ #
2211
+ # The selected caption is bold *always*, so the strip still says where you
2212
+ # are once focus has moved on, and additionally sits on
2213
+ # {Theme#active_bg_color} while the strip is on the focus chain. Bold is the
2214
+ # selection channel and not strip chrome: bolding every caption would leave
2215
+ # selection to the focus-gated background alone, and an unfocused strip
2216
+ # would then show no selection at all.
2217
+ class Tabs < Component
2218
+ DEFAULT_SEPARATOR: String
2219
+
2220
+ # _@param_ `separator` — see {#separator=}.
2221
+ def initialize: (?separator: (String | StyledString)) -> void
2222
+
2223
+ # _@return_ — `true` — the strip takes focus, so its arrows work.
2224
+ def focusable?: () -> bool
2225
+
2226
+ # _@return_ — `true` — one stop for the whole strip.
2227
+ def tab_stop?: () -> bool
2228
+
2229
+ # _@return_ — the selected tab; `nil` only while there are no tabs.
2230
+ def selected: () -> Tab?
2231
+
2232
+ # _@param_ `tab` — one of this strip's tabs.
2233
+ def selected=: (Tab tab) -> void
2234
+
2235
+ # Appends a tab and returns its handle. The first tab added becomes the
2236
+ # selection; later ones don't disturb it.
2237
+ #
2238
+ # _@param_ `caption` — parsed as {Tab#caption=} parses it.
2239
+ def add_tab: (?(String | StyledString)? caption) -> Tab
2240
+
2241
+ # Removes `tab` and detaches its handle permanently.
2242
+ #
2243
+ # The selection is never left dangling: removing the selected tab selects
2244
+ # whichever tab slid into its place (the new last tab, if it was the last),
2245
+ # and removing the final tab leaves {#selected} `nil`. Either way
2246
+ # {#on_tab_selected} fires — the empty case with `(nil, nil)`, since a
2247
+ # listener rendering from the selection has to be told to render nothing.
2248
+ #
2249
+ # _@param_ `tab` — one of this strip's tabs.
2250
+ def remove_tab: (Tab tab) -> void
2251
+
2252
+ # Selects the next tab, clamping at the last — the strip never wraps.
2253
+ # Public because it is the verb an app's own key binding drives.
2254
+ #
2255
+ # _@return_ — `false` only when there are no tabs.
2256
+ def select_next: () -> bool
2257
+
2258
+ # Selects the previous tab, clamping at the first.
2259
+ #
2260
+ # _@return_ — `false` only when there are no tabs.
2261
+ def select_previous: () -> bool
2262
+
2263
+ # The cells the strip actually paints: one row, as wide as its segments and
2264
+ # separators need, clipped to {#rect}. A layout routinely hands a strip a
2265
+ # window's full width for a 32-column strip — the extent is those 32
2266
+ # columns.
2267
+ #
2268
+ # Both the focus highlight and the click hit test use it, so a click on the
2269
+ # blank tail — or on a lower row, when the rect is taller than one —
2270
+ # selects nothing. It still *focuses*: {Component#handle_mouse}'s
2271
+ # click-to-focus is ungated by geometry.
2272
+ def extent: () -> Rect
2273
+
2274
+ # Switches tabs on LEFT / RIGHT, consuming the key even at the ends of the
2275
+ # strip (the selection clamps). Every other key is left unhandled so it
2276
+ # bubbles to an ancestor; an empty strip handles nothing at all.
2277
+ #
2278
+ # _@param_ `key`
2279
+ def handle_key: (String key) -> bool
2280
+
2281
+ # Selects the tab under a left click; `super` runs first, so a click
2282
+ # anywhere in {#rect} still focuses.
2283
+ #
2284
+ # _@param_ `event`
2285
+ def handle_mouse: (MouseEvent event) -> void
2286
+
2287
+ def repaint: () -> void
2288
+
2289
+ # Re-syncs the scroll offset and repaints — what every change to the
2290
+ # captions, the separator or the selection ends in.
2291
+ def refresh: () -> void
2292
+
2293
+ # The rect's *width* is the only part of it the offset depends on, so this
2294
+ # hook is the whole geometry story; {Component#rect=} invalidates for us.
2295
+ def on_width_changed: () -> void
2296
+
2297
+ # Scrolls the minimum needed to show the selected segment whole, and is the
2298
+ # sole writer of {#left_column}. Idempotent, so every mutation site can
2299
+ # call it blindly; it returns the offset to `0` on its own once the strip
2300
+ # fits again, which is why no mutator owes a scroll-back branch.
2301
+ #
2302
+ # A segment wider than the whole rect cannot be shown whole: its head wins,
2303
+ # being the half of a caption that identifies it.
2304
+ def adjust_left_column: () -> void
2305
+
2306
+ # {StyledString#slice} *drops* a cluster straddling the window's edge
2307
+ # rather than half-painting it, which would leave the painted row a column
2308
+ # short and shift everything past the hole one column left — paint and hit
2309
+ # test would then disagree, silently and only for wide glyphs. So the
2310
+ # offset only ever lands on a cluster boundary. Snapping *forward* is the
2311
+ # safe direction: it gives up at most one column of the segment to the left
2312
+ # of the window, never of the one being revealed.
2313
+ #
2314
+ # _@param_ `column`
2315
+ #
2316
+ # _@return_ — the smallest cluster-boundary column `>= column`.
2317
+ def snap_to_glyph_start: (Integer column) -> Integer
2318
+
2319
+ # Paints the overflow cues over the windowed row's edge columns: `<` when
2320
+ # segments sit to the left of the window, `>` when more sit to the right.
2321
+ # ASCII by convention rather than by constant, as {Checkbox}'s brackets
2322
+ # are, and *overlaid* rather than given reserved columns — reserving would
2323
+ # make the window width a function of the offset computed from it.
2324
+ #
2325
+ # _@param_ `row` — the windowed row, as painted.
2326
+ def draw_cues: (StyledString row) -> void
2327
+
2328
+ # The cue keeps the style of the cell it covers, so one landing on the
2329
+ # selected segment doesn't punch a default-background hole in its
2330
+ # highlight.
2331
+ #
2332
+ # _@param_ `row` — the windowed row.
2333
+ #
2334
+ # _@param_ `column` — relative to {#rect}`.left`.
2335
+ #
2336
+ # _@param_ `glyph`
2337
+ def draw_cue: (StyledString row, Integer column, String glyph) -> void
2338
+
2339
+ # One `[tab, start_column, width]` triple per tab, in strip order, in
2340
+ # columns relative to {#rect}`.left`. A segment's width is its caption plus
2341
+ # the two padding columns; the separator columns between segments belong to
2342
+ # no segment.
2343
+ def segments: () -> ::Array[[Tab, Integer, Integer]]
2344
+
2345
+ # _@return_ — columns the strip would paint given an unlimited rect.
2346
+ def painted_width: () -> Integer
2347
+
2348
+ # _@param_ `point`
2349
+ #
2350
+ # _@return_ — the tab painted at `point`; `nil` for a separator
2351
+ # column, the blank tail, or a row the strip doesn't paint.
2352
+ def tab_at: (Point point) -> Tab?
2353
+
2354
+ # _@return_ — the whole strip as one row, unclipped: segments
2355
+ # left to right, joined by the separator. {#repaint} windows it to the
2356
+ # rect; nothing else may, since the window's own arithmetic is
2357
+ # {#adjust_left_column}'s.
2358
+ def strip_row: () -> StyledString
2359
+
2360
+ # _@param_ `tab`
2361
+ #
2362
+ # _@param_ `index`
2363
+ #
2364
+ # _@return_ — the caption between its padding columns, styled
2365
+ # for the selection.
2366
+ def segment_text: (Tab tab, Integer index) -> StyledString
2367
+
2368
+ # _@param_ `tab`
2369
+ #
2370
+ # _@return_ — the tab's position on this strip.
2371
+ def index_of!: (Tab tab) -> Integer
2372
+
2373
+ # _@param_ `delta` — `+1` / `-1`.
2374
+ #
2375
+ # _@return_ — `false` only when there are no tabs.
2376
+ def step_selection: (Integer delta) -> bool
2377
+
2378
+ # _@param_ `index`
2379
+ def select_at: (Integer? index) -> void
2380
+
2381
+ # Stores the selection, repaints, and fires {#on_tab_selected} when the
2382
+ # selected *tab* changed.
2383
+ #
2384
+ # `previous` is passed in rather than read here because a removal can leave
2385
+ # the index numerically unchanged while a different tab sits under it —
2386
+ # remove the selected middle tab of three and index 1 now holds what used
2387
+ # to be index 2. Comparing indices would swallow that notification.
2388
+ #
2389
+ # _@param_ `index` — the new selection.
2390
+ #
2391
+ # _@param_ `previous` — the tab selected before the caller's change.
2392
+ def apply_selection: (Integer? index, Tab? previous) -> void
2393
+
2394
+ # Called with `@tabs` already shortened and `@selected_index` still holding
2395
+ # the pre-removal position.
2396
+ #
2397
+ # _@param_ `removed_index` — the position the removed tab held.
2398
+ #
2399
+ # _@return_ — where the selection lands.
2400
+ def selection_after_removing: (Integer removed_index) -> Integer?
2401
+
2402
+ # Called on every change of {#selected} with the new selection —
2403
+ # `(index, tab)`, or `(nil, nil)` once the last tab has been removed.
2404
+ #
2405
+ # It reports that the selection *changed*, not that the user pressed
2406
+ # something: arrows, a click, {#selected=} / {#selected_index=}, the
2407
+ # autoselect of the first {#add_tab} and the re-selection that follows
2408
+ # removing the selected tab all fire it. Re-selecting the tab already
2409
+ # selected fires nothing.
2410
+ attr_accessor on_tab_selected: Proc?
2411
+
2412
+ # _@return_ — the tabs, in strip order. Read-only by convention
2413
+ # (like {Component#children}) — grow and shrink it through {#add_tab} /
2414
+ # {#remove_tab}, which keep the selection consistent. Enumerate it to
2415
+ # find a tab: `tabs.find { |t| t.caption.to_s == "Payment" }`.
2416
+ attr_reader tabs: ::Array[Tab]
2417
+
2418
+ # _@return_ — the column painted between two segments.
2419
+ attr_accessor separator: (StyledString | String)
2420
+
2421
+ # _@return_ — the selected tab's position; `nil` only while
2422
+ # there are no tabs.
2423
+ attr_accessor selected_index: Integer?
2424
+
2425
+ # _@return_ — the strip column painted in {#rect}'s leftmost cell —
2426
+ # the horizontal scroll offset. `0` unless the strip overflows its rect;
2427
+ # {#adjust_left_column} is its sole writer.
2428
+ attr_reader left_column: Integer
2429
+
2430
+ # A single tab: a caption, plus its identity on the strip.
2431
+ #
2432
+ # tab = tabs.add_tab("Payment")
2433
+ # tab.caption = "Payment ⚠" # repaints the strip
2434
+ # tab.remove # `tab` now raises on every mutator
2435
+ #
2436
+ # Apps don't construct tabs; {Tabs#add_tab} mints them. A removed handle
2437
+ # raises {RuntimeError} on every mutator and on every reader that consults
2438
+ # the strip — answering confidently about a tab the strip no longer holds
2439
+ # would hide the bug. {#caption} and {#attached?} stay readable (the
2440
+ # caption lives here, so an error message can still name it), and
2441
+ # {#remove} is a silent no-op so a cleanup path can call it blindly.
2442
+ class Tab
2443
+ # _@param_ `strip` — the owning strip.
2444
+ #
2445
+ # _@param_ `caption` — already coerced by the caller.
2446
+ def initialize: (Tabs strip, StyledString caption) -> void
2447
+
2448
+ # _@return_ — `true` while the tab is owned by its {Tabs}; `false`
2449
+ # permanently once removed.
2450
+ def attached?: () -> bool
2451
+
2452
+ # _@return_ — whether this is the strip's selected tab.
2453
+ def selected?: () -> bool
2454
+
2455
+ # Removes this tab from its strip and detaches the handle permanently —
2456
+ # {Tabs#remove_tab} has what that does to the selection. Idempotent on an
2457
+ # already-removed tab, unlike the mutators.
2458
+ def remove: () -> void
2459
+
2460
+ def inspect: () -> String
2461
+
2462
+ def detach: () -> void
2463
+
2464
+ def check_attached: () -> void
2465
+
2466
+ # _@return_ — the label painted on the strip. Safe to read on
2467
+ # a removed tab.
2468
+ attr_accessor caption: (StyledString | String)?
2469
+ end
2470
+ end
2471
+
1991
2472
  # A label which shows static text. No word-wrapping; long lines are
1992
2473
  # truncated with an ellipsis. Text is modeled as a {StyledString};
1993
2474
  # {#text=} accepts a {String} (parsed via {StyledString.parse}, so
@@ -2002,19 +2483,19 @@ module Tuile
2002
2483
  # Skips the {Component#repaint} default's auto-clear: every row is
2003
2484
  # painted explicitly (with pre-padded blanks past the last line), so
2004
2485
  # the "fully draw over your rect" contract is met without an upfront
2005
- # wipe. Rows go through {Component#draw_line}, so the padding and blank
2486
+ # wipe. Rows go through {Component#draw_text}, so the padding and blank
2006
2487
  # rows inherit {Component#effective_bg_color} when {#bg} is unset.
2007
2488
  def repaint: () -> void
2008
2489
 
2009
2490
  def on_width_changed: () -> void
2010
2491
 
2011
- # Recomputes {@clipped_lines} for the current text and rect width.
2492
+ # Recomputes {@rows} for the current text and rect width.
2012
2493
  # Each line is ellipsized to fit and padded with trailing spaces out to
2013
- # the full width, so {#repaint} is just a lookup + {Buffer#set_line} per
2014
- # row. {@blank_line} covers rows past the last text line. When {#bg} is
2494
+ # the full width, so {#repaint} is just a lookup + {Buffer#set_text} per
2495
+ # row. {@blank_row} covers rows past the last text line. When {#bg} is
2015
2496
  # set, every produced line (and the blank row) has the bg applied
2016
2497
  # uniformly.
2017
- def update_clipped_lines: () -> void
2498
+ def update_rows: () -> void
2018
2499
 
2019
2500
  # _@param_ `line`
2020
2501
  def apply_bg: (StyledString line) -> StyledString
@@ -2074,6 +2555,10 @@ module Tuile
2074
2555
  # declined it (see {ScreenPane#handle_key}). That's why typing `q` into a
2075
2556
  # nested {Component::TextField} doesn't dismiss the popup: the field
2076
2557
  # consumes it first.
2558
+ #
2559
+ # A left click *outside* the popup closes it too, modal or not — see
2560
+ # {#close_on_outside_click?} for the exact contract and {#on_close=} for
2561
+ # the notice a driver hears when it happens.
2077
2562
  class Popup < Component
2078
2563
  include Tuile::Component::HasContent
2079
2564
 
@@ -2082,11 +2567,41 @@ module Tuile
2082
2567
  # _@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).
2083
2568
  #
2084
2569
  # _@param_ `size` — the popup's size, applied top-down. A {Fraction} is resolved against the screen each layout pass; a {Size} is clamped to the screen. Defaults to {Fraction::HALF}.
2085
- def initialize: (?content: Component?, ?modal: bool, ?size: (Size | Fraction)) -> void
2570
+ #
2571
+ # _@param_ `close_on_outside_click` — true (default) to dismiss on a left click that misses this popup. See {#close_on_outside_click?}.
2572
+ def initialize: (
2573
+ ?content: Component?,
2574
+ ?modal: bool,
2575
+ ?size: (Size | Fraction),
2576
+ ?close_on_outside_click: bool
2577
+ ) -> void
2086
2578
 
2087
2579
  # _@return_ — whether this popup is modal. See {#initialize}.
2088
2580
  def modal?: () -> bool
2089
2581
 
2582
+ # Whether a left click outside this popup closes it (default true, modal or
2583
+ # not). The pane does the closing — {ScreenPane#handle_mouse} snapshots
2584
+ # the open popups *before* routing the click and closes the dismissable
2585
+ # ones *after*, so a widget that toggles its own overlay from a click on
2586
+ # its face (a {Component::Select}, a {Component::MenuBar} title) still
2587
+ # toggles correctly: the delivered click closes the overlay and the
2588
+ # dismissal then no-ops on it, rather than closing and reopening it. Only
2589
+ # `:left` dismisses; scroll and right clicks never do.
2590
+ #
2591
+ # **"Outside" spans the {#owner} chain, not just this rect.** A click
2592
+ # counts as inside this popup when it lands in its rect *or* in any popup
2593
+ # that belongs to it — so a dialog is not dismissed by a click on a
2594
+ # dropdown its own field opened, and a menu cascade is not dismissed by a
2595
+ # click on one of its deeper panels. Popups with no owner relationship are
2596
+ # independent: clicking one dismisses the other, which is what a
2597
+ # window-like overlay should do. A popup that must survive unrelated
2598
+ # clicks entirely ({Component::Notification}) sets this false.
2599
+ #
2600
+ # Every dismissable popup closes, not just the topmost, and stacking order
2601
+ # plays no part: a {Component::MenuBar} cascade must vanish whole on one
2602
+ # click on the background, not peel one panel per click.
2603
+ def close_on_outside_click?: () -> bool
2604
+
2090
2605
  def focusable?: () -> bool
2091
2606
 
2092
2607
  # Reassigns the popup's rect, escalating to a full scene repaint when an
@@ -2102,18 +2617,13 @@ module Tuile
2102
2617
 
2103
2618
  # Mounts this popup on the {Screen}, re-resolving its {#size} against the
2104
2619
  # current screen first.
2105
- def open: () -> void
2106
-
2107
- # Constructs and opens a popup in one call.
2108
2620
  #
2109
- # _@param_ `content`
2110
- #
2111
- # _@param_ `modal` — see {#initialize}.
2621
+ # popup = Component::Popup.new(content: window).open # construct and mount
2112
2622
  #
2113
- # _@param_ `size` — see {#initialize}.
2114
- #
2115
- # _@return_ — the opened popup.
2116
- def self.open: (?content: Component?, ?modal: bool, ?size: (Size | Fraction)) -> Popup
2623
+ # There is deliberately no class-level `Popup.open` factory — see
2624
+ # `DECISIONS.md` `D-popup-open`; returning `self` is what keeps the
2625
+ # one-liner above available without one.
2626
+ def open: () -> self
2117
2627
 
2118
2628
  # Removes this popup from the {Screen}. No-op if not currently open.
2119
2629
  def close: () -> void
@@ -2137,9 +2647,6 @@ module Tuile
2137
2647
  # Recenters the popup on the screen, preserving its current width/height.
2138
2648
  def center: () -> void
2139
2649
 
2140
- # Hint for the status bar: own "q Close" plus the wrapped content's hint.
2141
- def keyboard_hint: () -> String
2142
-
2143
2650
  # `q` and ESC close the popup. The popup sits on the focus chain of
2144
2651
  # whatever it wraps, so the key reaches here by bubbling up from the
2145
2652
  # focused content after that content declined to handle it.
@@ -2149,6 +2656,10 @@ module Tuile
2149
2656
  # _@return_ — true if the key was handled.
2150
2657
  def handle_key: (String key) -> bool
2151
2658
 
2659
+ # Fires {#on_close}. A subclass overriding this **must** call `super`, or
2660
+ # the popup's driver never hears that it closed.
2661
+ def on_detached: () -> void
2662
+
2152
2663
  # Content fills the popup's full rect — Popup has no border to subtract.
2153
2664
  #
2154
2665
  # _@param_ `content`
@@ -2161,6 +2672,37 @@ module Tuile
2161
2672
 
2162
2673
  # _@return_ — the popup's declared size. See {#size=}.
2163
2674
  attr_accessor size: (Size | Fraction)
2675
+
2676
+ # _@return_ — see {#close_on_outside_click?}.
2677
+ attr_writer close_on_outside_click: bool
2678
+
2679
+ # The component this overlay is *part of*, or `nil` (the default) when it
2680
+ # is an overlay in its own right. It exists for outside-click dismissal:
2681
+ # a click inside this popup also counts as inside whatever popup encloses
2682
+ # its owner, so the host is not dismissed by a click on a panel it put
2683
+ # there. See {#close_on_outside_click?}.
2684
+ #
2685
+ # Set it to the *driver* — {Component::ComboBox} hands its dropdown
2686
+ # `self` — rather than to the enclosing popup: the driver knows what it
2687
+ # is, while the popup above it is a tree relationship the pane resolves
2688
+ # at click time (so it cannot go stale). Any {Component} is accepted, and
2689
+ # a `Popup` resolves to itself, which is how a
2690
+ # {Component::MenuBar::Cascade} chains each panel to the one it dropped
2691
+ # out of.
2692
+ attr_accessor owner: Component?
2693
+
2694
+ # A callback taking no arguments, fired once this popup has left the
2695
+ # screen — **however it left**: {#close}, a direct {Screen#remove_popup},
2696
+ # an outside click, or teardown via {Screen#close}. That unconditionality
2697
+ # is the point, so it hangs off {#on_detached} rather than {#close}; a
2698
+ # driver keeping its own record of open popups reconciles it here and
2699
+ # cannot drift ({Component::MenuBar::Cascade} is the worked example).
2700
+ #
2701
+ # It fires *after* the popup is detached, so {#open?} is already false and
2702
+ # the usual {Component#on_detached} caveats apply: release state, don't
2703
+ # inspect the tree, keep it trivial (it may run while the pane is mid-way
2704
+ # through closing a batch of popups, and a raise propagates).
2705
+ attr_accessor on_close: Proc?
2164
2706
  end
2165
2707
 
2166
2708
  # A clickable button. Activated by Enter, Space, or a left mouse click;
@@ -2721,8 +3263,6 @@ module Tuile
2721
3263
 
2722
3264
  def tab_stop?: () -> bool
2723
3265
 
2724
- def keyboard_hint: () -> String
2725
-
2726
3266
  # Re-anchors the (open) dropdown after a move or resize.
2727
3267
  #
2728
3268
  # _@param_ `new_rect`
@@ -2766,10 +3306,10 @@ module Tuile
2766
3306
 
2767
3307
  def close_menu: () -> void
2768
3308
 
2769
- # Adopts the item on row `index` as {#value} and closes the dropdown.
3309
+ # Adopts the chosen item as {#value} and closes the dropdown.
2770
3310
  #
2771
- # _@param_ `index`
2772
- def commit: (Integer index) -> void
3311
+ # _@param_ `item`
3312
+ def commit: (Object item) -> void
2773
3313
 
2774
3314
  def anchor: () -> void
2775
3315
 
@@ -2867,15 +3407,15 @@ module Tuile
2867
3407
  # _@param_ `content`
2868
3408
  def layout: (Component content) -> void
2869
3409
 
2870
- # Paints the window border via {Component#draw_line}/{Component#draw_char},
3410
+ # Paints the window border via {Component#draw_text}/{Component#draw_char},
2871
3411
  # so the border cells inherit {Component#effective_bg_color} — a
2872
3412
  # {Component#bg_color} on the window tints border and content alike. Both
2873
- # border lines are clipped by *display* width, so no caption overflows the
3413
+ # border rows are clipped by *display* width, so no caption overflows the
2874
3414
  # box; when the window is active the whole border — the caption's own
2875
3415
  # colors included — is drawn in {Theme#active_border_color}.
2876
3416
  def repaint_border: () -> void
2877
3417
 
2878
- # Builds the top border line: corners, {#caption} embedded at its own
3418
+ # Builds the top border row: corners, {#caption} embedded at its own
2879
3419
  # width, dashes filling the remainder. The caption keeps its own styling
2880
3420
  # unless `fg` is set — an active window's border claims it.
2881
3421
  #
@@ -2884,7 +3424,7 @@ module Tuile
2884
3424
  # _@param_ `fg` — the active-border color, or nil when inactive.
2885
3425
  def top_border: (Integer inner_w, Color? fg) -> StyledString
2886
3426
 
2887
- # Builds the bottom border line. The corners take the border color; the
3427
+ # Builds the bottom border row. The corners take the border color; the
2888
3428
  # interior is plain dashes when a {#footer} component occupies the row
2889
3429
  # (it overpaints them) or when there's no chrome, otherwise it carries
2890
3430
  # {#footer_text} embedded at its own width — keeping the text's own
@@ -3053,86 +3593,573 @@ module Tuile
3053
3593
  def focusable?: () -> bool
3054
3594
  end
3055
3595
 
3056
- # A text field with a filtering dropdown: type to narrow the candidates,
3057
- # arrow to move the highlight, Enter (or click) to accept. Its {#value} is
3058
- # the *selected item* — of whatever type the items are — not the display
3059
- # string, so a combo over domain objects hands back the object:
3060
- #
3061
- # combo = Component::ComboBox.new
3062
- # combo.items = User.all # Array of any type
3063
- # combo.item_label = ->(u) { u.full_name } # item -> shown text; default :to_s
3064
- # combo.on_value_change = ->(u) { open(u) } # fires on commit, with the item
3065
- # combo.value = some_user # selects it; field shows its label
3066
- #
3067
- # It's the assembly you'd otherwise wire by hand — a {TextField} plus a
3068
- # non-modal {Popup} over a {List} — promoted to one component. Give it a
3069
- # single-row {#rect}; it paints the field across that row with a `▾` in the
3070
- # last column and floats the dropdown above or below.
3071
- #
3072
- # == The two values
3073
- # {#value} (the committed selection) and the field's typed text (a transient
3074
- # *query*) are deliberately distinct. Keystrokes move the query and refilter
3075
- # the list; only Enter/click commits, and only a commit changes {#value} and
3076
- # fires {#on_value_change}. An uncommitted query reverts to the current
3077
- # value's label when the dropdown is dismissed (ESC) or the combo loses
3078
- # focus. Selecting by list index (not by matching the label back) is what
3079
- # lets two items share a label and still resolve to the right object.
3596
+ # A one-row strip of menu captions, each dropping open a cascade of submenus
3597
+ # that nests as deep as you build it.
3598
+ #
3599
+ # ␣File␣␣Edit␣␣View␣ <- the strip; highlighted while focused
3600
+ # ␣New␣␣␣␣␣␣␣␣␣ <- the open menu, measured to its widest label
3601
+ # ␣Recent␣␣␣␣▸␣ <- a row that opens a submenu
3602
+ # ␣Quit␣␣␣␣␣␣␣␣ (the outer gutters are {List}'s)
3603
+ #
3604
+ # bar = Component::MenuBar.new
3605
+ # file = bar.add_item("File", mnemonic: "f")
3606
+ # file.add_item("New", mnemonic: "n") { new_document }
3607
+ # recent = file.add_item("Recent") # no block ⇒ a submenu holder
3608
+ # recent.add_item("notes.txt") { open("notes.txt") }
3609
+ # bar.add_item("Quit") { screen.close } # a top-level leaf: a button
3610
+ #
3611
+ # LEFT / RIGHT move along the strip; Enter, Space or Down opens the
3612
+ # highlighted menu. Inside a menu: Up / Down (and PgUp/PgDn, Ctrl+U/D) move
3613
+ # the highlight, Enter or Space activates a row or opens its submenu, RIGHT
3614
+ # opens a submenu, LEFT returns to the previous menu, ESC closes one level.
3615
+ # LEFT at the first level and RIGHT on a row with no submenu step to the
3616
+ # sibling menu, as they do in every menu bar. Book ch7 has the table.
3617
+ #
3618
+ # == Mnemonics
3619
+ # An item given a `mnemonic:` answers to that letter, underlined in its
3620
+ # caption wherever it occurs — on the strip and in the panels, focused or
3621
+ # not (there is no Alt key to reveal them with). Matching is **level-scoped
3622
+ # with no fallback**: the top-level items while the cascade is closed, the
3623
+ # deepest open panel's items while it is open, and nothing else is ever
3624
+ # consulted. So `f` then `q` walks File ▸ Quit as two ordinary keystrokes,
3625
+ # two items on *different* levels may share a letter with nothing to
3626
+ # arbitrate, and only siblings compete — a duplicate among them raises at
3627
+ # {#add_item}. A letter matching nothing on the live level is swallowed and
3628
+ # rings {Screen#beep}; it never falls out to a shallower level and switches
3629
+ # menus. A mnemonic shadows what the app (or an ancestor, including a
3630
+ # {Popup}'s `q`-to-close) would do with that key while the bar has focus.
3631
+ # A paste can never fire one — pasted text rides its own path off the key
3632
+ # ladder.
3633
+ #
3634
+ # {Item} handles are minted by {#add_item} and nest via the *same* method, so
3635
+ # depth is unlimited. There is no removal, no reordering and no dynamic
3636
+ # rebuilding: a menu is built once, at construction. See `DECISIONS.md`
3637
+ # `D-menu-bar`.
3638
+ #
3639
+ # == Sizing
3640
+ # Assign a {#rect} (typically one {Layout::Fixed}`[1]` row at the top of a
3641
+ # {Layout::Vertical}). One wider than {#extent}`.width` leaves a dead tail; a
3642
+ # narrower one **scrolls** to keep the highlighted segment whole, cueing the
3643
+ # hidden captions with a `<` or `>` over an edge column, exactly as {Tabs}
3644
+ # does — so a bar wider than its terminal stays wholly reachable by arrow,
3645
+ # mnemonic and click. Reassigning the rect
3646
+ # **closes** an open cascade: every panel position is derived from a segment
3647
+ # or a parent row, so after a resize they would all sit at stale columns, and
3648
+ # a resize with a menu open is rare enough that closing beats re-anchoring
3649
+ # every level.
3080
3650
  #
3081
- # The dropdown is a {ListDropdown}, tinted to read as a floating panel; see
3082
- # it for the theming knob.
3651
+ # == Implementation details
3652
+ # Deliberately painted *unlike* {Tabs}, whose picture it would otherwise
3653
+ # share: no separator between segments, no bold, and no highlight at all
3654
+ # while unfocused — a menu bar has no persistent selection to show, and a
3655
+ # reader should not have to work out which of the two controls they are
3656
+ # looking at. Hit testing *is* {Tabs}': one private `segments` method feeds
3657
+ # both the paint and the click and both offset it by the same scroll column,
3658
+ # so a click cannot land on a caption other than the one drawn under it, and it is derived from the captions on each
3659
+ # call so a hit test is correct before the first paint.
3660
+ #
3661
+ # The open panels are overlays owned by a private {Cascade}, not children:
3662
+ # focus stays here for the whole interaction, so the strip receives every key
3663
+ # and forwards it. An open cascade swallows keys the cascade doesn't
3664
+ # recognize; a *closed* strip lets every printable bubble, so an app's
3665
+ # `s`-to-save keeps working while the bar has focus.
3666
+ #
3667
+ # A click outside an open cascade is not blocked — non-modal overlays block
3668
+ # nothing — but any click on a focusable component moves focus, and losing
3669
+ # focus closes the cascade.
3083
3670
  #
3084
3671
  # UI-thread-confined, like every component (see {Screen}).
3085
- class ComboBox < Component
3086
- include Tuile::Component::HasContent
3087
- include Tuile::Component::HasValue
3672
+ class MenuBar < Component
3673
+ def initialize: () -> void
3088
3674
 
3089
- # _@param_ `items` — the candidate items (any type); also settable via {#items=}.
3090
- def initialize: (?items: ::Array[untyped]) -> void
3675
+ # _@return_ — `true` — the strip takes focus, so its keys work.
3676
+ def focusable?: () -> bool
3091
3677
 
3092
- # Selects `new_value` programmatically: updates the field to its label
3093
- # *without* opening the dropdown, then fires {#on_value_change}. `nil`
3094
- # clears the selection (blank field). The value need not be in {#items}.
3095
- #
3096
- # _@param_ `new_value`
3097
- def value=: (Object new_value) -> void
3678
+ # _@return_ — `true` — one stop for the whole strip, as on {Tabs}.
3679
+ def tab_stop?: () -> bool
3098
3680
 
3099
- # _@return_ — the field's caret position (the combo delegates the
3100
- # hardware cursor to its field).
3101
- def cursor_position: () -> Point?
3681
+ # _@return_ — the top-level items, in strip order. Read-only by
3682
+ # convention; grow it through {#add_item}.
3683
+ def items: () -> ::Array[Item]
3102
3684
 
3103
- def keyboard_hint: () -> String
3685
+ # Appends a top-level item and returns its handle; nest submenus into it
3686
+ # with {Item#add_item}.
3687
+ #
3688
+ # _@param_ `caption` — parsed as {StyledString.parse} parses it.
3689
+ #
3690
+ # _@param_ `mnemonic` — a single one-column printable character that activates this item while the strip is focused and *closed*, underlined in the caption where it occurs. Matched case-insensitively; it shadows whatever the app would otherwise do with that key while the bar has focus.
3691
+ def add_item: (?(String | StyledString)? caption, ?mnemonic: String?) -> Item
3104
3692
 
3105
- # Re-anchors the (open) dropdown after {HasContent#rect=} has resized the
3106
- # field via {#layout}.
3693
+ # The cells the strip actually paints: one row, as wide as its segments
3694
+ # need, clipped to {#rect}.
3107
3695
  #
3696
+ # Both the highlight and the click hit test use it, so a click on the blank
3697
+ # tail — or on a lower row, when the rect is taller than one — opens
3698
+ # nothing. It still *focuses*: {Component#handle_mouse}'s click-to-focus is
3699
+ # ungated by geometry.
3700
+ def extent: () -> Rect
3701
+
3108
3702
  # _@param_ `new_rect`
3109
3703
  def rect=: (Rect new_rect) -> void
3110
3704
 
3111
- # Closes the dropdown and reverts an uncommitted query when the combo
3112
- # leaves the focus chain — so tabbing away doesn't strand an open menu or
3113
- # a half-typed filter. Safe against re-entrancy: focus never sits inside
3114
- # the (non-focusable) {ListDropdown}, so closing the overlay repairs no
3115
- # focus.
3705
+ # Closes the cascade when the strip leaves the focus chain, so tabbing (or
3706
+ # clicking) away doesn't strand an open menu.
3116
3707
  #
3117
3708
  # _@param_ `flag`
3118
3709
  def active=: (bool flag) -> void
3119
3710
 
3711
+ # Closes the cascade, so a bar removed from the tree can't strand its
3712
+ # panels on the pane — they are the {ScreenPane}'s children, not the bar's,
3713
+ # so nothing else would take them down.
3714
+ def on_detached: () -> void
3715
+
3716
+ # Offers the key to the open cascade first, then to the strip's own
3717
+ # LEFT/RIGHT/Enter/Space/Down.
3718
+ #
3719
+ # With a cascade open, the only keys reaching the strip are the two the
3720
+ # cascade declines — LEFT at the first level, RIGHT on a row with no
3721
+ # submenu — and both step to the sibling menu.
3722
+ #
3723
+ # _@param_ `key`
3724
+ def handle_key: (String key) -> bool
3725
+
3726
+ # Opens the menu under a left click, or closes it when it is already the
3727
+ # open one; `super` runs first, so a click anywhere in {#rect} still
3728
+ # focuses.
3729
+ #
3120
3730
  # _@param_ `event`
3121
3731
  def handle_mouse: (MouseEvent event) -> void
3122
3732
 
3123
3733
  def repaint: () -> void
3124
3734
 
3125
- # Field spans the row bar the last column, which the `▾` occupies
3126
- # ({HasContent} layout hook). One row, or none at all when the combo itself
3127
- # was given none — a starved parent must not hand out a rect it doesn't own.
3735
+ # The sole writer of {#highlighted_index}: assigns, re-syncs the scroll
3736
+ # offset and repaints. Every path that moves the highlight — arrow,
3737
+ # mnemonic, click — goes through it, so the highlighted segment is on
3738
+ # screen *before* {Cascade} anchors a panel to it.
3128
3739
  #
3129
- # _@param_ `field`
3130
- def layout: (Component field) -> void
3740
+ # _@param_ `index`
3741
+ def highlight=: (Integer index) -> void
3131
3742
 
3132
- # The field's key interceptor: while the dropdown is open forwards movement
3133
- # to it ({ListDropdown#move}), commits on Enter ({ListDropdown#choose}),
3134
- # and dismisses on ESC (reverting the query); opens it on Down or Enter
3135
- # when closed. Everything else (printable keys, editing) falls through to
3743
+ # Re-syncs the scroll offset and repaints — what every change to the items
3744
+ # or the highlight ends in.
3745
+ def refresh: () -> void
3746
+
3747
+ # The rect's *width* is the only part of it the offset depends on, so this
3748
+ # hook is the whole geometry story; {Component#rect=} invalidates for us,
3749
+ # and {#rect=} closes the cascade rather than re-anchoring it.
3750
+ def on_width_changed: () -> void
3751
+
3752
+ # Scrolls the minimum needed to show the highlighted segment whole, and is
3753
+ # the sole writer of {#left_column}. Idempotent, so every mutation site can
3754
+ # call it blindly; it returns the offset to `0` on its own once the strip
3755
+ # fits again, which is why no mutator owes a scroll-back branch.
3756
+ #
3757
+ # A segment wider than the whole rect cannot be shown whole: its head wins,
3758
+ # being the half of a caption that identifies it.
3759
+ def adjust_left_column: () -> void
3760
+
3761
+ # {StyledString#slice} *drops* a cluster straddling the window's edge
3762
+ # rather than half-painting it, which would leave the painted row a column
3763
+ # short and shift everything past the hole one column left — paint and hit
3764
+ # test would then disagree, silently and only for wide glyphs. So the
3765
+ # offset only ever lands on a cluster boundary. Snapping *forward* is the
3766
+ # safe direction: it gives up at most one column of the segment to the left
3767
+ # of the window, never of the one being revealed.
3768
+ #
3769
+ # _@param_ `column`
3770
+ #
3771
+ # _@return_ — the smallest cluster-boundary column `>= column`.
3772
+ def snap_to_glyph_start: (Integer column) -> Integer
3773
+
3774
+ # Paints the overflow cues over the windowed row's edge columns: `<` when
3775
+ # segments sit to the left of the window, `>` when more sit to the right.
3776
+ # ASCII by convention rather than by constant, as {Checkbox}'s brackets
3777
+ # are, and *overlaid* rather than given reserved columns — reserving would
3778
+ # make the window width a function of the offset computed from it. Painted
3779
+ # focused or not: overflow is a fact about the captions and the rect, not
3780
+ # about focus.
3781
+ #
3782
+ # _@param_ `row` — the windowed row, as painted.
3783
+ def draw_cues: (StyledString row) -> void
3784
+
3785
+ # The cue keeps the style of the cell it covers, so one landing on the
3786
+ # highlighted segment doesn't punch a default-background hole in its
3787
+ # highlight.
3788
+ #
3789
+ # _@param_ `row` — the windowed row.
3790
+ #
3791
+ # _@param_ `column` — relative to {#rect}`.left`.
3792
+ #
3793
+ # _@param_ `glyph`
3794
+ def draw_cue: (StyledString row, Integer column, String glyph) -> void
3795
+
3796
+ # One `[item, start_column, width]` triple per top-level item, in strip
3797
+ # order, in columns relative to {#rect}`.left`. A segment is its caption
3798
+ # between two padding columns, and neighbours abut — the two blank columns
3799
+ # between captions are the segments' own padding, so a click on either
3800
+ # opens the menu it belongs to.
3801
+ def segments: () -> ::Array[[Item, Integer, Integer]]
3802
+
3803
+ # _@return_ — columns the strip would paint given an unlimited rect.
3804
+ def painted_width: () -> Integer
3805
+
3806
+ # _@param_ `point`
3807
+ #
3808
+ # _@return_ — the index of the item painted at `point`; `nil`
3809
+ # for the blank tail or a row the strip doesn't paint.
3810
+ def index_at: (Point point) -> Integer?
3811
+
3812
+ # _@param_ `index`
3813
+ #
3814
+ # _@return_ — the segment's cells on screen — the cascade's anchor.
3815
+ def segment_rect: (Integer index) -> Rect
3816
+
3817
+ # _@return_ — the whole strip as one row, unclipped. {#repaint}
3818
+ # windows it to the rect; nothing else may, since the window's own
3819
+ # arithmetic is {#adjust_left_column}'s.
3820
+ def strip_row: () -> StyledString
3821
+
3822
+ # _@param_ `item`
3823
+ #
3824
+ # _@param_ `index`
3825
+ #
3826
+ # _@return_ — the caption between its padding columns,
3827
+ # highlighted when it is the one Enter would open *and* the strip has
3828
+ # focus. An unfocused strip shows no highlight at all: there is no
3829
+ # persistent selection to report.
3830
+ def segment_text: (Item item, Integer index) -> StyledString
3831
+
3832
+ # Activates the item bound to `key` on the *live* level — the deepest open
3833
+ # panel while the cascade is open, the top-level strip while it is closed.
3834
+ # No fallback between the two: a letter matching nothing in the live set is
3835
+ # not offered to any other level.
3836
+ #
3837
+ # _@param_ `key`
3838
+ #
3839
+ # _@return_ — whether a mnemonic claimed the key.
3840
+ def handle_mnemonic: (String key) -> bool
3841
+
3842
+ # Moves the highlight along the strip, clamping at both ends. Consumes the
3843
+ # key even at an end, as {Tabs} does.
3844
+ #
3845
+ # _@param_ `delta` — `+1` / `-1`.
3846
+ #
3847
+ # _@return_ — `false` only when there are no items.
3848
+ def move_highlight: (Integer delta) -> bool
3849
+
3850
+ # Steps to the neighbouring menu, showing *its* menu instead — or closing
3851
+ # the cascade, when the neighbour is a top-level button with no menu to
3852
+ # show. The cascade is left alone when the highlight is already at an end:
3853
+ # reopening the same menu would throw away the submenu the user is standing
3854
+ # in.
3855
+ #
3856
+ # It deliberately never *activates*. An item arrowed past is highlighted,
3857
+ # not pressed, so a top-level button waits for Enter or Space — otherwise
3858
+ # walking the strip would fire every button on it.
3859
+ #
3860
+ # _@param_ `delta` — `+1` / `-1`.
3861
+ #
3862
+ # _@return_ — always `true`: an open menu swallows the key either way.
3863
+ def step_menu: (Integer delta) -> bool
3864
+
3865
+ # Opens the highlighted item's menu, or fires it when it is a top-level
3866
+ # button — the Enter/Space/Down/click path, and the only one that fires a
3867
+ # listener.
3868
+ #
3869
+ # _@return_ — `false` only when there are no items.
3870
+ def open_highlighted: () -> bool
3871
+
3872
+ # Shows the highlighted item's menu, closing the cascade when it has none.
3873
+ def show_highlighted_menu: () -> void
3874
+
3875
+ # _@return_ — which top-level item the strip highlights while
3876
+ # focused, and which menu Enter opens. `0` until the user moves.
3877
+ attr_reader highlighted_index: Integer
3878
+
3879
+ # _@return_ — the strip column painted in {#rect}'s leftmost cell —
3880
+ # the horizontal scroll offset. `0` unless the strip overflows its rect;
3881
+ # {#adjust_left_column} is its sole writer.
3882
+ attr_reader left_column: Integer
3883
+
3884
+ # One menu item: a caption, an optional click listener, and its children.
3885
+ #
3886
+ # file = bar.add_item("File") # minted by the bar
3887
+ # file.add_item("New") { create } # …and nested by the same method
3888
+ # file.items.size # => 1
3889
+ #
3890
+ # An item with children is a submenu and its own listener is dead
3891
+ # ({#submenu?} decides). An item with **neither** children nor a listener is
3892
+ # legal and inert: it highlights, Enter closes the menu, nothing happens —
3893
+ # an item that looks live but does nothing is the app's error to fix, not
3894
+ # the framework's to raise on.
3895
+ #
3896
+ # Apps don't construct items; {MenuBar#add_item} and {#add_item} do.
3897
+ class Item
3898
+ # _@param_ `caption` — already coerced by the caller.
3899
+ #
3900
+ # _@param_ `mnemonic` — already validated by the caller, in the case it was given in.
3901
+ #
3902
+ # _@param_ `on_click`
3903
+ def initialize: (StyledString caption, String? mnemonic, (Proc | Method)? on_click) -> void
3904
+
3905
+ # _@return_ — whether this item opens a submenu, i.e. has children.
3906
+ def submenu?: () -> bool
3907
+
3908
+ # Appends a child and returns its handle.
3909
+ #
3910
+ # _@param_ `caption` — parsed as {StyledString.parse} parses it.
3911
+ #
3912
+ # _@param_ `mnemonic` — the letter that activates this child while *this* item's children are the live level; see {MenuBar#add_item}.
3913
+ def add_item: (?(String | StyledString)? caption, ?mnemonic: String?) -> Item
3914
+
3915
+ def inspect: () -> String
3916
+
3917
+ # Rejects a mnemonic that couldn't work, or that would make two siblings
3918
+ # ambiguous — all three at *registration*, since none has a sane answer
3919
+ # at keypress time.
3920
+ #
3921
+ # _@param_ `mnemonic`
3922
+ def validate_mnemonic: (String? mnemonic) -> void
3923
+
3924
+ # {StyledString#slice} counts **columns** while a caption search yields a
3925
+ # **character** index, so the prefix is measured, never counted.
3926
+ #
3927
+ # _@param_ `caption`
3928
+ #
3929
+ # _@param_ `mnemonic` — in the case it was given in.
3930
+ def build_cued_caption: (StyledString caption, String? mnemonic) -> StyledString
3931
+
3932
+ # _@return_ — the label painted on the strip or the row.
3933
+ attr_reader caption: StyledString
3934
+
3935
+ # _@return_ — the downcased letter that activates this item
3936
+ # while its own level is the live one; `nil` when it has none.
3937
+ attr_reader mnemonic: String?
3938
+
3939
+ # _@return_ — {#caption} with the {#mnemonic} underlined —
3940
+ # what both paint sites draw. Equal to {#caption} when there is no
3941
+ # mnemonic or the caption doesn't contain it. Computed once, at
3942
+ # construction: caption and mnemonic are both fixed there, and
3943
+ # underline is a plain attribute with no theme or `bg_color` input, so
3944
+ # this is not a cached theme value.
3945
+ attr_reader cued_caption: StyledString
3946
+
3947
+ # _@return_ — this item's children, in menu order. Read-only by
3948
+ # convention, like {Component#children} — grow it through {#add_item}.
3949
+ attr_reader items: ::Array[Item]
3950
+
3951
+ # _@return_ — no-arg callable fired when the item is
3952
+ # activated (Enter, Space or a left click), exactly as
3953
+ # {Button#on_click}. Never fired on an item with children.
3954
+ attr_accessor on_click: (Proc | Method)?
3955
+ end
3956
+
3957
+ # The stack of open menu panels — one {ListDropdown} per level, the last
3958
+ # deepest — and the drill/pop/activate logic driving them. Private
3959
+ # machinery of {MenuBar}; an app never names it.
3960
+ #
3961
+ # cascade.open_below(segment_rect, item) # Enter/Down on the strip
3962
+ # return true if cascade.handle_key(key) # MenuBar#handle_key, first
3963
+ # cascade.close # focus lost, or rect changed
3964
+ #
3965
+ # A panel is a **non-modal overlay, not a child**, so it never takes focus:
3966
+ # focus stays on the {MenuBar} for the whole interaction and every key
3967
+ # arrives via {MenuBar#handle_key}, which offers it here first. That is
3968
+ # {Component::Select}'s architecture extended to N levels, and it is why
3969
+ # nothing in the key-dispatch ladder changes.
3970
+ #
3971
+ # Widths are measured here, per level — the panel is as wide as the level's
3972
+ # widest label — because {ListDropdown} deliberately measures nothing
3973
+ # itself (`DECISIONS.md` `D-select`).
3974
+ #
3975
+ # == Implementation details
3976
+ # While open it consumes **everything** except the two keys that mean
3977
+ # "leave this menu sideways", which only the strip can answer: LEFT at
3978
+ # depth 1, and RIGHT on a row with no submenu. An open menu is quasi-modal
3979
+ # — firing an app's `s`-to-save behind a visible panel is worse than a dead
3980
+ # keystroke.
3981
+ #
3982
+ # UI-thread-confined, like everything in the tree (see {Screen}).
3983
+ class Cascade
3984
+ SUBMENU_ARROW: String
3985
+ ARROW_WIDTH: Integer
3986
+
3987
+ def initialize: () -> void
3988
+
3989
+ # _@return_ — whether any panel is open.
3990
+ def open?: () -> bool
3991
+
3992
+ # _@return_ — how many panels are open; `0` when closed.
3993
+ def depth: () -> Integer
3994
+
3995
+ # Opens `item`'s children directly beneath `anchor`, closing anything
3996
+ # already open first.
3997
+ #
3998
+ # _@param_ `anchor` — the strip segment the menu drops from.
3999
+ #
4000
+ # _@param_ `item` — a childless one opens nothing.
4001
+ def open_below: (Rect anchor, Item item) -> void
4002
+
4003
+ # Closes every open panel, deepest first.
4004
+ def close: () -> void
4005
+
4006
+ # Offers a key to the deepest panel and to the cascade's own verbs.
4007
+ #
4008
+ # _@param_ `key`
4009
+ #
4010
+ # _@return_ — `true` when consumed — almost always, while open.
4011
+ # `false` when closed, and for the two sideways keys {MenuBar} answers
4012
+ # (see the class docs).
4013
+ def handle_key: (String key) -> bool
4014
+
4015
+ # Activates the deepest level's item bound to `key` — the drill-or-fire
4016
+ # the mnemonic shares with Enter. The highlight moves there *first*, so a
4017
+ # submenu anchors beside the row that opened it rather than beside
4018
+ # wherever the cursor happened to be.
4019
+ #
4020
+ # _@param_ `key` — a single printable, already downcased.
4021
+ #
4022
+ # _@return_ — whether an item on the deepest level claimed it. A
4023
+ # miss is never offered to a shallower level.
4024
+ def handle_mnemonic: (String key) -> bool
4025
+
4026
+ # _@return_ — the deepest open panel.
4027
+ def deepest: () -> ListDropdown
4028
+
4029
+ def activate_highlighted: () -> void
4030
+
4031
+ # Drills into `item`, or fires it and closes the cascade.
4032
+ #
4033
+ # _@param_ `level` — the panel the item belongs to.
4034
+ #
4035
+ # _@param_ `item` — `nil` (an off-content cursor) does nothing.
4036
+ def activate: (Integer level, Item? item) -> void
4037
+
4038
+ # Opens `item`'s children beside the row highlighted in `level`.
4039
+ #
4040
+ # _@param_ `level`
4041
+ #
4042
+ # _@param_ `item`
4043
+ def push_beside: (Integer level, Item item) -> void
4044
+
4045
+ # Mounts a panel for `item`'s children and yields it for geometry.
4046
+ #
4047
+ # _@param_ `item`
4048
+ def push: (Item item) ?{ (ListDropdown drop, Integer rows, Integer width) -> void } -> void
4049
+
4050
+ # Closes the deepest panel; at depth 1 that closes the cascade.
4051
+ def pop: () -> void
4052
+
4053
+ # _@param_ `count` — how many panels to keep.
4054
+ def truncate: (Integer count) -> void
4055
+
4056
+ # _@param_ `level`
4057
+ #
4058
+ # _@return_ — the item under `level`'s cursor; `nil` when it sits
4059
+ # off-content. The range guard matters: a cursor at `-1` would
4060
+ # otherwise index the *last* child.
4061
+ def highlighted: (Integer level) -> Item?
4062
+
4063
+ # _@param_ `items`
4064
+ #
4065
+ # _@return_ — item -> row: the label padded to the level's widest, plus
4066
+ # an arrow column when any sibling has a submenu — so every arrow lands
4067
+ # in the same column without asking the {List} how wide it ended up.
4068
+ def renderer_for: (::Array[Item] items) -> Proc
4069
+
4070
+ # _@param_ `items`
4071
+ #
4072
+ # _@return_ — the panel width: the rendered row plus {List}'s two
4073
+ # row gutters, plus a scrollbar column when the rows can't all be shown.
4074
+ # As in {Component::Select}, the scrollbar is predicted from the item
4075
+ # count rather than the final height — a panel the screen clamps
4076
+ # shorter than {ListDropdown::MAX_VISIBLE_ROWS} scrolls without having
4077
+ # bought that column, and ellipsizes one character early.
4078
+ def width_for: (::Array[Item] items) -> Integer
4079
+
4080
+ # _@param_ `items`
4081
+ def label_width_of: (::Array[Item] items) -> Integer
4082
+ end
4083
+ end
4084
+
4085
+ # A text field with a filtering dropdown: type to narrow the candidates,
4086
+ # arrow to move the highlight, Enter (or click) to accept. Its {#value} is
4087
+ # the *selected item* — of whatever type the items are — not the display
4088
+ # string, so a combo over domain objects hands back the object:
4089
+ #
4090
+ # combo = Component::ComboBox.new
4091
+ # combo.items = User.all # Array of any type
4092
+ # combo.item_label = ->(u) { u.full_name } # item -> shown text; default :to_s
4093
+ # combo.on_value_change = ->(u) { open(u) } # fires on commit, with the item
4094
+ # combo.value = some_user # selects it; field shows its label
4095
+ #
4096
+ # It's the assembly you'd otherwise wire by hand — a {TextField} plus a
4097
+ # non-modal {Popup} over a {List} — promoted to one component. Give it a
4098
+ # single-row {#rect}; it paints the field across that row with a `▾` in the
4099
+ # last column and floats the dropdown above or below.
4100
+ #
4101
+ # == The two values
4102
+ # {#value} (the committed selection) and the field's typed text (a transient
4103
+ # *query*) are deliberately distinct. Keystrokes move the query and refilter
4104
+ # the list; only Enter/click commits, and only a commit changes {#value} and
4105
+ # fires {#on_value_change}. An uncommitted query reverts to the current
4106
+ # value's label when the dropdown is dismissed (ESC) or the combo loses
4107
+ # focus. Selecting by list index (not by matching the label back) is what
4108
+ # lets two items share a label and still resolve to the right object.
4109
+ #
4110
+ # The dropdown is a {ListDropdown}, tinted to read as a floating panel; see
4111
+ # it for the theming knob.
4112
+ #
4113
+ # UI-thread-confined, like every component (see {Screen}).
4114
+ class ComboBox < Component
4115
+ include Tuile::Component::HasContent
4116
+ include Tuile::Component::HasValue
4117
+
4118
+ # _@param_ `items` — the candidate items (any type); also settable via {#items=}.
4119
+ def initialize: (?items: ::Array[untyped]) -> void
4120
+
4121
+ # Selects `new_value` programmatically: updates the field to its label
4122
+ # *without* opening the dropdown, then fires {#on_value_change}. `nil`
4123
+ # clears the selection (blank field). The value need not be in {#items}.
4124
+ #
4125
+ # _@param_ `new_value`
4126
+ def value=: (Object new_value) -> void
4127
+
4128
+ # _@return_ — the field's caret position (the combo delegates the
4129
+ # hardware cursor to its field).
4130
+ def cursor_position: () -> Point?
4131
+
4132
+ # Re-anchors the (open) dropdown after {HasContent#rect=} has resized the
4133
+ # field via {#layout}.
4134
+ #
4135
+ # _@param_ `new_rect`
4136
+ def rect=: (Rect new_rect) -> void
4137
+
4138
+ # Closes the dropdown and reverts an uncommitted query when the combo
4139
+ # leaves the focus chain — so tabbing away doesn't strand an open menu or
4140
+ # a half-typed filter. Safe against re-entrancy: focus never sits inside
4141
+ # the (non-focusable) {ListDropdown}, so closing the overlay repairs no
4142
+ # focus.
4143
+ #
4144
+ # _@param_ `flag`
4145
+ def active=: (bool flag) -> void
4146
+
4147
+ # _@param_ `event`
4148
+ def handle_mouse: (MouseEvent event) -> void
4149
+
4150
+ def repaint: () -> void
4151
+
4152
+ # Field spans the row bar the last column, which the `▾` occupies
4153
+ # ({HasContent} layout hook). One row, or none at all when the combo itself
4154
+ # was given none — a starved parent must not hand out a rect it doesn't own.
4155
+ #
4156
+ # _@param_ `field`
4157
+ def layout: (Component field) -> void
4158
+
4159
+ # The field's key interceptor: while the dropdown is open forwards movement
4160
+ # to it ({ListDropdown#move}), commits on Enter ({ListDropdown#choose}),
4161
+ # and dismisses on ESC (reverting the query); opens it on Down or Enter
4162
+ # when closed. Everything else (printable keys, editing) falls through to
3136
4163
  # the field, whose {TextField#on_change} refilters.
3137
4164
  #
3138
4165
  # _@param_ `key`
@@ -3152,11 +4179,11 @@ module Tuile
3152
4179
  # _@param_ `query`
3153
4180
  def matching: (String query) -> ::Array[untyped]
3154
4181
 
3155
- # Commits the item at the menu's `index`: closes the dropdown and adopts
3156
- # it as {#value} (which repaints the field with its label).
4182
+ # Commits the item chosen from the menu: closes the dropdown and adopts it
4183
+ # as {#value} (which repaints the field with its label).
3157
4184
  #
3158
- # _@param_ `index`
3159
- def commit: (Integer index) -> void
4185
+ # _@param_ `item`
4186
+ def commit: (Object item) -> void
3160
4187
 
3161
4188
  def open_menu: () -> void
3162
4189
 
@@ -3166,6 +4193,9 @@ module Tuile
3166
4193
 
3167
4194
  # Sets the field's text without triggering a refilter — for programmatic
3168
4195
  # value changes and query reverts, which must not spring the dropdown.
4196
+ # Every programmatic write to the field goes through here; a direct
4197
+ # `content.text =` reaches the field's `on_change` and pops the dropdown
4198
+ # open on a {#value=} the user never asked to browse.
3169
4199
  # Parks the caret at the end: `text=` only *clamps* the caret, so a
3170
4200
  # shorter query replaced by a longer label would otherwise strand it
3171
4201
  # mid-word (commit "Go", then pick "Kotlin" → caret after "Ko").
@@ -3265,11 +4295,169 @@ module Tuile
3265
4295
  attr_accessor on_value_change: (Proc | Method)?
3266
4296
  end
3267
4297
 
4298
+ # A {Component::Tabs} strip on its top row plus the pane belonging to the
4299
+ # selected tab underneath it:
4300
+ #
4301
+ # ␣Details␣│␣Payment␣│␣Shipping␣
4302
+ # the selected tab's pane fills the rest of the rect
4303
+ #
4304
+ # sheet = Component::TabSheet.new
4305
+ # sheet.add_tab("Details", details_form) # selected, and shown
4306
+ # sheet.add_tab("Payment", payment_form)
4307
+ # sheet.select_next # shows payment_form
4308
+ # sheet.on_tab_selected = ->(index, tab) { log("now on #{tab&.caption}") }
4309
+ #
4310
+ # Tab lands on the strip first and enters the pane on the next press, which
4311
+ # is the browser's order; switching tabs does *not* move focus into the new
4312
+ # pane, but if focus was inside the pane that just went away it lands back
4313
+ # on the strip.
4314
+ #
4315
+ # == Hidden panes are detached
4316
+ # Only the selected tab's pane is in the component tree — the others are
4317
+ # detached, which is how Tuile hides a component (there is no visibility
4318
+ # flag, and an empty rect gates painting only). Consequences worth
4319
+ # designing around:
4320
+ #
4321
+ # - A hidden pane is invisible to *everything*: the Tab cycle, focus
4322
+ # cascades, repaint, the cursor, `on_tree` walks. No gates anywhere.
4323
+ # - Its state survives, because state is ivars — scroll position, caret,
4324
+ # list cursor, text are all exactly as the user left them, and mutating a
4325
+ # hidden pane is safe (`invalidate` while detached is a silent no-op).
4326
+ # - {Component#on_detached} / {Component#on_attached} fire on every switch,
4327
+ # so a pane holding a mounted-lifetime resource — a {Component::ProgressBar}'s
4328
+ # ticker — releases it while hidden and re-acquires it on return. A pane
4329
+ # that must keep something alive while hidden can't; that something
4330
+ # belongs in the model the pane renders, not in the pane.
4331
+ #
4332
+ # == Implementation details
4333
+ # `children` is `[strip, pane]`, the strip pinned at index 0, so pre-order
4334
+ # traversal gives the strip-then-pane Tab order for free. The swap follows
4335
+ # the slot-swap recipe {Component#detach_child} documents — detach, rewire,
4336
+ # `on_child_removed` last, so the focus repair sees the new occupant.
4337
+ #
4338
+ # Panes live in an identity-keyed `Tab => Component` map here rather than in
4339
+ # a slot on {Tabs::Tab}: the strip's tab array stays the sole ordering
4340
+ # authority, and the strip itself stays ignorant of panes. One idempotent
4341
+ # `sync_pane` is the sole writer of the visible pane, deriving it from
4342
+ # `strip.selected` on every call, so registering a pane and selecting a tab
4343
+ # can happen in either order.
4344
+ #
4345
+ # The sheet owns the strip's `on_tab_selected` (that is what drives the
4346
+ # swap); an app's listener goes on {#on_tab_selected} here, which fires
4347
+ # after the pane has been swapped in.
4348
+ class TabSheet < Component
4349
+ # _@param_ `separator` — the strip's separator; see {Tabs#separator=}.
4350
+ def initialize: (?separator: (String | StyledString)) -> void
4351
+
4352
+ # Adds a tab and the pane to show while it is selected. The first tab
4353
+ # added becomes the selection, so its pane is shown immediately.
4354
+ #
4355
+ # _@param_ `caption` — parsed as {Tabs::Tab#caption=} parses it.
4356
+ #
4357
+ # _@param_ `pane` — shown while this tab is selected, detached while it isn't.
4358
+ #
4359
+ # _@return_ — the new tab's handle.
4360
+ def add_tab: ((String | StyledString)? caption, Component pane) -> Tabs::Tab
4361
+
4362
+ # Removes a tab and forgets its pane, detaching it if it was the visible
4363
+ # one. The strip re-selects as {Tabs#remove_tab} describes, and this
4364
+ # sheet shows whatever it lands on.
4365
+ #
4366
+ # _@param_ `tab` — one of this sheet's tabs.
4367
+ #
4368
+ # _@return_ — the pane that tab owned.
4369
+ def remove_tab: (Tabs::Tab tab) -> Component?
4370
+
4371
+ # _@param_ `tab`
4372
+ #
4373
+ # _@return_ — the pane registered for `tab`; `nil` for a
4374
+ # removed tab, a tab of another sheet, or `nil`.
4375
+ def pane_for: (Tabs::Tab? tab) -> Component?
4376
+
4377
+ # _@return_ — the strip's tabs, in order.
4378
+ def tabs: () -> ::Array[Tabs::Tab]
4379
+
4380
+ # _@return_ — the selected tab.
4381
+ def selected: () -> Tabs::Tab?
4382
+
4383
+ # _@param_ `tab` — one of this sheet's tabs.
4384
+ def selected=: (Tabs::Tab tab) -> void
4385
+
4386
+ # _@return_ — the selected tab's position.
4387
+ def selected_index: () -> Integer?
4388
+
4389
+ # _@param_ `index` — a position in `0...tabs.size`.
4390
+ def selected_index=: (Integer index) -> void
4391
+
4392
+ # Selects the next tab, clamping at the last one.
4393
+ #
4394
+ # _@return_ — `false` only when there are no tabs.
4395
+ def select_next: () -> bool
4396
+
4397
+ # Selects the previous tab, clamping at the first one.
4398
+ #
4399
+ # _@return_ — `false` only when there are no tabs.
4400
+ def select_previous: () -> bool
4401
+
4402
+ # _@param_ `new_rect`
4403
+ def rect=: (Rect new_rect) -> void
4404
+
4405
+ # Forwards to whichever child the click landed on — the strip's row, or
4406
+ # the pane below it.
4407
+ #
4408
+ # _@param_ `event`
4409
+ def handle_mouse: (MouseEvent event) -> void
4410
+
4411
+ # Sends focus to the strip: a sheet is a container, and the strip is where
4412
+ # a tab switch is driven from. The pane is a Tab press away.
4413
+ def on_focus: () -> void
4414
+
4415
+ # Lands focus on the strip rather than on `self` when the focused pane is
4416
+ # swapped out — a bare container can't use keys, and the user's last
4417
+ # action was a tab switch.
4418
+ #
4419
+ # _@param_ `child`
4420
+ def on_child_removed: (Component child) -> void
4421
+
4422
+ # Makes the visible pane match `strip.selected`, swapping if it doesn't.
4423
+ # Idempotent and the sole writer of `@pane`: it derives everything from
4424
+ # current state, so {#add_tab} can register a pane after the strip has
4425
+ # already selected its tab.
4426
+ def sync_pane: () -> void
4427
+
4428
+ # Drops entries whose tab is gone. {Tabs::Tab#remove} takes a tab off the
4429
+ # strip without passing through {#remove_tab}, and a detached tab can never
4430
+ # be selected again, so its entry is dead weight — it pins the pane against
4431
+ # garbage collection and makes {#add_tab} reject that pane as still in use.
4432
+ # Idempotent and the only cleaner, because the rule it enforces is an
4433
+ # invariant ("every key is a live tab of my strip") rather than a step in
4434
+ # one code path.
4435
+ def forget_removed_tabs: () -> void
4436
+
4437
+ def layout_pane: () -> void
4438
+
4439
+ # An app's own selection listener, called after the pane has been swapped
4440
+ # in — `(index, tab)`, or `(nil, nil)` once the last tab is gone. Same
4441
+ # contract as {Tabs#on_tab_selected}: it reports that the selection
4442
+ # changed, whatever changed it.
4443
+ attr_accessor on_tab_selected: Proc?
4444
+
4445
+ # _@return_ — the strip. Reach through it for the rest of its API —
4446
+ # `sheet.strip.separator = "|"` — but leave its `on_tab_selected` alone:
4447
+ # the sheet drives the pane swap through it, and {#on_tab_selected} is
4448
+ # where an app's listener goes.
4449
+ attr_reader strip: Tabs
4450
+
4451
+ # _@return_ — the pane currently in the tree — the selected
4452
+ # tab's, `nil` while the sheet has no tabs.
4453
+ attr_reader pane: Component?
4454
+ end
4455
+
3268
4456
  # A multi-line, word-wrapping text input.
3269
4457
  #
3270
4458
  # Sized by the caller — {#rect} is fixed; the area does not grow with
3271
4459
  # content. Text is wrapped to {Rect#width} columns and any text that
3272
- # doesn't fit vertically is reached by scrolling: {#top_display_row}
4460
+ # doesn't fit vertically is reached by scrolling: {#scroll_top_row}
3273
4461
  # follows the caret so the line being edited stays visible. There is no
3274
4462
  # horizontal scrolling.
3275
4463
  #
@@ -3280,28 +4468,54 @@ module Tuile
3280
4468
  # start of the next row in nearly all cases).
3281
4469
  #
3282
4470
  # Enter inserts a newline, as in a plain `<textarea>` or text editor; only
3283
- # {#on_change} is wired. A pasted line break arrives as `\n`
3284
- # ({Keys::CTRL_J}) rather than the `\r` a typed Enter sends, so both are
3285
- # accepted — otherwise a multi-line paste would silently lose its
3286
- # newlines.
4471
+ # {#on_change} is wired. {Keys::CTRL_J} does the same, since that is the
4472
+ # byte a terminal sends for a typed Ctrl+J. A *pasted* line break arrives
4473
+ # through {AbstractStringField#handle_paste} instead and never as a key at
4474
+ # all — so a subclass rebinding Enter to submit keeps working under a
4475
+ # multi-line paste, which lands as one draft.
4476
+ #
4477
+ # Up/Down move the caret between rows and, at the first/last row, snap to
4478
+ # the start/end of the text. A subclass can claim the key at that edge
4479
+ # instead — shell-style history recall is the motivating case — by asking
4480
+ # {#caret_row} and {#row_count} before delegating:
4481
+ #
4482
+ # class PromptArea < Component::TextArea
4483
+ # protected
4484
+ #
4485
+ # def handle_text_input_key(key)
4486
+ # return recall_previous if key == Keys::UP_ARROW && caret_row.zero?
4487
+ # return recall_next if key == Keys::DOWN_ARROW && caret_row == row_count - 1
4488
+ #
4489
+ # super # anywhere else: the caret moves, and the edge still snaps
4490
+ # end
4491
+ # end
4492
+ #
4493
+ # Both recalls return `true` to consume the key; app code that would rather
4494
+ # not subclass claims the same keys through {AbstractStringField#on_key}.
3287
4495
  #
3288
4496
  # == Implementation details
3289
4497
  #
3290
- # The same two axes {TextField} names apply, and the wrap straddles both: an
3291
- # **index** counts characters into {#text} ({#caret}, a row's `start` and
3292
- # `length`), a **column** counts terminal cells ({#rect}, a row's `columns`,
3293
- # {#cursor_position}, a {MouseEvent}). A row therefore carries *both* counts,
3294
- # and the wrap fills each row to a column budget while recording a character
3295
- # span. Everything crossing between them goes through the inherited
3296
- # `columns_of` and the private `chars_for_column`.
3297
- #
3298
- # The wrap walks **grapheme clusters**, not characters — a combining mark must
3299
- # add no columns and must not be split from its base across a row break. Note
3300
- # `"\r\n"` is a *single* cluster, so a hard break tests `end_with?("\n")`
3301
- # rather than equality.
4498
+ # The wrap itself — and with it every conversion between a character
4499
+ # **index** and a **row/column** — lives in {WrappedText}, a
4500
+ # snapshot of `(text, rect.width)` this class caches and drops whenever
4501
+ # either changes. What stays here is the widget: keys, mouse, painting, and
4502
+ # the {#scroll_top_row} viewport, which {WrappedText} deliberately knows
4503
+ # nothing about (it is a pure function of text and width; the viewport is
4504
+ # stateful and needs {Rect#height}).
3302
4505
  class TextArea < Tuile::Component::AbstractStringField
3303
4506
  def initialize: () -> void
3304
4507
 
4508
+ # The caret's row, counted from the text's first row — *not* from the top
4509
+ # of the viewport (subtract {#scroll_top_row} for that).
4510
+ #
4511
+ # _@return_ — a row index in `0...row_count`.
4512
+ def caret_row: () -> Integer
4513
+
4514
+ # _@return_ — rows the wrapped text occupies at the current
4515
+ # {Rect#width}; always `>= 1`, since empty text still wraps to one
4516
+ # (empty) row.
4517
+ def row_count: () -> Integer
4518
+
3305
4519
  def cursor_position: () -> Point?
3306
4520
 
3307
4521
  # _@param_ `event`
@@ -3318,119 +4532,236 @@ module Tuile
3318
4532
 
3319
4533
  def on_width_changed: () -> void
3320
4534
 
3321
- # _@return_ — cached wrap of {#text} for the
3322
- # current {Rect#width}. Each entry is `{start:, length:}`.
3323
- def display_rows: () -> ::Array[::Hash[Symbol, Integer]]
4535
+ # _@return_ — the current wrap of {#text} at {Rect#width}.
4536
+ def wrap: () -> WrappedText
3324
4537
 
3325
- # _@return_ — one entry per grapheme cluster of
3326
- # {#text}: `{offset: <text-index>, text: <cluster>, width: <columns>}`.
3327
- # Rebuilt per wrap and discarded — the wrap is what's cached.
3328
- def cluster_table: () -> ::Array[::Hash[Symbol, Object]]
4538
+ # _@param_ `delta` — `+1` for down, `-1` for up.
4539
+ def move_caret_vertical: (Integer delta) -> void
3329
4540
 
3330
- # _@param_ `cluster`
3331
- #
3332
- # _@return_ — true for a space or tab (each exactly one column).
3333
- def blank?: (::Hash[Symbol, Object] cluster) -> bool
4541
+ def move_caret_to_row_start: () -> void
3334
4542
 
3335
- # _@param_ `cluster`
4543
+ def move_caret_to_row_end: () -> void
4544
+
4545
+ # _@param_ `char`
3336
4546
  #
3337
- # _@return_ — true for a hard line break. Tests the suffix rather
3338
- # than equality because `"\r\n"` is one grapheme cluster.
3339
- def newline?: (::Hash[Symbol, Object] cluster) -> bool
4547
+ # _@return_ — always true.
4548
+ def insert_char: (String char) -> bool
3340
4549
 
3341
- # Greedy word-wrap, filling each row to a **column** budget while recording
3342
- # the **character** span that produced it. Whitespace at a soft-wrap break
3343
- # point is absorbed (not rendered on either row). A token wider than
3344
- # {Rect#width} hard-wraps inside the token. Newlines force a hard break and
3345
- # the wrap restarts on the next cluster.
3346
- def compute_display_rows: () -> ::Array[::Hash[Symbol, Integer]]
4550
+ # Keeps the caret visible by scrolling vertically.
4551
+ def adjust_scroll_top_row: () -> void
3347
4552
 
3348
- # _@param_ `clusters`
3349
- #
3350
- # _@param_ `index` — cluster index of the word's first glyph.
3351
- #
3352
- # _@return_ — `[chars, columns, next_index]`
3353
- # for the run of non-whitespace starting at `index`.
3354
- def measure_word: (::Array[::Hash[Symbol, Object]] clusters, Integer index) -> [Integer, Integer, Integer]
4553
+ # _@return_ — index of the topmost row currently visible.
4554
+ attr_reader scroll_top_row: Integer
3355
4555
 
3356
- # Splits a token too wide for a whole row, taking entire glyphs while they
3357
- # fit. Consumes at least one glyph even when that single glyph is wider than
3358
- # the row — otherwise the wrap would not terminate (the row would stay empty
3359
- # and the same token be reconsidered forever). Such a row reports more
3360
- # columns than the rect holds and {#padded_row} drops the glyph; a
3361
- # 2-column glyph in a 1-column area is unpaintable either way.
4556
+ # The word-wrapped layout of a {TextArea}'s text: which row each
4557
+ # character lands on, and which glyphs each row paints.
3362
4558
  #
3363
- # _@param_ `clusters`
4559
+ # wrap = WrappedText.new("hello world", 6)
4560
+ # wrap.row_count # => 2
4561
+ # wrap.position_at(8) # => [1, 2] index 8 ("r") sits at row 1, column 2
4562
+ # wrap.index_at(1, 2) # => 8 and back again
4563
+ # wrap.row_text(0) # => "hello " padded out to the width
3364
4564
  #
3365
- # _@param_ `index`
4565
+ # A snapshot of `(text, width)` — rebuild it whenever either changes.
3366
4566
  #
3367
- # _@param_ `width` — column budget.
4567
+ # == Implementation details
3368
4568
  #
3369
- # _@return_ — `[chars, columns, next_index]`
3370
- def hard_wrap: (::Array[::Hash[Symbol, Object]] clusters, Integer index, Integer width) -> [Integer, Integer, Integer]
4569
+ # Two axes meet here, and every method name says which one it speaks: an
4570
+ # **index** counts characters into {#text}, a **column** counts terminal
4571
+ # cells. They agree only for one-column glyphs. A {Row} therefore carries
4572
+ # *both* counts — the wrap fills each row to a column budget while
4573
+ # recording the character span that produced it.
4574
+ #
4575
+ # The wrap walks **grapheme clusters**, not characters: a combining mark
4576
+ # must add no columns and must not be split from its base across a row
4577
+ # break. Note `"\r\n"` is a *single* cluster, so a hard break tests
4578
+ # `end_with?("\n")` rather than equality. Every branch of the wrap
4579
+ # consumes at least one cluster — `"\v"` and `"\f"` match `/\s/` but are
4580
+ # neither blank nor a newline here, and a loop that measured them as zero
4581
+ # and did not advance would hang the UI thread on
4582
+ # `area.text = File.read(...)`.
4583
+ class WrappedText
4584
+ # _@param_ `text` — the full buffer, unwrapped.
4585
+ #
4586
+ # _@param_ `width` — column budget per row; `0` or less yields a single empty row.
4587
+ def initialize: (String text, Integer width) -> void
3371
4588
 
3372
- # Trims trailing space/tab characters off a row's visible length so the
3373
- # whitespace at a soft-wrap point is absorbed (not rendered) rather than
3374
- # left at the end of the row. Without this, soft-wrapping `"foo bar"`
3375
- # to width 4 would yield row 0 length 4 (`"foo "`) and the natural
3376
- # end-of-row caret position would coincide with row 1's start.
3377
- #
3378
- # Both counts drop by one per trimmed character: a space and a tab each
3379
- # measure exactly one column.
3380
- #
3381
- # _@param_ `row_start`
3382
- #
3383
- # _@param_ `row_chars`
3384
- #
3385
- # _@param_ `row_cols`
3386
- #
3387
- # _@return_ — `[row_chars, row_cols]`
3388
- def trim_trailing_whitespace: (Integer row_start, Integer row_chars, Integer row_cols) -> [Integer, Integer]
4589
+ # _@return_ — rows the text occupies; always `>= 1`, since
4590
+ # empty text still wraps to one (empty) row.
4591
+ def row_count: () -> Integer
3389
4592
 
3390
- # _@param_ `caret`
3391
- #
3392
- # _@return_ — `[row_index, column]` for `caret`.
3393
- def caret_to_display: (Integer caret) -> [Integer, Integer]
4593
+ # Display row holding `index`. An index inside a whitespace run absorbed
4594
+ # by a soft wrap belongs to the row *before* the break.
4595
+ #
4596
+ # _@param_ `index` — a character index into {#text}.
4597
+ #
4598
+ # _@return_ — a row index in `0...row_count`.
4599
+ def row_at: (Integer index) -> Integer
3394
4600
 
3395
- # _@param_ `row`
3396
- #
3397
- # _@param_ `caret`
3398
- #
3399
- # _@return_ — `caret`'s column offset within `row`.
3400
- def caret_column_in: (::Hash[Symbol, Integer] row, Integer caret) -> Integer
4601
+ # _@param_ `index` — a character index into {#text}.
4602
+ #
4603
+ # _@return_ — `[row, column]` for `index`.
4604
+ def position_at: (Integer index) -> [Integer, Integer]
3401
4605
 
3402
- # _@param_ `row`
3403
- #
3404
- # _@param_ `column` — a column offset within `row`.
3405
- #
3406
- # _@return_ — characters from the row's start. A column landing in a
3407
- # wide glyph's right half resolves past it, as a click does in
3408
- # {TextField}.
3409
- def chars_for_column: (::Hash[Symbol, Integer] row, Integer column) -> Integer
4606
+ # Inverse of {#position_at}. A column landing in a wide glyph's right
4607
+ # half resolves *past* it, as a click does in {TextField}.
4608
+ #
4609
+ # _@param_ `row` — a row index in `0...row_count`.
4610
+ #
4611
+ # _@param_ `column` — a column offset within that row.
4612
+ #
4613
+ # _@return_ — a character index into {#text}.
4614
+ def index_at: (Integer row, Integer column) -> Integer
3410
4615
 
3411
- # _@param_ `row`
3412
- #
3413
- # _@return_ — the row's text padded to `rect.width` columns. A glyph
3414
- # with no room left is dropped rather than half-painted.
3415
- def padded_row: (::Hash[Symbol, Integer] row) -> String
4616
+ # _@param_ `row` — a row index in `0...row_count`.
4617
+ #
4618
+ # _@return_ — character index where the row begins.
4619
+ def row_start: (Integer row) -> Integer
3416
4620
 
3417
- # _@param_ `delta` — `+1` for down, `-1` for up.
3418
- def move_caret_vertical: (Integer delta) -> void
4621
+ # _@param_ `row` — a row index in `0...row_count`.
4622
+ #
4623
+ # _@return_ — character index one past the row's last *visible*
4624
+ # character — whitespace absorbed by a soft wrap is excluded.
4625
+ def row_end: (Integer row) -> Integer
4626
+
4627
+ # The row's glyphs, padded with spaces out to {#width}. A trailing glyph
4628
+ # with no room left is dropped rather than half-painted. A row past the
4629
+ # end of the text is all spaces, so a caller can paint a viewport taller
4630
+ # than the text without a bounds check.
4631
+ #
4632
+ # _@param_ `row`
4633
+ def row_text: (Integer row) -> String
3419
4634
 
3420
- def move_caret_to_row_start: () -> void
4635
+ # A plain Hash rather than a {Row}-style Data: this table is one entry
4636
+ # per grapheme cluster, built and discarded inside a single {#compute_rows}
4637
+ # call and never handed to another method as a documented type.
4638
+ #
4639
+ # _@return_ — one entry per grapheme cluster of
4640
+ # {#text}: `{offset: <text-index>, text: <cluster>, width: <columns>}`.
4641
+ def cluster_table: () -> ::Array[::Hash[Symbol, Object]]
3421
4642
 
3422
- def move_caret_to_row_end: () -> void
4643
+ # _@param_ `cluster`
4644
+ #
4645
+ # _@return_ — true for a space or tab (each exactly one column).
4646
+ def blank?: (::Hash[Symbol, Object] cluster) -> bool
3423
4647
 
3424
- # _@param_ `char`
3425
- #
3426
- # _@return_ — always true.
3427
- def insert_char: (String char) -> bool
4648
+ # _@param_ `cluster`
4649
+ #
4650
+ # _@return_ — true for a hard line break. Tests the suffix rather
4651
+ # than equality because `"\r\n"` is one grapheme cluster.
4652
+ def newline?: (::Hash[Symbol, Object] cluster) -> bool
4653
+
4654
+ # Greedy word-wrap, filling each row to a **column** budget while recording
4655
+ # the **character** span that produced it. Whitespace at a soft-wrap break
4656
+ # point is absorbed (not rendered on either row). A token wider than
4657
+ # {#width} hard-wraps inside the token. Newlines force a hard break and
4658
+ # the wrap restarts on the next cluster.
4659
+ #
4660
+ # _@return_ — one entry per row.
4661
+ def compute_rows: () -> ::Array[Row]
3428
4662
 
3429
- # Keeps the caret visible by scrolling vertically.
3430
- def adjust_top_display_row: () -> void
4663
+ # _@param_ `clusters`
4664
+ #
4665
+ # _@param_ `index` — cluster index of the word's first glyph.
4666
+ #
4667
+ # _@return_ — `[chars, columns, next_index]`
4668
+ # for the run of non-whitespace starting at `index`.
4669
+ def measure_word: (::Array[::Hash[Symbol, Object]] clusters, Integer index) -> [Integer, Integer, Integer]
4670
+
4671
+ # Splits a token too wide for a whole row, taking entire glyphs while they
4672
+ # fit. Consumes at least one glyph even when that single glyph is wider than
4673
+ # the row — otherwise the wrap would not terminate (the row would stay empty
4674
+ # and the same token be reconsidered forever). Such a row reports more
4675
+ # columns than {#width} holds and {#row_text} drops the glyph; a 2-column
4676
+ # glyph in a 1-column area is unpaintable either way.
4677
+ #
4678
+ # _@param_ `clusters`
4679
+ #
4680
+ # _@param_ `index`
4681
+ #
4682
+ # _@return_ — `[chars, columns, next_index]`
4683
+ def hard_wrap: (::Array[::Hash[Symbol, Object]] clusters, Integer index) -> [Integer, Integer, Integer]
4684
+
4685
+ # Trims trailing space/tab characters off a row's visible length so the
4686
+ # whitespace at a soft-wrap point is absorbed (not rendered) rather than
4687
+ # left at the end of the row. Without this, soft-wrapping `"foo bar"`
4688
+ # to width 4 would yield row 0 length 4 (`"foo "`) and the natural
4689
+ # end-of-row caret position would coincide with row 1's start.
4690
+ #
4691
+ # Both counts drop by one per trimmed character: a space and a tab each
4692
+ # measure exactly one column.
4693
+ #
4694
+ # _@param_ `row_start`
4695
+ #
4696
+ # _@param_ `row_chars`
4697
+ #
4698
+ # _@param_ `row_cols`
4699
+ #
4700
+ # _@return_ — `[row_chars, row_cols]`
4701
+ def trim_trailing_whitespace: (Integer row_start, Integer row_chars, Integer row_cols) -> [Integer, Integer]
4702
+
4703
+ # _@param_ `row`
4704
+ #
4705
+ # _@param_ `index`
4706
+ #
4707
+ # _@return_ — `index`'s column offset within `row`.
4708
+ def column_in: (Row row, Integer index) -> Integer
4709
+
4710
+ # _@param_ `row`
4711
+ #
4712
+ # _@param_ `column`
4713
+ #
4714
+ # _@return_ — characters from the row's start.
4715
+ def chars_for_column: (Row row, Integer column) -> Integer
4716
+
4717
+ # _@param_ `row`
4718
+ #
4719
+ # _@return_ — the row's visible characters.
4720
+ def glyphs_of: (Row row) -> String
4721
+
4722
+ # Mirrors {AbstractStringField}'s measurement primitive, which this class
4723
+ # can't inherit. Per-cluster rather than whole-string so a multi-codepoint
4724
+ # emoji measures as the one glyph a terminal draws.
4725
+ #
4726
+ # _@param_ `str`
4727
+ #
4728
+ # _@return_ — columns.
4729
+ def columns_of: (String str) -> Integer
3431
4730
 
3432
- # _@return_ — index of the topmost display row currently visible.
3433
- attr_reader top_display_row: Integer
4731
+ attr_reader text: String
4732
+
4733
+ attr_reader width: Integer
4734
+
4735
+ # One row's span, measured on both axes.
4736
+ #
4737
+ # Both counts cover only the row's *visible* content: whitespace absorbed
4738
+ # at a soft wrap, and the newline ending a hard one, belong to no row. So
4739
+ # the next row's `start` may sit past this row's `start + length`, and an
4740
+ # index in that gap resolves to the earlier row (see {WrappedText#row_at}).
4741
+ #
4742
+ # @!attribute [r] start
4743
+ # @return [Integer] character index into {WrappedText#text} where the
4744
+ # row begins.
4745
+ # @!attribute [r] length
4746
+ # @return [Integer] visible characters from `start`.
4747
+ # @!attribute [r] columns
4748
+ # @return [Integer] terminal cells those characters occupy. Exceeds
4749
+ # {WrappedText#width} only for a single glyph too wide for a row.
4750
+ class Row
4751
+ EMPTY: Row
4752
+
4753
+ # _@return_ — character index into {WrappedText#text} where the
4754
+ # row begins.
4755
+ attr_reader start: Integer
4756
+
4757
+ # _@return_ — visible characters from `start`.
4758
+ attr_reader length: Integer
4759
+
4760
+ # _@return_ — terminal cells those characters occupy. Exceeds
4761
+ # {WrappedText#width} only for a single glyph too wide for a row.
4762
+ attr_reader columns: Integer
4763
+ end
4764
+ end
3434
4765
  end
3435
4766
 
3436
4767
  # A read-only viewer for prose: chunks of formatted text that scroll
@@ -3524,7 +4855,7 @@ module Tuile
3524
4855
  # Drops the last `n` hard lines from the buffer — the inverse of building
3525
4856
  # up a tail with {#append} / {#add_line}, so a caller can `remove` then
3526
4857
  # `append` to rewrite a damaged tail in place. Operates on **hard lines**
3527
- # (the `\n`-delimited entries), not wrapped physical rows. `n == 0` and the
4858
+ # (the `\n`-delimited entries), not wrapped rows. `n == 0` and the
3528
4859
  # empty buffer are no-ops; `n >= hard-line count` empties the buffer.
3529
4860
  #
3530
4861
  # _@param_ `n` — number of hard lines to drop; must be >= 0.
@@ -3541,7 +4872,7 @@ module Tuile
3541
4872
  # - an empty `Range` (e.g. `2...2`, or `size...size` at the end) is
3542
4873
  # *insertion* at that position — nothing removed. {#insert} aliases this.
3543
4874
  #
3544
- # Splices in place — only the affected slice of the physical-row buffer is
4875
+ # Splices in place — only the affected slice of the row buffer is
3545
4876
  # touched, no preceding lines re-wrapped (cost O(from + length + new
3546
4877
  # content)). A no-op when the replacement equals the covered range, so
3547
4878
  # `replace(n...n, "")` is cheap.
@@ -3565,10 +4896,27 @@ module Tuile
3565
4896
  # Clears the text. Equivalent to `text = ""`.
3566
4897
  def clear: () -> void
3567
4898
 
4899
+ # Scrolls up half a viewport (`rect.height / 2`, at least one row),
4900
+ # clamped at the top — unlike {#scroll_top_row=}, which raises below `0`.
4901
+ # What `Ctrl+U` does, minus the focus: dispatch delivers keys only along
4902
+ # the focus chain, so this is what a host with focus elsewhere — a chat
4903
+ # transcript under an input field — calls instead of forwarding a
4904
+ # synthetic keystroke that would lie about where focus is.
4905
+ def scroll_half_page_up: () -> void
4906
+
4907
+ # The `Ctrl+D` twin of {#scroll_half_page_up}, clamped at the last row —
4908
+ # arriving there re-arms {#following?}.
4909
+ def scroll_half_page_down: () -> void
4910
+
3568
4911
  def focusable?: () -> bool
3569
4912
 
3570
4913
  def tab_stop?: () -> bool
3571
4914
 
4915
+ # Claims the scroll ladder: the arrows and `j`/`k`, PageUp/PageDown,
4916
+ # `Ctrl+U`/`Ctrl+D`, Home/`g`/End/`G`. Acts on the key alone — a clamped
4917
+ # scroll at either edge is still a handled key, and hand-feeding a key to
4918
+ # an unfocused view scrolls it (dispatch gates on focus, this doesn't).
4919
+ #
3572
4920
  # _@param_ `key`
3573
4921
  def handle_key: (String key) -> bool
3574
4922
 
@@ -3580,7 +4928,7 @@ module Tuile
3580
4928
  # Skips the {Component#repaint} default's auto-clear: every row is
3581
4929
  # painted explicitly (with padded blanks past the last line), so the
3582
4930
  # "fully draw over your rect" contract is met without an upfront wipe.
3583
- # Rows go through {Component#draw_line}, so content and blank rows inherit
4931
+ # Rows go through {Component#draw_text}, so content and blank rows inherit
3584
4932
  # {Component#effective_bg_color} (a {#bg_color} set here or on an ancestor).
3585
4933
  def repaint: () -> void
3586
4934
 
@@ -3606,7 +4954,7 @@ module Tuile
3606
4954
  # _@param_ `what`
3607
4955
  def normalize_replace_range: ((::Range[untyped] | Integer) range, ?Integer size, ?String what) -> [Integer, Integer]
3608
4956
 
3609
- # Hard-line index where `region` begins in {@hard_lines} — derived
4957
+ # Hard-line index where `region` begins in {@lines} — derived
3610
4958
  # by summing the line counts of all regions that precede it.
3611
4959
  #
3612
4960
  # _@param_ `region`
@@ -3647,7 +4995,7 @@ module Tuile
3647
4995
  def append_to_region: (Region region, (String | StyledString)? str) -> void
3648
4996
 
3649
4997
  # Drops the last `n` hard lines from `region`'s tail via
3650
- # {#splice_hard_lines}. `n` is clamped to the region's current
4998
+ # {#splice_lines}. `n` is clamped to the region's current
3651
4999
  # line count; callers guarantee `n > 0` and the region is
3652
5000
  # non-empty (the {Region#remove_last_n_lines} guard handles the
3653
5001
  # no-op cases).
@@ -3658,7 +5006,7 @@ module Tuile
3658
5006
  def remove_last_n_from_region: (Region region, Integer n) -> void
3659
5007
 
3660
5008
  # Drops `region` from {@regions}: its hard lines are removed via
3661
- # {#splice_hard_lines}, the handle is detached, and the always-one
5009
+ # {#splice_lines}, the handle is detached, and the always-one
3662
5010
  # default is restored if the removal would have left zero regions.
3663
5011
  # Skips the rewrap / invalidate work when the region was empty
3664
5012
  # (the buffer didn't change), but always detaches.
@@ -3666,7 +5014,7 @@ module Tuile
3666
5014
  # _@param_ `region`
3667
5015
  def remove_region: (Region region) -> void
3668
5016
 
3669
- # Adjusts region line counts after a {@hard_lines} splice that removed
5017
+ # Adjusts region line counts after a {@lines} splice that removed
3670
5018
  # `removed_count` lines at `from` and inserted `added_count`. Subtracts
3671
5019
  # each region's overlap with the removed range, then credits the added
3672
5020
  # lines to the first region that lost lines. Pure insertions have no such
@@ -3681,68 +5029,71 @@ module Tuile
3681
5029
  def update_region_counts: (Integer from, Integer removed_count, Integer added_count) -> void
3682
5030
 
3683
5031
  # _@return_ — number of visible lines.
3684
- def viewport_lines: () -> Integer
5032
+ def viewport_rows: () -> Integer
3685
5033
 
3686
- # _@return_ — the max value of {#top_line} for scroll-key clamping.
3687
- def top_line_max: () -> Integer
5034
+ # _@return_ — the max value of {#scroll_top_row} for scroll-key clamping.
5035
+ def scroll_top_row_max: () -> Integer
3688
5036
 
3689
- # Full rebuild of {@physical_lines} and {@hard_line_wrap_counts}
3690
- # from {@hard_lines}. Called when wrap width changes (which
5037
+ # _@return_ — half a viewport, at least one row, for the half-page
5038
+ # scroll verbs and their keys.
5039
+ def half_page_rows: () -> Integer
5040
+
5041
+ # Full rebuild of {@rows} and {@line_wrap_counts}
5042
+ # from {@lines}. Called when wrap width changes (which
3691
5043
  # invalidates every cached row count) and from {#text=} (which
3692
5044
  # replaces the whole logical model). Mid-buffer mutators splice
3693
- # incrementally via {#splice_hard_lines} and do *not* go through
3694
- # here. Clamps {@top_line} if the new line count puts it out of
5045
+ # incrementally via {#splice_lines} and do *not* go through
5046
+ # here. Clamps {@scroll_top_row} if the new line count puts it out of
3695
5047
  # range.
3696
5048
  def rewrap: () -> void
3697
5049
 
3698
- # Wraps `hard_line` at `width` and returns the padded physical rows
3699
- # alongside the row count. Empty hard lines (e.g. from a `"\n\n"`
3700
- # run) and degenerate `width <= 0` both emit a single {@blank_line}
5050
+ # Wraps `line` at `width` and returns the padded rows alongside the
5051
+ # row count. Empty lines (e.g. from a `"\n\n"`
5052
+ # run) and degenerate `width <= 0` both emit a single {@blank_row}
3701
5053
  # row, matching what `@text.wrap(width).map { |l| pad_to(l, width) }`
3702
5054
  # would have produced.
3703
5055
  #
3704
- # _@param_ `hard_line`
5056
+ # _@param_ `line`
3705
5057
  #
3706
5058
  # _@param_ `width`
3707
- def wrap_hard_line: (StyledString hard_line, Integer width) -> [::Array[StyledString], Integer]
5059
+ def wrap_line: (StyledString line, Integer width) -> [::Array[StyledString], Integer]
3708
5060
 
3709
- # Appends `hard_line` to the tail of {@hard_lines}, updating the
3710
- # wrap-count cache and {@physical_lines} in lockstep.
5061
+ # Appends `line` to the tail of {@lines}, updating the
5062
+ # wrap-count cache and {@rows} in lockstep.
3711
5063
  #
3712
- # _@param_ `hard_line`
5064
+ # _@param_ `line`
3713
5065
  #
3714
5066
  # _@param_ `width`
3715
- def push_hard_line: (StyledString hard_line, Integer width) -> void
3716
-
3717
- # Pops the last hard line, the corresponding cache entry, and the
3718
- # physical rows that hard line contributed. Returns the popped
3719
- # hard line.
3720
- def pop_hard_line: () -> StyledString
3721
-
3722
- # Splices `new_hard_lines` into the buffer in place of the `count`
3723
- # hard lines starting at index `from`. Updates {@hard_lines},
3724
- # {@hard_line_wrap_counts}, and {@physical_lines} consistently.
3725
- # The starting physical-row offset is computed in O(`from`) integer
3726
- # adds via the cache — no wraps of preceding hard lines. Wraps are
5067
+ def push_line: (StyledString line, Integer width) -> void
5068
+
5069
+ # Pops the last line, the corresponding cache entry, and the rows
5070
+ # that line contributed. Returns the popped line.
5071
+ def pop_line: () -> StyledString
5072
+
5073
+ # Splices `new_lines` into the buffer in place of the `count` lines
5074
+ # starting at index `from`. Updates {@lines},
5075
+ # {@line_wrap_counts}, and {@rows} consistently.
5076
+ # The starting row offset is computed in O(`from`) integer
5077
+ # adds via the cache — no wraps of preceding lines. Wraps are
3727
5078
  # done only for the new content, so total cost is
3728
- # `O(from + count + new_hard_lines.sum(&:display_width))`.
5079
+ # `O(from + count + new_lines.sum(&:display_width))`.
3729
5080
  #
3730
5081
  # _@param_ `from`
3731
5082
  #
3732
- # _@param_ `count` — number of existing hard lines to remove.
5083
+ # _@param_ `count` — number of existing lines to remove.
3733
5084
  #
3734
- # _@param_ `new_hard_lines`
3735
- def splice_hard_lines: (Integer from, Integer count, ::Array[StyledString] new_hard_lines) -> void
5085
+ # _@param_ `new_lines`
5086
+ def splice_lines: (Integer from, Integer count, ::Array[StyledString] new_lines) -> void
3736
5087
 
3737
5088
  # _@param_ `idx`
3738
5089
  #
3739
- # _@return_ — the {@physical_lines} index where the hard line
3740
- # at {@hard_lines}`[idx]` starts. O(`idx`) integer adds via the
5090
+ # _@return_ — the {@rows} index where the line
5091
+ # at {@lines}`[idx]` starts. O(`idx`) integer adds via the
3741
5092
  # wrap-count cache.
3742
- def phys_offset_at: (Integer idx) -> Integer
5093
+ def row_offset_at: (Integer idx) -> Integer
3743
5094
 
3744
- # Rebuilds the joined {StyledString} from {@hard_lines}, inserting a
3745
- # default-styled `"\n"` between hard lines. Called from the {#text}
5095
+ # Rebuilds the joined {StyledString} from {@lines}, inserting a
5096
+ # default-styled `"\n"` between lines. Called from the {#text}
3746
5097
  # reader when the cache is cold. Cost is O(total spans).
3747
5098
  def build_text: () -> StyledString
3748
5099
 
@@ -3752,19 +5103,19 @@ module Tuile
3752
5103
  def wrap_width: () -> Integer
3753
5104
 
3754
5105
  # _@param_ `delta` — negative scrolls up, positive scrolls down.
3755
- def move_top_line_by: (Integer delta) -> void
5106
+ def move_scroll_top_row_by: (Integer delta) -> void
3756
5107
 
3757
- # _@param_ `target` — desired top line; clamped to `[0, top_line_max]`.
3758
- def move_top_line_to: (Integer target) -> void
5108
+ # _@param_ `target` — desired top line; clamped to `[0, scroll_top_row_max]`.
5109
+ def move_scroll_top_row_to: (Integer target) -> void
3759
5110
 
3760
5111
  # Gated on {#following?}: once the user scrolls up off the bottom the
3761
5112
  # viewport pin is skipped, so reading older content is not interrupted
3762
- # by incoming lines. {#top_line=} re-arms `@follow` when the viewport
5113
+ # by incoming lines. {#scroll_top_row=} re-arms `@follow` when the viewport
3763
5114
  # returns to the bottom.
3764
- def update_top_line_if_auto_scroll: () -> void
5115
+ def update_scroll_top_row_if_auto_scroll: () -> void
3765
5116
 
3766
5117
  # _@return_ — whether the viewport is pinned to the last line.
3767
- # Drives {#following?}: re-evaluated on every {#top_line=}.
5118
+ # Drives {#following?}: re-evaluated on every {#scroll_top_row=}.
3768
5119
  def at_bottom?: () -> bool
3769
5120
 
3770
5121
  def scrollbar_visible?: () -> bool
@@ -3775,24 +5126,24 @@ module Tuile
3775
5126
  # `width <= 0` returns {StyledString::EMPTY} to handle the degenerate
3776
5127
  # `wrap_width == 0` case (rect.width == 1 with scrollbar).
3777
5128
  #
3778
- # _@param_ `line`
5129
+ # _@param_ `row`
3779
5130
  #
3780
5131
  # _@param_ `width`
3781
- def pad_to: (StyledString line, Integer width) -> StyledString
5132
+ def pad_to: (StyledString row, Integer width) -> StyledString
3782
5133
 
3783
- # _@param_ `index` — 0-based index into `@physical_lines`.
5134
+ # _@param_ `index` — 0-based index into `@rows`.
3784
5135
  #
3785
5136
  # _@param_ `row_in_viewport` — 0-based row within the viewport.
3786
5137
  #
3787
5138
  # _@param_ `scrollbar`
3788
5139
  #
3789
- # _@return_ — paintable line exactly `rect.width` columns wide.
3790
- # Body lines come pre-padded from {#rewrap}, so this reduces to a lookup
5140
+ # _@return_ — paintable row exactly `rect.width` columns wide.
5141
+ # Body rows come pre-padded from {#rewrap}, so this reduces to a lookup
3791
5142
  # plus a concat of the scrollbar glyph when one is present.
3792
- def paintable_line: (Integer index, Integer row_in_viewport, VerticalScrollBar? scrollbar) -> StyledString
5143
+ def paintable_row: (Integer index, Integer row_in_viewport, VerticalScrollBar? scrollbar) -> StyledString
3793
5144
 
3794
- # _@return_ — index of the first visible physical line.
3795
- attr_accessor top_line: Integer
5145
+ # _@return_ — index of the first visible row.
5146
+ attr_accessor scroll_top_row: Integer
3796
5147
 
3797
5148
  # _@return_ — `:gone` or `:visible`.
3798
5149
  attr_accessor scrollbar_visibility: Symbol
@@ -3986,8 +5337,8 @@ module Tuile
3986
5337
  #
3987
5338
  # - an **index** counts characters into {#text} — {#caret},
3988
5339
  # {#max_text_length}, `text[i]`, every edit;
3989
- # - a **column** counts terminal cells — {#rect}, {#left_column},
3990
- # {#cursor_position}, a {MouseEvent}.
5340
+ # - a **column** counts terminal cells — {#rect}, {#cursor_position}, a
5341
+ # {MouseEvent}, and the private horizontal scroll offset `left_column`.
3991
5342
  #
3992
5343
  # They coincide only while every glyph is one column wide. A fullwidth CJK
3993
5344
  # char is two columns and a combining mark zero, so index 3 of `"日本語"` is
@@ -4021,6 +5372,14 @@ module Tuile
4021
5372
  # _@param_ `key`
4022
5373
  def handle_text_input_key: (String key) -> bool
4023
5374
 
5375
+ # Flattens the paste onto the field's one row — newlines become spaces —
5376
+ # and trims it to what {#max_text_length} still allows. Trimming rather
5377
+ # than rejecting: a paste that overshoots the cap fills the field, which
5378
+ # is what typing the same characters would have done.
5379
+ #
5380
+ # _@param_ `text`
5381
+ def preprocess_paste: (String text) -> String
5382
+
4024
5383
  def on_text_mutated: () -> void
4025
5384
 
4026
5385
  def on_caret_mutated: () -> void
@@ -4088,10 +5447,6 @@ module Tuile
4088
5447
  # _@return_ — maximum characters, or nil for unbounded (default).
4089
5448
  attr_accessor max_text_length: Integer?
4090
5449
 
4091
- # _@return_ — text column drawn in the field's leftmost cell — the
4092
- # horizontal scroll offset. Follows {#caret}, always on a glyph boundary.
4093
- attr_reader left_column: Integer
4094
-
4095
5450
  # Optional callback fired when the UP arrow key is pressed. When set, UP
4096
5451
  # is consumed by the field; when nil, UP falls through to the parent
4097
5452
  # (default behavior). Only triggered by {Keys::UP_ARROW}, not by `k`,
@@ -4114,6 +5469,15 @@ module Tuile
4114
5469
  #
4115
5470
  # _@return_ — no-arg callable, or nil.
4116
5471
  attr_accessor on_enter: (Proc | Method)?
5472
+
5473
+ # Internal — the field's own scroll state, with no caller outside this
5474
+ # class: the paint, the cursor and the hit test all read the ivar, and
5475
+ # nothing above the field has a column to spend it on. Specs assert the
5476
+ # scrolling through `send`.
5477
+ #
5478
+ # _@return_ — text column drawn in the field's leftmost cell — the
5479
+ # horizontal scroll offset. Follows {#caret}, always on a glyph boundary.
5480
+ attr_reader left_column: Integer
4117
5481
  end
4118
5482
 
4119
5483
  # A single-line field whose {#value} is a `Float` (or `nil` when empty) —
@@ -4328,12 +5692,13 @@ module Tuile
4328
5692
  # initial state, and assigning it is the only way back, since Space on the
4329
5693
  # already-selected row is a no-op rather than a deselect.
4330
5694
  #
4331
- # Composes rather than subclasses, like {ComboBox}: a {List} is its single
4332
- # {HasContent} child, which is where the cursor, scrolling, the scrollbar
4333
- # and per-row mouse hit-testing come from. `content` is that list, so an app
4334
- # can tune it (`scrollbar_visibility`, `show_cursor_when_inactive`, …). Rows
4335
- # beyond {#rect}'s height scroll; the inner list is the tab stop, not the
4336
- # group.
5695
+ # Composes rather than subclasses, like {ComboBox}: a {List} of the items
5696
+ # is its single {HasContent} child, which is where the cursor, scrolling,
5697
+ # the scrollbar and per-row mouse hit-testing come from — the group only
5698
+ # supplies the {List#renderer} that puts the marker in front of the label.
5699
+ # `content` is that list, so an app can tune it (`scrollbar_visibility`,
5700
+ # `show_cursor_when_inactive`, …). Rows beyond {#rect}'s height scroll; the
5701
+ # inner list is the tab stop, not the group.
4337
5702
  #
4338
5703
  # == The cursor is chrome
4339
5704
  # The cursor and the selection are two independent things, as in
@@ -4357,7 +5722,7 @@ module Tuile
4357
5722
  # == Implementation details
4358
5723
  # Two `==`-equal items share one selection, so selecting either marks both
4359
5724
  # rows; two *distinct* items that merely render the same label stay
4360
- # independent, because a row resolves to an item by index.
5725
+ # independent, because a row resolves to its own item, never to its label.
4361
5726
  #
4362
5727
  # Rows are `(*) `/`( ) ` literals, mirroring {Checkbox}'s convention rather
4363
5728
  # than importing constants from it. ASCII deliberately: `(•)` would measure
@@ -4374,6 +5739,15 @@ module Tuile
4374
5739
  # _@param_ `value` — the initially selected item. Seeds the backing ivar directly, so no listener fires and assignment order doesn't matter to a form helper.
4375
5740
  def initialize: (?items: ::Array[untyped], ?value: Object?) -> void
4376
5741
 
5742
+ # _@return_ — the presented items.
5743
+ def items: () -> ::Array[untyped]
5744
+
5745
+ # Replaces the presented rows, leaving {#value} untouched and clamping the
5746
+ # cursor back into range.
5747
+ #
5748
+ # _@param_ `new_items`
5749
+ def items=: (::Array[untyped] new_items) -> void
5750
+
4377
5751
  # Selects `new_value`, firing {HasValue#on_value_change} when it really
4378
5752
  # changed. The cursor stays where it is.
4379
5753
  #
@@ -4393,19 +5767,26 @@ module Tuile
4393
5767
  # _@param_ `list`
4394
5768
  def layout: (Component list) -> void
4395
5769
 
4396
- # Selects the item on row `index`; an index outside {#items} is ignored.
5770
+ # Selects the item on row `index`; an index outside {#items} is ignored —
5771
+ # {List::Cursor::None}'s `-1` would otherwise select the *last* item.
4397
5772
  #
4398
5773
  # _@param_ `index`
4399
5774
  def select_at: (Integer index) -> void
4400
5775
 
4401
- # Re-renders every row from the current items, labels and selection.
4402
- def rebuild_rows: () -> void
5776
+ # _@param_ `item`
5777
+ #
5778
+ # _@return_ — the item's row: its label behind a selection
5779
+ # marker. The {List} calls this at paint time, so the marker tracks
5780
+ # {#value} without re-rendering anything but the visible rows.
5781
+ def render_row: (Object item) -> StyledString
4403
5782
 
4404
5783
  # Pulls an over-range cursor back onto the last row (row 0 when there are
4405
- # none). {List#lines=} leaves a stale cursor alone, which would strand it
5784
+ # none). {List#items=} leaves a stale cursor alone, which would strand it
4406
5785
  # off-content: no highlight, a dead Enter, and a Space that resolves to
4407
5786
  # `nil` and silently clears the selection.
4408
- def clamp_cursor: () -> void
5787
+ #
5788
+ # _@param_ `item_count` — size of the incoming item list.
5789
+ def clamp_cursor: (Integer item_count) -> void
4409
5790
 
4410
5791
  # _@param_ `item`
4411
5792
  #
@@ -4441,14 +5822,182 @@ module Tuile
4441
5822
 
4442
5823
  def on_focus: () -> void
4443
5824
 
4444
- # _@return_ — the presented items.
4445
- attr_accessor items: ::Array[untyped]
4446
-
4447
5825
  # _@return_ — item -> row label (a `String`, {StyledString}, or
4448
5826
  # anything with `#to_s`); `:to_s` by default.
4449
5827
  attr_accessor item_label: (Proc | Method)
4450
5828
  end
4451
5829
 
5830
+ # A transient message in the screen's top-right corner — the TTY toast:
5831
+ #
5832
+ # Component::Notification.show("Saved")
5833
+ # Component::Notification.show("Disk full", color: Theme.ref(:error))
5834
+ #
5835
+ # ┌─────────┐ ← flush: row 0, right edge at the last column
5836
+ # │Saved │ ← oldest on top, retires in 3 s
5837
+ # │Disk full│ ← then this one, 3 s after that
5838
+ # └─────────┘
5839
+ #
5840
+ # {show} is the only entry point ({new} is private): it finds the live
5841
+ # notification and appends to it, so a burst stacks as entries in one box
5842
+ # instead of opening five overlapping ones.
5843
+ #
5844
+ # One repeating ticker retires the **oldest** entry every {DISPLAY_SECONDS}
5845
+ # and closes the box when the last one goes — five messages raised together
5846
+ # appear at once and drain over fifteen seconds. A message arriving mid-cycle
5847
+ # waits its turn and does *not* restart the clock, so the bottom entry of a
5848
+ # full box is visible for about `N × DISPLAY_SECONDS`. Past {MAX_MESSAGES} a
5849
+ # message is dropped and reported to {Tuile.logger}; an app notifying faster
5850
+ # than that wants a {Component::LogWindow}.
5851
+ #
5852
+ # The box is flush to the corner, at most {WIDTH_FRACTION} of the screen wide
5853
+ # (floor {MIN_CAP_WIDTH}) and {HEIGHT_FRACTION} tall, and **grows but never
5854
+ # shrinks** while it lives; a long message wraps to {MAX_ROWS_PER_MESSAGE}
5855
+ # rows and is then ellipsized, and entries past the height cap wait unpainted.
5856
+ # `DECISIONS.md` `D-notification` has why each of those is what it is.
5857
+ #
5858
+ # Three things it deliberately doesn't do:
5859
+ #
5860
+ # - **Take focus, or receive keys.** A non-modal popup sits off the
5861
+ # key-dispatch scope ({ScreenPane#handle_key}), so not even {Popup}'s
5862
+ # `q`/ESC arrives here. A left click *on the box* dismisses
5863
+ # ({#handle_mouse}); an app wanting a key registers a global shortcut and
5864
+ # calls {#close}. A click *elsewhere* does not — this is the one popup
5865
+ # with {Popup#close_on_outside_click?} false, since a toast is timed and
5866
+ # an unrelated click is not about it.
5867
+ # - **Follow a theme flip.** A `Theme::Ref` `color:` is resolved once, when
5868
+ # the message is added — a toast lives seconds, so there is no
5869
+ # {Component#on_theme_changed} rebuild.
5870
+ # - **Take a size.** {#size=} raises; the messages decide.
5871
+ class Notification < Tuile::Component::Popup
5872
+ MAX_MESSAGES: Integer
5873
+ MAX_ROWS_PER_MESSAGE: Integer
5874
+ DISPLAY_SECONDS: Float
5875
+ WIDTH_FRACTION: Float
5876
+ HEIGHT_FRACTION: Float
5877
+ MIN_CAP_WIDTH: Integer
5878
+ SPACE: StyledString
5879
+ ROW_BREAK: StyledString
5880
+
5881
+ # Shows `text` in the corner, creating the box if none is open and
5882
+ # appending to it if one is.
5883
+ #
5884
+ # _@param_ `text` — the message. A `String` is parsed via {StyledString.parse}, so embedded ANSI is honored. `nil` and the empty string are no-ops (nothing is shown, nothing is created).
5885
+ #
5886
+ # _@param_ `color` — applied to every span of the message via {StyledString#with_fg}. A {Theme::Ref} is resolved against the current theme *now* — see the class docs on theme following. `nil` leaves the message's own colors alone.
5887
+ #
5888
+ # _@return_ — the live notification, or `nil` when `text`
5889
+ # was empty.
5890
+ def self.show: ((String | StyledString)? text, ?color: (Color | Theme::Ref | Symbol | Integer | ::Array[Integer])?) -> Notification?
5891
+
5892
+ def initialize: () -> void
5893
+
5894
+ # Load-bearing, not cosmetic: focus landing inside a non-modal popup sits
5895
+ # outside the key-dispatch scope, where {ScreenPane#handle_key} delivers to
5896
+ # nobody — every keystroke would go dead until the user pressed Tab.
5897
+ #
5898
+ # _@return_ — false.
5899
+ def focusable?: () -> bool
5900
+
5901
+ # _@return_ — false — see {#focusable?}.
5902
+ def tab_stop?: () -> bool
5903
+
5904
+ # Appends a message, dropping it (with a {Tuile.logger} warning) once
5905
+ # {MAX_MESSAGES} are held. Public so a caller holding the instance can
5906
+ # append without repeating {show}'s lookup.
5907
+ #
5908
+ # _@param_ `text` — see {show}. Empty is a no-op.
5909
+ #
5910
+ # _@param_ `color` — see {show}.
5911
+ def add_message: ((String | StyledString)? text, ?color: (Color | Theme::Ref | Symbol | Integer | ::Array[Integer])?) -> void
5912
+
5913
+ # Recomputes the box from its messages and re-anchors it to the screen's
5914
+ # top-right corner — so a SIGWINCH re-wraps and re-anchors, where
5915
+ # {Popup#reposition} would have kept the stale left column of a *derived*
5916
+ # position (off-screen entirely if the terminal narrowed).
5917
+ #
5918
+ # Rebuilds the {TextView}'s text too, and every mutation routes through
5919
+ # here, because the four are one computation: the wrap width *is* the box
5920
+ # width, the height *is* the wrapped row count, the left edge *is* derived
5921
+ # from the width.
5922
+ def reposition: () -> void
5923
+
5924
+ # A notification is sized by its messages, so this always raises. Failing
5925
+ # loudly beats accepting a size the next {#reposition} would discard.
5926
+ #
5927
+ # _@param_ `_new_size`
5928
+ def size=: ((Size | Fraction) _new_size) -> void
5929
+
5930
+ # A left click dismisses the whole box, every message with it. Other buttons
5931
+ # are consumed and inert — including the scroll wheel, which would otherwise
5932
+ # nuke the box on a stray spin.
5933
+ #
5934
+ # Deliberately *replaces* rather than augments: neither `super` nor
5935
+ # {HasContent#handle_mouse} may run, since both end at a
5936
+ # `screen.focused = …` inside this subtree (see {#focusable?}).
5937
+ #
5938
+ # _@param_ `event`
5939
+ def handle_mouse: (MouseEvent event) -> void
5940
+
5941
+ def on_attached: () -> void
5942
+
5943
+ def on_detached: () -> void
5944
+
5945
+ # Retires the oldest message, closing the box when it was the last. Runs on
5946
+ # the event-loop thread, from the ticker.
5947
+ def retire_oldest: () -> void
5948
+
5949
+ # Syncs the retirement clock from the invariant "something to retire, and on
5950
+ # screen" — the sole writer of `@ticker`. Four sites change whether it is
5951
+ # wanted (append, a retirement that empties the queue, {#close}, detach),
5952
+ # which is the 2×2 a start-in-{#on_attached} / cancel-in-{#on_detached} pair
5953
+ # gets half wrong. The early return is also what keeps an append from
5954
+ # *restarting* the clock and extending the oldest message's life.
5955
+ def sync_ticker: () -> void
5956
+
5957
+ # _@param_ `text`
5958
+ #
5959
+ # _@param_ `color`
5960
+ def build_message: ((String | StyledString)? text, (Color | Theme::Ref | Symbol | Integer | ::Array[Integer])? color) -> StyledString
5961
+
5962
+ # Columns the message would like, ignoring wrapping — the *widest* of its
5963
+ # hard lines, not the sum of its spans (which would add every line
5964
+ # together for a message carrying `\n`).
5965
+ #
5966
+ # _@param_ `message`
5967
+ def natural_width: (StyledString message) -> Integer
5968
+
5969
+ # Grow-only: the high-water mark is kept in *desired* columns and the cap
5970
+ # is applied here, last. Storing the clamped value instead would let a
5971
+ # SIGWINCH that narrows the terminal ratchet the box permanently down to
5972
+ # the narrow cap, with nothing to restore it when the terminal widens.
5973
+ def box_width: () -> Integer
5974
+
5975
+ def cap_width: () -> Integer
5976
+
5977
+ # _@return_ — at least 3: two border rows plus one row of message.
5978
+ def cap_height: () -> Integer
5979
+
5980
+ # Wraps one message to `width` columns, capped at {MAX_ROWS_PER_MESSAGE}
5981
+ # rows.
5982
+ #
5983
+ # The overflow is ellipsized from the *joined remainder*, not by
5984
+ # ellipsizing the last kept row: that row usually already fits `width`, so
5985
+ # {StyledString#ellipsize} would be a no-op and the message would be
5986
+ # truncated with no `…` to say so.
5987
+ #
5988
+ # _@param_ `message`
5989
+ #
5990
+ # _@param_ `width`
5991
+ def wrap_message: (StyledString message, Integer width) -> ::Array[StyledString]
5992
+
5993
+ # Joins pre-wrapped rows into one {StyledString} with `\n` separators, so
5994
+ # {TextView} takes them as hard lines and its own wrap is a no-op over
5995
+ # them (each row already fits the width it will be painted at).
5996
+ #
5997
+ # _@param_ `rows`
5998
+ def join_rows: (::Array[StyledString] rows) -> StyledString
5999
+ end
6000
+
4452
6001
  # A one-row progress bar: a run of `█` growing left to right across {#rect},
4453
6002
  # over a `░` track.
4454
6003
  #
@@ -4710,18 +6259,20 @@ module Tuile
4710
6259
  # highlight, and reads the pick.
4711
6260
  #
4712
6261
  # drop = Component::ListDropdown.new
4713
- # drop.on_item_chosen = ->(index, _line) { commit(index) } # caller commits
6262
+ # drop.renderer = method(:label_for) # caller renders
6263
+ # drop.on_item_chosen = ->(_index, item) { commit(item) } # caller commits
4714
6264
  # # …then, from the driver's key handler:
4715
- # drop.lines = matches.map { |m| render(m) } # caller filters + renders
6265
+ # drop.items = matches # caller filters
4716
6266
  # drop.anchor_to(rect, rows: matches.size) # below the driver, or flipped
4717
6267
  # drop.open
4718
6268
  # return true if drop.move(key) # Up/Down/PgUp/PgDn/^U/^D → list scroll
4719
6269
  # drop.choose if key == Keys::ENTER # commit the highlight
4720
6270
  #
4721
6271
  # It owns only what every such dropdown shares — *placement* included, via
4722
- # {#anchor_to}. What stays with the driver: the width **policy** ({#anchor_to}
4723
- # measures nothing itself), filtering, row rendering, the commit action, and
4724
- # ESC/Enter handling. ESC and Enter carry driver-specific tails (ESC may
6272
+ # {#anchor_to} (below a field) and {#anchor_beside} (beside a parent row, for
6273
+ # a cascading submenu). What stays with the driver: the width **policy**
6274
+ # (neither placement method measures anything itself), filtering, row
6275
+ # rendering, the commit action, and ESC/Enter handling. ESC and Enter carry driver-specific tails (ESC may
4725
6276
  # revert a query; Enter may commit via {#choose} *or* via a separate submit
4726
6277
  # path), so {#move} claims neither — the driver calls {#choose} and {#close}
4727
6278
  # from its own branches.
@@ -4739,21 +6290,36 @@ module Tuile
4739
6290
 
4740
6291
  def initialize: () -> void
4741
6292
 
4742
- # _@param_ `lines` — the rows to show; see {List#lines=}.
4743
- def lines=: (::Array[untyped] lines) -> void
6293
+ # _@param_ `items` — the items to show, one row each; see {List#items=}.
6294
+ def items=: (::Array[untyped] items) -> void
4744
6295
 
4745
- # _@return_ — the current rows.
4746
- def lines: () -> ::Array[StyledString]
6296
+ # _@return_ — the items currently shown.
6297
+ def items: () -> ::Array[untyped]
6298
+
6299
+ # _@param_ `proc` — item -> row; see {List#renderer}.
6300
+ def renderer=: ((Proc | Method) proc) -> void
4747
6301
 
4748
6302
  # _@param_ `proc` — commit callback; see {List#on_item_chosen}.
4749
6303
  def on_item_chosen=: ((Proc | Method)? proc) -> void
4750
6304
 
6305
+ # _@param_ `proc` — highlight-moved callback; see {List#on_cursor_changed}. A cascading driver needs it to drop the panels that belonged to the row the highlight just left.
6306
+ def on_cursor_changed=: ((Proc | Method)? proc) -> void
6307
+
4751
6308
  # _@param_ `cursor` — the highlight; see {List#cursor=}.
4752
6309
  def cursor=: (List::Cursor cursor) -> void
4753
6310
 
4754
6311
  # _@return_ — the list's cursor (the current highlight).
4755
6312
  def cursor: () -> List::Cursor
4756
6313
 
6314
+ # Moves the highlight to the item at `index`, scrolling it into view; see
6315
+ # {List#select}. The positional counterpart of {#move}, for a driver that
6316
+ # picked a row by something other than a key — a mnemonic letter, say.
6317
+ #
6318
+ # _@param_ `index`
6319
+ #
6320
+ # _@return_ — whether the highlight moved there.
6321
+ def select: (Integer index) -> bool
6322
+
4757
6323
  # Sizes and places the dropdown against `anchor`: directly beneath it,
4758
6324
  # flipped above when `rows` won't fit below, clamped — with the list
4759
6325
  # scrolling — when neither side has room. Horizontally the left edges line
@@ -4779,6 +6345,49 @@ module Tuile
4779
6345
  ?max_rows: Integer
4780
6346
  ) -> void
4781
6347
 
6348
+ # Sizes and places the dropdown *beside* `anchor` — the placement a
6349
+ # cascading submenu wants, where {#anchor_to} is the placement a field's
6350
+ # dropdown wants.
6351
+ #
6352
+ # sub.anchor_beside(parent.cursor_row_rect, rows: kids.size, width: measured)
6353
+ #
6354
+ # Horizontally it sits against `anchor`'s right edge, **flipping** to its
6355
+ # left when the right has no room (and clamping to the screen when neither
6356
+ # side does). Vertically it **slides**: the panel's first row lines up with
6357
+ # the anchored row, sliding up only far enough to keep the panel on screen.
6358
+ #
6359
+ # The two axes are the mirror image of {#anchor_to}'s, for the same reason:
6360
+ # never cover the thing being chosen from. A field's dropdown must not
6361
+ # cover the field, so it flips *vertically* and shares its columns; a
6362
+ # submenu must not cover its parent panel, so it flips *horizontally* and
6363
+ # shares its rows.
6364
+ #
6365
+ # _@param_ `anchor` — the row the submenu belongs to — typically the parent dropdown's {#cursor_row_rect}. Its width is the parent panel's, which is what the submenu clears.
6366
+ #
6367
+ # _@param_ `rows` — how many rows there are to show — the content count, not the height; more than fits turns the scrollbar on. `0` collapses the dropdown to an empty rect (drivers close instead).
6368
+ #
6369
+ # _@param_ `width` — the panel's width in columns, clamped to the screen. **Required, with no default:** `anchor.width` is the *parent's* width and would be meaningless here, so the caller measures (see `DECISIONS.md` `D-select` on why the width policy stays with the driver).
6370
+ #
6371
+ # _@param_ `max_rows` — rows shown before the list scrolls.
6372
+ def anchor_beside: (
6373
+ Rect anchor,
6374
+ rows: Integer,
6375
+ width: Integer,
6376
+ ?max_rows: Integer
6377
+ ) -> void
6378
+
6379
+ # The highlighted row's rect on screen — what a cascading submenu anchors
6380
+ # against, via {#anchor_beside}.
6381
+ #
6382
+ # It lives here rather than in the driver because {ListDropdown} owns the
6383
+ # list's geometry: a driver computing `top + position - scroll_top_row`
6384
+ # itself would have to reach through to the private list.
6385
+ #
6386
+ # _@return_ — one row spanning the panel's width, or `nil` when
6387
+ # the cursor is off-content ({List::Cursor::None}, an empty list) or its
6388
+ # row is scrolled out of the viewport.
6389
+ def cursor_row_rect: () -> Rect?
6390
+
4782
6391
  # Forwards a cursor-movement key to the list. The driver calls this from
4783
6392
  # its own key handler; a truthy return means "consumed — stop here", falsy
4784
6393
  # means "not mine — proceed with normal editing/dispatch". Only {MOVE_KEYS}
@@ -4802,6 +6411,8 @@ module Tuile
4802
6411
  # stealing focus — so a driving text input never loses its caret
4803
6412
  # mid-interaction.
4804
6413
  class Menu < Tuile::Component::List
6414
+ DEFAULT_RENDERER: Proc
6415
+
4805
6416
  def focusable?: () -> bool
4806
6417
 
4807
6418
  def tab_stop?: () -> bool
@@ -4830,8 +6441,6 @@ module Tuile
4830
6441
  # _@param_ `key`
4831
6442
  def handle_key: (String key) -> bool
4832
6443
 
4833
- def keyboard_hint: () -> String
4834
-
4835
6444
  # Opens a picker as a popup. Picking an option fires `block`, then
4836
6445
  # closes the popup; ESC / `q` close without firing `block`.
4837
6446
  #
@@ -4885,12 +6494,13 @@ module Tuile
4885
6494
  # Treat it as *unordered*: it iterates in toggle order, so use
4886
6495
  # `cg.items & cg.value.to_a` when you need {#items} order.
4887
6496
  #
4888
- # Composes rather than subclasses, like {ComboBox}: a {List} is its single
4889
- # {HasContent} child, which is where the cursor, scrolling, the scrollbar and
4890
- # per-row mouse hit-testing come from. `content` is that list, so an app can
4891
- # tune it (`scrollbar_visibility`, `show_cursor_when_inactive`, …). Rows
4892
- # beyond {#rect}'s height scroll; the inner list is the tab stop, not the
4893
- # group.
6497
+ # Composes rather than subclasses, like {ComboBox}: a {List} of the items is
6498
+ # its single {HasContent} child, which is where the cursor, scrolling, the
6499
+ # scrollbar and per-row mouse hit-testing come from — the group only supplies
6500
+ # the {List#renderer} that puts the box in front of the label. `content` is
6501
+ # that list, so an app can tune it (`scrollbar_visibility`,
6502
+ # `show_cursor_when_inactive`, …). Rows beyond {#rect}'s height scroll; the
6503
+ # inner list is the tab stop, not the group.
4894
6504
  #
4895
6505
  # == +items+ is chrome; +value+ is authoritative
4896
6506
  # {#items=} changes only what is *presented*. It never touches {#value} and
@@ -4908,7 +6518,7 @@ module Tuile
4908
6518
  # mutated after being selected becomes unfindable. Two `==`-equal items also
4909
6519
  # share one selection — their rows check and uncheck together — whereas two
4910
6520
  # *distinct* items that merely render the same label toggle independently,
4911
- # because a row resolves to an item by index.
6521
+ # because a row resolves to its own item, never to its label.
4912
6522
  #
4913
6523
  # Rows repeat {Checkbox}'s `[x] `/`[ ] ` glyph convention rather than
4914
6524
  # importing a constant from it.
@@ -4924,6 +6534,14 @@ module Tuile
4924
6534
  # _@param_ `value` — the initial selection. Seeds the backing ivar directly, so no listener fires and assignment order doesn't matter to a form helper.
4925
6535
  def initialize: (?items: ::Array[untyped], ?value: ::Enumerable[untyped]?) -> void
4926
6536
 
6537
+ # _@return_ — the presented items.
6538
+ def items: () -> ::Array[untyped]
6539
+
6540
+ # Replaces the presented rows, leaving {#value} untouched.
6541
+ #
6542
+ # _@param_ `new_items`
6543
+ def items=: (::Array[untyped] new_items) -> void
6544
+
4927
6545
  # _@return_ — the frozen empty set — {HasValue#empty?} means nothing is
4928
6546
  # selected.
4929
6547
  def empty_value: () -> ::Set[untyped]
@@ -4949,13 +6567,23 @@ module Tuile
4949
6567
  def layout: (Component list) -> void
4950
6568
 
4951
6569
  # Flips membership of the item on row `index`; an index outside {#items} is
4952
- # ignored.
6570
+ # ignored — {List::Cursor::None}'s `-1` would otherwise toggle the *last*
6571
+ # item.
4953
6572
  #
4954
6573
  # _@param_ `index`
4955
6574
  def toggle_at: (Integer index) -> void
4956
6575
 
4957
- # Re-renders every row from the current items, labels and selection.
4958
- def rebuild_rows: () -> void
6576
+ # Flips `item`'s membership of {#value}.
6577
+ #
6578
+ # _@param_ `item`
6579
+ def toggle: (Object item) -> void
6580
+
6581
+ # _@param_ `item`
6582
+ #
6583
+ # _@return_ — the item's row: its label behind a checkmark box.
6584
+ # The {List} calls this at paint time, so the boxes track {#value}
6585
+ # without re-rendering anything but the visible rows.
6586
+ def render_row: (Object item) -> StyledString
4959
6587
 
4960
6588
  # _@param_ `new_value`
4961
6589
  #
@@ -4992,9 +6620,6 @@ module Tuile
4992
6620
 
4993
6621
  def on_focus: () -> void
4994
6622
 
4995
- # _@return_ — the presented items.
4996
- attr_accessor items: ::Array[untyped]
4997
-
4998
6623
  # _@return_ — item -> row label (a `String`, {StyledString}, or
4999
6624
  # anything with `#to_s`); `:to_s` by default.
5000
6625
  attr_accessor item_label: (Proc | Method)
@@ -5226,6 +6851,8 @@ module Tuile
5226
6851
  #
5227
6852
  # - {#preprocess_text} — input filter (e.g. {TextField} truncates to
5228
6853
  # fit `rect.width - 1`).
6854
+ # - {#preprocess_paste} — the same for {#handle_paste}, which lands a
6855
+ # whole clipboard at the caret in one mutation.
5229
6856
  # - {#on_text_mutated} / {#on_caret_mutated} — post-mutation side
5230
6857
  # effects (e.g. {TextArea} invalidates its wrap cache and scrolls to
5231
6858
  # keep the caret visible).
@@ -5257,6 +6884,34 @@ module Tuile
5257
6884
  # _@param_ `key`
5258
6885
  def handle_key: (String key) -> bool
5259
6886
 
6887
+ # Inserts pasted text at the caret as **one** mutation, so {#on_change}
6888
+ # fires once for the whole paste rather than once per character.
6889
+ # {#preprocess_paste} filters it first.
6890
+ #
6891
+ # _@param_ `text`
6892
+ #
6893
+ # _@return_ — always true — a field consumes every paste, an empty
6894
+ # one included.
6895
+ def handle_paste: (String text) -> bool
6896
+
6897
+ # Input filter for {#handle_paste}, the paste-side counterpart of
6898
+ # {#preprocess_text}. Strips the C0 control characters a text buffer
6899
+ # cannot hold — a raw `\e` or `\t` reaching {Buffer} would move the real
6900
+ # terminal cursor mid-frame — keeping `\n`, and turning a tab into a
6901
+ # single space so pasted code keeps its word gaps. {TextField} narrows it
6902
+ # further; an app wanting tab *expansion* overrides {#handle_paste}.
6903
+ #
6904
+ # _@param_ `text`
6905
+ def preprocess_paste: (String text) -> String
6906
+
6907
+ # Inserts `str` at the caret, leaving the caret behind it. The bulk
6908
+ # counterpart of a subclass's per-key insert.
6909
+ #
6910
+ # _@param_ `str`
6911
+ #
6912
+ # _@return_ — true if the text changed.
6913
+ def insert_text: (String str) -> bool
6914
+
5260
6915
  # Renders `text` on the field's background well, looked up from the
5261
6916
  # current {Screen#theme} at paint time: {Theme#active_bg_color} when this
5262
6917
  # input is on the active (focus) chain, {Theme#input_bg_color} otherwise —
@@ -5563,6 +7218,28 @@ module Tuile
5563
7218
  attr_reader key: String
5564
7219
  end
5565
7220
 
7221
+ # Text arrived from the clipboard rather than the keyboard: the terminal
7222
+ # bracketed it in {Keys::PASTE_START} … {Keys::PASTE_END} because
7223
+ # {Screen#run_event_loop} enabled mode 2004. The whole paste is one event,
7224
+ # so a component sees one mutation instead of a keystroke per character —
7225
+ # and a pasted line break can no longer be mistaken for a typed Enter.
7226
+ #
7227
+ # {Screen#event_loop} routes it to {Component#handle_paste} down the focus
7228
+ # chain. It never enters the key ladder: no Tab traversal, no global
7229
+ # shortcut, no {Component#handle_key}.
7230
+ #
7231
+ # @!attribute [r] text
7232
+ # @return [String] the pasted text, `\n`-normalized by
7233
+ # {Keys.normalize_paste}.
7234
+ class PasteEvent
7235
+ # _@param_ `text`
7236
+ def initialize: (text: String) -> void
7237
+
7238
+ # _@return_ — the pasted text, `\n`-normalized by
7239
+ # {Keys.normalize_paste}.
7240
+ attr_reader text: String
7241
+ end
7242
+
5566
7243
  # An error event, causes {EventQueue#run_loop} to throw `StandardError` with
5567
7244
  # {#error} as its origin.
5568
7245
  #
@@ -5699,6 +7376,22 @@ module Tuile
5699
7376
  # _@param_ `str`
5700
7377
  def emit: (String str) -> void
5701
7378
 
7379
+ # Pastes `text` into the focused component, as a real terminal would with
7380
+ # bracketed paste on:
7381
+ #
7382
+ # area.focus
7383
+ # Screen.instance.paste("one\r\ntwo")
7384
+ # area.text # => "one\ntwo" — one mutation, no ENTER anywhere
7385
+ #
7386
+ # Goes through {Keys.normalize_paste} first, so a spec can hand it the
7387
+ # CR-flavored line endings terminals actually deliver and still assert
7388
+ # against `\n`.
7389
+ #
7390
+ # _@param_ `text`
7391
+ #
7392
+ # _@return_ — true if some component consumed it.
7393
+ def paste: (String text) -> bool
7394
+
5702
7395
  # _@param_ `component` — the component to check.
5703
7396
  def invalidated?: (Component component) -> bool
5704
7397
 
@@ -5773,10 +7466,15 @@ module Tuile
5773
7466
  #
5774
7467
  # {Screen} is a singleton runtime owner (event loop, lock, terminal IO,
5775
7468
  # invalidation set). All actual UI lives under a {ScreenPane}: the tiled
5776
- # {#content}, the modal {#popups} stack, and the bottom {#status_bar}.
5777
- # Putting them under a single Component parent gives focus traversal a real
5778
- # root, makes {Component#attached?} a one-liner, and lets popup-focus repair
5779
- # fall out of the standard {Component#on_child_removed} hook.
7469
+ # {#content} and the {#popups} stack. Putting them under a single Component
7470
+ # parent gives focus traversal a real root, makes {Component#attached?} a
7471
+ # one-liner, and lets popup-focus repair fall out of the standard
7472
+ # {Component#on_child_removed} hook.
7473
+ #
7474
+ # The pane owns no chrome of its own — no status bar, no reserved row.
7475
+ # {#content} gets the full pane rect, and an app that wants a status line
7476
+ # builds one into its own layout and drives it from
7477
+ # {Screen#on_focus_changed=} (`D-status-bar`).
5780
7478
  #
5781
7479
  # The pane is not a {Component::Layout}: popups deliberately overlap content
5782
7480
  # (Z-ordered, full overdraw, no clipping) and key/mouse dispatch follows
@@ -5826,9 +7524,9 @@ module Tuile
5826
7524
 
5827
7525
  # _@return_ — the topmost *modal* popup, or nil when
5828
7526
  # only non-modal overlays (or no popups) are open. This is the "modal
5829
- # owner": the popup that scopes key dispatch, blocks mouse clicks, owns
5830
- # the status bar, and confines Tab cycling. Non-modal overlays are
5831
- # excluded — they float above the content without capturing input.
7527
+ # owner": the popup that scopes key dispatch, blocks mouse clicks, and
7528
+ # confines Tab cycling. Non-modal overlays are excluded — they float above
7529
+ # the content without capturing input.
5832
7530
  def modal_popup: () -> Component::Popup?
5833
7531
 
5834
7532
  # Re-lays out children whenever the pane's own rect changes.
@@ -5836,11 +7534,11 @@ module Tuile
5836
7534
  # _@param_ `new_rect`
5837
7535
  def rect=: (Rect new_rect) -> void
5838
7536
 
5839
- # Lays out content (full pane minus the bottom row) and the status bar
5840
- # (bottom row). Each popup re-resolves its {Component::Popup#size} against
5841
- # the new screen via {Component::Popup#reposition} — so a {Fraction} size
5842
- # tracks resize — repositioning itself (modal popups recenter; non-modal
5843
- # overlays keep the top-left their owner assigned).
7537
+ # Gives {#content} the whole pane rect — the pane reserves nothing for
7538
+ # itself. Each popup re-resolves its {Component::Popup#size} against the new
7539
+ # screen via {Component::Popup#reposition} — so a {Fraction} size tracks
7540
+ # resize — repositioning itself (modal popups recenter; non-modal overlays
7541
+ # keep the top-left their owner assigned).
5844
7542
  def layout: () -> void
5845
7543
 
5846
7544
  # Pane paints nothing itself; its children paint over the entire rect.
@@ -5867,6 +7565,15 @@ module Tuile
5867
7565
  # _@return_ — true if the key was handled.
5868
7566
  def handle_key: (String key) -> bool
5869
7567
 
7568
+ # Delivers pasted text along the same focus chain {#handle_key} bubbles
7569
+ # along, and with the same scoping — first {Component#handle_paste}
7570
+ # returning true wins.
7571
+ #
7572
+ # _@param_ `text`
7573
+ #
7574
+ # _@return_ — true if the text was consumed.
7575
+ def handle_paste: (String text) -> bool
7576
+
5870
7577
  # Mouse events check popups in reverse stacking order (topmost first), and
5871
7578
  # fall through to content only when no popup is hit *and* no modal popup is
5872
7579
  # open. This preserves modal click-blocking — an open modal eats clicks
@@ -5874,6 +7581,35 @@ module Tuile
5874
7581
  # inside it route to it (e.g. click-to-select), clicks elsewhere reach the
5875
7582
  # content beneath.
5876
7583
  #
7584
+ # A left click also *dismisses* the open popups it landed outside of that
7585
+ # asked for it ({Component::Popup#close_on_outside_click?}). That is a
7586
+ # second thing happening on a click, but not a second dispatch: the click is
7587
+ # still delivered exactly once, down one chain, and a dismissed popup is
7588
+ # closed rather than told.
7589
+ #
7590
+ # "Outside" is measured against the {Component::Popup#owner} chain, not
7591
+ # against one rect and not against stacking order: the popup the click hit
7592
+ # is kept, and so is every popup that one *belongs to*, transitively. That
7593
+ # is what stops a dialog being dismissed by a click on a dropdown its own
7594
+ # field opened, and a menu cascade being dismissed by a click on one of its
7595
+ # own deeper panels. Order carries no meaning here — between unrelated
7596
+ # overlays it is merely the order they opened in — so ownership is declared
7597
+ # rather than inferred from the stack.
7598
+ #
7599
+ # Two halves of the ordering are load-bearing, and both are specced:
7600
+ #
7601
+ # - **Snapshot before routing.** A popup the delivered click *opens* must
7602
+ # not be in the set (it would immediately dismiss itself — every
7603
+ # {Component::Select} would be unopenable by mouse).
7604
+ # - **Close after routing.** A widget toggling its own overlay from a click
7605
+ # on its face closes it during delivery, and {Component::Popup#close} is
7606
+ # idempotent, so the dismissal no-ops. Close *first* and the widget sees
7607
+ # a shut overlay and reopens it — a Select's dropdown could then never be
7608
+ # dismissed by clicking the Select.
7609
+ #
7610
+ # The snapshot is a fresh array for a third reason: a handler may close
7611
+ # further popups, and `@popups` must not be mutated mid-iteration.
7612
+ #
5877
7613
  # _@param_ `event`
5878
7614
  def handle_mouse: (MouseEvent event) -> void
5879
7615
 
@@ -5892,6 +7628,22 @@ module Tuile
5892
7628
  # _@param_ `child`
5893
7629
  def on_child_removed: (Component child) -> void
5894
7630
 
7631
+ # The popups a click counts as landing *inside*: the one it hit, plus every
7632
+ # popup that one belongs to, up the {Component::Popup#owner} chain. An owner
7633
+ # is any component, so it is resolved to the popup enclosing it (a popup
7634
+ # resolves to itself) — which keeps the relationship a live tree question
7635
+ # rather than one frozen when the overlay opened. The `include?` guard makes
7636
+ # a mis-wired cycle terminate instead of hanging the UI thread.
7637
+ #
7638
+ # _@param_ `hit` — the popup the click landed in, if any.
7639
+ def kept_by: (Component::Popup? hit) -> ::Array[Component::Popup]
7640
+
7641
+ # _@param_ `component`
7642
+ #
7643
+ # _@return_ — `component` itself when it is a popup,
7644
+ # else the nearest popup above it, else nil.
7645
+ def enclosing_popup: (Component? component) -> Component::Popup?
7646
+
5895
7647
  # Delivers `key` to {Screen#focused} and bubbles it up the ancestor chain,
5896
7648
  # stopping at (and including) `scope`. Delivers to no one — returning false
5897
7649
  # — when focus is nil or sits outside `scope`; the latter is what makes an
@@ -5905,6 +7657,14 @@ module Tuile
5905
7657
  # _@return_ — true if some component on the chain handled the key.
5906
7658
  def bubble_key: (String key, Component scope) -> bool
5907
7659
 
7660
+ # {Screen#focused} and its ancestors up to and including `scope`.
7661
+ #
7662
+ # _@param_ `scope` — the modal scope root (topmost popup or content).
7663
+ #
7664
+ # _@return_ — the chain, innermost first; nil when
7665
+ # focus is nil or sits outside `scope`.
7666
+ def focus_chain: (Component scope) -> ::Array[Component]?
7667
+
5908
7668
  # First {Component#tab_stop?} in `root`'s subtree (pre-order), falling
5909
7669
  # back to `root` itself when the subtree has no tab stops. Returns `nil`
5910
7670
  # if `root` is `nil`.
@@ -5919,9 +7679,6 @@ module Tuile
5919
7679
  # topmost. Holds both modal popups and non-modal overlays
5920
7680
  # ({Component::Popup#modal?}). The array must not be mutated by callers.
5921
7681
  attr_reader popups: ::Array[Component]
5922
-
5923
- # _@return_ — the bottom status bar.
5924
- attr_reader status_bar: Component::Label
5925
7682
  end
5926
7683
 
5927
7684
  # An immutable string-with-styling, modeled as a sequence of {Span}s where
@@ -6047,7 +7804,7 @@ module Tuile
6047
7804
  # An empty {StyledString} returns a single empty entry, like `"".split`.
6048
7805
  def lines: () -> ::Array[StyledString]
6049
7806
 
6050
- # Word-wraps to physical lines that each fit within `width` display
7807
+ # Word-wraps to rows that each fit within `width` display
6051
7808
  # columns, preserving spans and styles across breaks. Greedy word-wrap,
6052
7809
  # hard-break for words wider than `width`, leading whitespace dropped on
6053
7810
  # wrapped continuations, hard `"\n"` breaks preserved as separate output
@@ -6070,7 +7827,7 @@ module Tuile
6070
7827
  #
6071
7828
  # _@param_ `width` — target column width. `nil` or `<= 0` skips wrapping and returns each hard-line as-is, so callers can pass a stale viewport width without crashing.
6072
7829
  #
6073
- # _@return_ — one entry per physical (output) line.
7830
+ # _@return_ — one entry per output row.
6074
7831
  # An empty receiver returns `[]`.
6075
7832
  def wrap: (Integer? width) -> ::Array[StyledString]
6076
7833
 
@@ -6103,6 +7860,40 @@ module Tuile
6103
7860
  # _@param_ `fg` — foreground color, coerced via {Color.coerce}. `nil` clears fg back to the terminal default.
6104
7861
  def with_fg: ((Color | Symbol | Integer | ::Array[Integer])? fg) -> StyledString
6105
7862
 
7863
+ # Returns a new {StyledString} with `bold` applied to every span, preserving
7864
+ # each span's text and other style attributes (`fg`, `bg`, `italic`,
7865
+ # `underline`, `strikethrough`). The bold-attribute counterpart of
7866
+ # {#with_bg} / {#with_fg}: it emphasizes a whole run of app-authored,
7867
+ # possibly multi-span content — a widget marking one caption out of several
7868
+ # as selected, where the caption may already carry its own colors.
7869
+ #
7870
+ # There is deliberately no `under_bold` (the fill-unset counterpart
7871
+ # {#under_bg} provides for backgrounds): a background is inherited down the
7872
+ # component tree, so a span with none has a meaningful "unset" state to
7873
+ # fill, while `bold` is a plain per-span attribute that is either on or
7874
+ # off. Pass `bold: false` to clear it.
7875
+ #
7876
+ # _@param_ `bold` — whether the spans should be bold.
7877
+ def with_bold: (?bold: bool) -> StyledString
7878
+
7879
+ # Returns a new {StyledString} with `underline` applied to every span,
7880
+ # preserving each span's text and other style attributes (`fg`, `bg`,
7881
+ # `bold`, `italic`, `strikethrough`). Slice and rejoin to underline *part*
7882
+ # of a string, which is what a one-character cue needs:
7883
+ #
7884
+ # cap = StyledString.parse("File")
7885
+ # cap.slice(0, 1).with_underline + cap.slice(1, cap.display_width - 1)
7886
+ # # => "File" with the F underlined — a menu mnemonic
7887
+ #
7888
+ # Note {#slice} counts **columns**, not characters, so a caption with a
7889
+ # wide glyph before the cue needs the prefix measured rather than counted.
7890
+ #
7891
+ # There is deliberately no `under_underline`, for the reason {#with_bold}
7892
+ # spells out. Pass `underline: false` to clear it.
7893
+ #
7894
+ # _@param_ `underline` — whether the spans should be underlined.
7895
+ def with_underline: (?underline: bool) -> StyledString
7896
+
6106
7897
  def inspect: () -> String
6107
7898
 
6108
7899
  def build_ansi: () -> String
@@ -6124,23 +7915,23 @@ module Tuile
6124
7915
  # _@param_ `len`
6125
7916
  def slice_spans: (Integer start, Integer len) -> StyledString
6126
7917
 
6127
- # _@param_ `hard_line` — one hard-broken line — no embedded `"\n"`.
7918
+ # _@param_ `line` — one line — no embedded `"\n"`.
6128
7919
  #
6129
7920
  # _@param_ `width`
6130
- def wrap_one: (StyledString hard_line, Integer width) -> ::Array[StyledString]
7921
+ def wrap_one: (StyledString line, Integer width) -> ::Array[StyledString]
6131
7922
 
6132
7923
  # Splits into whitespace/word tokens by **grapheme cluster**, not character:
6133
7924
  # a cluster is the unit a terminal draws, so measuring its parts separately
6134
7925
  # would both mis-total an emoji sequence and let a wrap break a letter away
6135
7926
  # from its combining mark.
6136
7927
  #
6137
- # _@param_ `hard_line`
7928
+ # _@param_ `line`
6138
7929
  #
6139
7930
  # _@return_ — tokens shaped `[type, glyphs, w]` where `type` is
6140
7931
  # `:space` or `:word`, `glyphs` is an `Array<[String, Style, Integer]>`
6141
7932
  # (grapheme cluster, style, display width), and `w` is the token's total
6142
7933
  # width.
6143
- def tokenize_for_wrap: (StyledString hard_line) -> ::Array[::Array[untyped]]
7934
+ def tokenize_for_wrap: (StyledString line) -> ::Array[::Array[untyped]]
6144
7935
 
6145
7936
  # Like {#each_char_with_style} but per grapheme cluster. A cluster spanning a
6146
7937
  # style boundary takes the style of its first span — pathological input, and
@@ -6506,10 +8297,10 @@ module Tuile
6506
8297
  class VerticalScrollBar
6507
8298
  # _@param_ `height` — number of rows in the scrollbar (== viewport height).
6508
8299
  #
6509
- # _@param_ `line_count` — total number of content lines.
8300
+ # _@param_ `row_count` — total number of content rows.
6510
8301
  #
6511
- # _@param_ `top_line` — index of the first visible content line.
6512
- def initialize: (Integer height, line_count: Integer, top_line: Integer) -> void
8302
+ # _@param_ `scroll_top_row` — index of the first visible content row.
8303
+ def initialize: (Integer height, row_count: Integer, scroll_top_row: Integer) -> void
6513
8304
 
6514
8305
  # Returns the scrollbar character for the given viewport row.
6515
8306
  #