tuile 0.12.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/DECISIONS.md +1297 -13
  4. data/README.md +136 -490
  5. data/TERMINOLOGY.md +11 -2
  6. data/book/01-first-app.md +22 -17
  7. data/book/02-repaint.md +18 -5
  8. data/book/03-layout.md +11 -10
  9. data/book/05-focus.md +133 -18
  10. data/book/06-theming.md +5 -2
  11. data/book/07-components.md +402 -12
  12. data/book/08-testing.md +18 -4
  13. data/book/README.md +7 -5
  14. data/examples/file_commander.rb +22 -16
  15. data/examples/hello_world.rb +17 -5
  16. data/examples/sampler.rb +385 -66
  17. data/ideas/arrow-key-navigation.md +16 -0
  18. data/ideas/new-components.md +7 -6
  19. data/lib/tuile/ansi.rb +10 -0
  20. data/lib/tuile/component/abstract_string_field.rb +36 -0
  21. data/lib/tuile/component/combo_box.rb +3 -1
  22. data/lib/tuile/component/list.rb +22 -0
  23. data/lib/tuile/component/list_dropdown.rb +86 -3
  24. data/lib/tuile/component/menu_bar/cascade.rb +255 -0
  25. data/lib/tuile/component/menu_bar.rb +582 -0
  26. data/lib/tuile/component/notification.rb +14 -11
  27. data/lib/tuile/component/picker_window.rb +0 -5
  28. data/lib/tuile/component/popup.rb +75 -9
  29. data/lib/tuile/component/select.rb +3 -1
  30. data/lib/tuile/component/tab_sheet.rb +242 -0
  31. data/lib/tuile/component/tabs.rb +528 -0
  32. data/lib/tuile/component/text_area.rb +5 -4
  33. data/lib/tuile/component/text_field.rb +23 -6
  34. data/lib/tuile/component/text_view.rb +8 -5
  35. data/lib/tuile/component.rb +38 -13
  36. data/lib/tuile/event_queue.rb +25 -1
  37. data/lib/tuile/fake_screen.rb +14 -0
  38. data/lib/tuile/keys.rb +65 -0
  39. data/lib/tuile/screen.rb +94 -77
  40. data/lib/tuile/screen_pane.rb +109 -27
  41. data/lib/tuile/styled_string.rb +40 -0
  42. data/lib/tuile/version.rb +1 -1
  43. data/sig/tuile.rbs +1473 -93
  44. metadata +6 -3
  45. 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.
@@ -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.
@@ -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
  #
@@ -1696,6 +1799,22 @@ module Tuile
1696
1799
  # _@return_ — true if a match was found.
1697
1800
  def select_prev: (String query, ?include_current: bool) -> bool
1698
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
+
1699
1818
  # _@param_ `event`
1700
1819
  def handle_mouse: (MouseEvent event) -> void
1701
1820
 
@@ -2037,6 +2156,319 @@ module Tuile
2037
2156
  end
2038
2157
  end
2039
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
+
2040
2472
  # A label which shows static text. No word-wrapping; long lines are
2041
2473
  # truncated with an ellipsis. Text is modeled as a {StyledString};
2042
2474
  # {#text=} accepts a {String} (parsed via {StyledString.parse}, so
@@ -2123,6 +2555,10 @@ module Tuile
2123
2555
  # declined it (see {ScreenPane#handle_key}). That's why typing `q` into a
2124
2556
  # nested {Component::TextField} doesn't dismiss the popup: the field
2125
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.
2126
2562
  class Popup < Component
2127
2563
  include Tuile::Component::HasContent
2128
2564
 
@@ -2131,11 +2567,41 @@ module Tuile
2131
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).
2132
2568
  #
2133
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}.
2134
- 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
2135
2578
 
2136
2579
  # _@return_ — whether this popup is modal. See {#initialize}.
2137
2580
  def modal?: () -> bool
2138
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
+
2139
2605
  def focusable?: () -> bool
2140
2606
 
2141
2607
  # Reassigns the popup's rect, escalating to a full scene repaint when an
@@ -2181,9 +2647,6 @@ module Tuile
2181
2647
  # Recenters the popup on the screen, preserving its current width/height.
2182
2648
  def center: () -> void
2183
2649
 
2184
- # Hint for the status bar: own "q Close" plus the wrapped content's hint.
2185
- def keyboard_hint: () -> String
2186
-
2187
2650
  # `q` and ESC close the popup. The popup sits on the focus chain of
2188
2651
  # whatever it wraps, so the key reaches here by bubbling up from the
2189
2652
  # focused content after that content declined to handle it.
@@ -2193,6 +2656,10 @@ module Tuile
2193
2656
  # _@return_ — true if the key was handled.
2194
2657
  def handle_key: (String key) -> bool
2195
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
+
2196
2663
  # Content fills the popup's full rect — Popup has no border to subtract.
2197
2664
  #
2198
2665
  # _@param_ `content`
@@ -2205,6 +2672,37 @@ module Tuile
2205
2672
 
2206
2673
  # _@return_ — the popup's declared size. See {#size=}.
2207
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?
2208
2706
  end
2209
2707
 
2210
2708
  # A clickable button. Activated by Enter, Space, or a left mouse click;
@@ -2765,8 +3263,6 @@ module Tuile
2765
3263
 
2766
3264
  def tab_stop?: () -> bool
2767
3265
 
2768
- def keyboard_hint: () -> String
2769
-
2770
3266
  # Re-anchors the (open) dropdown after a move or resize.
2771
3267
  #
2772
3268
  # _@param_ `new_rect`
@@ -3097,6 +3593,495 @@ module Tuile
3097
3593
  def focusable?: () -> bool
3098
3594
  end
3099
3595
 
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.
3650
+ #
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.
3670
+ #
3671
+ # UI-thread-confined, like every component (see {Screen}).
3672
+ class MenuBar < Component
3673
+ def initialize: () -> void
3674
+
3675
+ # _@return_ — `true` — the strip takes focus, so its keys work.
3676
+ def focusable?: () -> bool
3677
+
3678
+ # _@return_ — `true` — one stop for the whole strip, as on {Tabs}.
3679
+ def tab_stop?: () -> bool
3680
+
3681
+ # _@return_ — the top-level items, in strip order. Read-only by
3682
+ # convention; grow it through {#add_item}.
3683
+ def items: () -> ::Array[Item]
3684
+
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
3692
+
3693
+ # The cells the strip actually paints: one row, as wide as its segments
3694
+ # need, clipped to {#rect}.
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
+
3702
+ # _@param_ `new_rect`
3703
+ def rect=: (Rect new_rect) -> void
3704
+
3705
+ # Closes the cascade when the strip leaves the focus chain, so tabbing (or
3706
+ # clicking) away doesn't strand an open menu.
3707
+ #
3708
+ # _@param_ `flag`
3709
+ def active=: (bool flag) -> void
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
+ #
3730
+ # _@param_ `event`
3731
+ def handle_mouse: (MouseEvent event) -> void
3732
+
3733
+ def repaint: () -> void
3734
+
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.
3739
+ #
3740
+ # _@param_ `index`
3741
+ def highlight=: (Integer index) -> void
3742
+
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
+
3100
4085
  # A text field with a filtering dropdown: type to narrow the candidates,
3101
4086
  # arrow to move the highlight, Enter (or click) to accept. Its {#value} is
3102
4087
  # the *selected item* — of whatever type the items are — not the display
@@ -3144,8 +4129,6 @@ module Tuile
3144
4129
  # hardware cursor to its field).
3145
4130
  def cursor_position: () -> Point?
3146
4131
 
3147
- def keyboard_hint: () -> String
3148
-
3149
4132
  # Re-anchors the (open) dropdown after {HasContent#rect=} has resized the
3150
4133
  # field via {#layout}.
3151
4134
  #
@@ -3312,6 +4295,164 @@ module Tuile
3312
4295
  attr_accessor on_value_change: (Proc | Method)?
3313
4296
  end
3314
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
+
3315
4456
  # A multi-line, word-wrapping text input.
3316
4457
  #
3317
4458
  # Sized by the caller — {#rect} is fixed; the area does not grow with
@@ -3327,10 +4468,11 @@ module Tuile
3327
4468
  # start of the next row in nearly all cases).
3328
4469
  #
3329
4470
  # Enter inserts a newline, as in a plain `<textarea>` or text editor; only
3330
- # {#on_change} is wired. A pasted line break arrives as `\n`
3331
- # ({Keys::CTRL_J}) rather than the `\r` a typed Enter sends, so both are
3332
- # accepted — otherwise a multi-line paste would silently lose its
3333
- # 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.
3334
4476
  #
3335
4477
  # Up/Down move the caret between rows and, at the first/last row, snap to
3336
4478
  # the start/end of the text. A subclass can claim the key at that edge
@@ -3756,8 +4898,10 @@ module Tuile
3756
4898
 
3757
4899
  # Scrolls up half a viewport (`rect.height / 2`, at least one row),
3758
4900
  # clamped at the top — unlike {#scroll_top_row=}, which raises below `0`.
3759
- # What `Ctrl+U` does, minus the focus: {#handle_key} ignores every key
3760
- # while the view is inactive, this works whoever holds focus.
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.
3761
4905
  def scroll_half_page_up: () -> void
3762
4906
 
3763
4907
  # The `Ctrl+D` twin of {#scroll_half_page_up}, clamped at the last row —
@@ -3768,6 +4912,11 @@ module Tuile
3768
4912
 
3769
4913
  def tab_stop?: () -> bool
3770
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
+ #
3771
4920
  # _@param_ `key`
3772
4921
  def handle_key: (String key) -> bool
3773
4922
 
@@ -4188,8 +5337,8 @@ module Tuile
4188
5337
  #
4189
5338
  # - an **index** counts characters into {#text} — {#caret},
4190
5339
  # {#max_text_length}, `text[i]`, every edit;
4191
- # - a **column** counts terminal cells — {#rect}, {#left_column},
4192
- # {#cursor_position}, a {MouseEvent}.
5340
+ # - a **column** counts terminal cells — {#rect}, {#cursor_position}, a
5341
+ # {MouseEvent}, and the private horizontal scroll offset `left_column`.
4193
5342
  #
4194
5343
  # They coincide only while every glyph is one column wide. A fullwidth CJK
4195
5344
  # char is two columns and a combining mark zero, so index 3 of `"日本語"` is
@@ -4223,6 +5372,14 @@ module Tuile
4223
5372
  # _@param_ `key`
4224
5373
  def handle_text_input_key: (String key) -> bool
4225
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
+
4226
5383
  def on_text_mutated: () -> void
4227
5384
 
4228
5385
  def on_caret_mutated: () -> void
@@ -4290,10 +5447,6 @@ module Tuile
4290
5447
  # _@return_ — maximum characters, or nil for unbounded (default).
4291
5448
  attr_accessor max_text_length: Integer?
4292
5449
 
4293
- # _@return_ — text column drawn in the field's leftmost cell — the
4294
- # horizontal scroll offset. Follows {#caret}, always on a glyph boundary.
4295
- attr_reader left_column: Integer
4296
-
4297
5450
  # Optional callback fired when the UP arrow key is pressed. When set, UP
4298
5451
  # is consumed by the field; when nil, UP falls through to the parent
4299
5452
  # (default behavior). Only triggered by {Keys::UP_ARROW}, not by `k`,
@@ -4316,6 +5469,15 @@ module Tuile
4316
5469
  #
4317
5470
  # _@return_ — no-arg callable, or nil.
4318
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
4319
5481
  end
4320
5482
 
4321
5483
  # A single-line field whose {#value} is a `Float` (or `nil` when empty) —
@@ -4697,8 +5859,11 @@ module Tuile
4697
5859
  #
4698
5860
  # - **Take focus, or receive keys.** A non-modal popup sits off the
4699
5861
  # key-dispatch scope ({ScreenPane#handle_key}), so not even {Popup}'s
4700
- # `q`/ESC arrives here. A left click dismisses ({#handle_mouse}); an app
4701
- # wanting a key registers a global shortcut and calls {#close}.
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.
4702
5867
  # - **Follow a theme flip.** A `Theme::Ref` `color:` is resolved once, when
4703
5868
  # the message is added — a toast lives seconds, so there is no
4704
5869
  # {Component#on_theme_changed} rebuild.
@@ -4736,11 +5901,6 @@ module Tuile
4736
5901
  # _@return_ — false — see {#focusable?}.
4737
5902
  def tab_stop?: () -> bool
4738
5903
 
4739
- # Empty: a non-modal popup never owns the status bar, and {Popup}'s
4740
- # inherited `q Close` hint would be a lie here — no key ever reaches a
4741
- # notification.
4742
- def keyboard_hint: () -> String
4743
-
4744
5904
  # Appends a message, dropping it (with a {Tuile.logger} warning) once
4745
5905
  # {MAX_MESSAGES} are held. Public so a caller holding the instance can
4746
5906
  # append without repeating {show}'s lookup.
@@ -5109,9 +6269,10 @@ module Tuile
5109
6269
  # drop.choose if key == Keys::ENTER # commit the highlight
5110
6270
  #
5111
6271
  # It owns only what every such dropdown shares — *placement* included, via
5112
- # {#anchor_to}. What stays with the driver: the width **policy** ({#anchor_to}
5113
- # measures nothing itself), filtering, row rendering, the commit action, and
5114
- # 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
5115
6276
  # revert a query; Enter may commit via {#choose} *or* via a separate submit
5116
6277
  # path), so {#move} claims neither — the driver calls {#choose} and {#close}
5117
6278
  # from its own branches.
@@ -5141,12 +6302,24 @@ module Tuile
5141
6302
  # _@param_ `proc` — commit callback; see {List#on_item_chosen}.
5142
6303
  def on_item_chosen=: ((Proc | Method)? proc) -> void
5143
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
+
5144
6308
  # _@param_ `cursor` — the highlight; see {List#cursor=}.
5145
6309
  def cursor=: (List::Cursor cursor) -> void
5146
6310
 
5147
6311
  # _@return_ — the list's cursor (the current highlight).
5148
6312
  def cursor: () -> List::Cursor
5149
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
+
5150
6323
  # Sizes and places the dropdown against `anchor`: directly beneath it,
5151
6324
  # flipped above when `rows` won't fit below, clamped — with the list
5152
6325
  # scrolling — when neither side has room. Horizontally the left edges line
@@ -5172,6 +6345,49 @@ module Tuile
5172
6345
  ?max_rows: Integer
5173
6346
  ) -> void
5174
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
+
5175
6391
  # Forwards a cursor-movement key to the list. The driver calls this from
5176
6392
  # its own key handler; a truthy return means "consumed — stop here", falsy
5177
6393
  # means "not mine — proceed with normal editing/dispatch". Only {MOVE_KEYS}
@@ -5225,8 +6441,6 @@ module Tuile
5225
6441
  # _@param_ `key`
5226
6442
  def handle_key: (String key) -> bool
5227
6443
 
5228
- def keyboard_hint: () -> String
5229
-
5230
6444
  # Opens a picker as a popup. Picking an option fires `block`, then
5231
6445
  # closes the popup; ESC / `q` close without firing `block`.
5232
6446
  #
@@ -5637,6 +6851,8 @@ module Tuile
5637
6851
  #
5638
6852
  # - {#preprocess_text} — input filter (e.g. {TextField} truncates to
5639
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.
5640
6856
  # - {#on_text_mutated} / {#on_caret_mutated} — post-mutation side
5641
6857
  # effects (e.g. {TextArea} invalidates its wrap cache and scrolls to
5642
6858
  # keep the caret visible).
@@ -5668,6 +6884,34 @@ module Tuile
5668
6884
  # _@param_ `key`
5669
6885
  def handle_key: (String key) -> bool
5670
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
+
5671
6915
  # Renders `text` on the field's background well, looked up from the
5672
6916
  # current {Screen#theme} at paint time: {Theme#active_bg_color} when this
5673
6917
  # input is on the active (focus) chain, {Theme#input_bg_color} otherwise —
@@ -5974,6 +7218,28 @@ module Tuile
5974
7218
  attr_reader key: String
5975
7219
  end
5976
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
+
5977
7243
  # An error event, causes {EventQueue#run_loop} to throw `StandardError` with
5978
7244
  # {#error} as its origin.
5979
7245
  #
@@ -6110,6 +7376,22 @@ module Tuile
6110
7376
  # _@param_ `str`
6111
7377
  def emit: (String str) -> void
6112
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
+
6113
7395
  # _@param_ `component` — the component to check.
6114
7396
  def invalidated?: (Component component) -> bool
6115
7397
 
@@ -6184,10 +7466,15 @@ module Tuile
6184
7466
  #
6185
7467
  # {Screen} is a singleton runtime owner (event loop, lock, terminal IO,
6186
7468
  # invalidation set). All actual UI lives under a {ScreenPane}: the tiled
6187
- # {#content}, the modal {#popups} stack, and the bottom {#status_bar}.
6188
- # Putting them under a single Component parent gives focus traversal a real
6189
- # root, makes {Component#attached?} a one-liner, and lets popup-focus repair
6190
- # 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`).
6191
7478
  #
6192
7479
  # The pane is not a {Component::Layout}: popups deliberately overlap content
6193
7480
  # (Z-ordered, full overdraw, no clipping) and key/mouse dispatch follows
@@ -6237,9 +7524,9 @@ module Tuile
6237
7524
 
6238
7525
  # _@return_ — the topmost *modal* popup, or nil when
6239
7526
  # only non-modal overlays (or no popups) are open. This is the "modal
6240
- # owner": the popup that scopes key dispatch, blocks mouse clicks, owns
6241
- # the status bar, and confines Tab cycling. Non-modal overlays are
6242
- # 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.
6243
7530
  def modal_popup: () -> Component::Popup?
6244
7531
 
6245
7532
  # Re-lays out children whenever the pane's own rect changes.
@@ -6247,11 +7534,11 @@ module Tuile
6247
7534
  # _@param_ `new_rect`
6248
7535
  def rect=: (Rect new_rect) -> void
6249
7536
 
6250
- # Lays out content (full pane minus the bottom row) and the status bar
6251
- # (bottom row). Each popup re-resolves its {Component::Popup#size} against
6252
- # the new screen via {Component::Popup#reposition} — so a {Fraction} size
6253
- # tracks resize — repositioning itself (modal popups recenter; non-modal
6254
- # 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).
6255
7542
  def layout: () -> void
6256
7543
 
6257
7544
  # Pane paints nothing itself; its children paint over the entire rect.
@@ -6278,6 +7565,15 @@ module Tuile
6278
7565
  # _@return_ — true if the key was handled.
6279
7566
  def handle_key: (String key) -> bool
6280
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
+
6281
7577
  # Mouse events check popups in reverse stacking order (topmost first), and
6282
7578
  # fall through to content only when no popup is hit *and* no modal popup is
6283
7579
  # open. This preserves modal click-blocking — an open modal eats clicks
@@ -6285,6 +7581,35 @@ module Tuile
6285
7581
  # inside it route to it (e.g. click-to-select), clicks elsewhere reach the
6286
7582
  # content beneath.
6287
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
+ #
6288
7613
  # _@param_ `event`
6289
7614
  def handle_mouse: (MouseEvent event) -> void
6290
7615
 
@@ -6303,6 +7628,22 @@ module Tuile
6303
7628
  # _@param_ `child`
6304
7629
  def on_child_removed: (Component child) -> void
6305
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
+
6306
7647
  # Delivers `key` to {Screen#focused} and bubbles it up the ancestor chain,
6307
7648
  # stopping at (and including) `scope`. Delivers to no one — returning false
6308
7649
  # — when focus is nil or sits outside `scope`; the latter is what makes an
@@ -6316,6 +7657,14 @@ module Tuile
6316
7657
  # _@return_ — true if some component on the chain handled the key.
6317
7658
  def bubble_key: (String key, Component scope) -> bool
6318
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
+
6319
7668
  # First {Component#tab_stop?} in `root`'s subtree (pre-order), falling
6320
7669
  # back to `root` itself when the subtree has no tab stops. Returns `nil`
6321
7670
  # if `root` is `nil`.
@@ -6330,9 +7679,6 @@ module Tuile
6330
7679
  # topmost. Holds both modal popups and non-modal overlays
6331
7680
  # ({Component::Popup#modal?}). The array must not be mutated by callers.
6332
7681
  attr_reader popups: ::Array[Component]
6333
-
6334
- # _@return_ — the bottom status bar.
6335
- attr_reader status_bar: Component::Label
6336
7682
  end
6337
7683
 
6338
7684
  # An immutable string-with-styling, modeled as a sequence of {Span}s where
@@ -6514,6 +7860,40 @@ module Tuile
6514
7860
  # _@param_ `fg` — foreground color, coerced via {Color.coerce}. `nil` clears fg back to the terminal default.
6515
7861
  def with_fg: ((Color | Symbol | Integer | ::Array[Integer])? fg) -> StyledString
6516
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
+
6517
7897
  def inspect: () -> String
6518
7898
 
6519
7899
  def build_ansi: () -> String