tuile 0.12.0 → 0.14.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -27
  3. data/COMPARISON.md +101 -0
  4. data/DECISIONS.md +2342 -158
  5. data/README.md +151 -505
  6. data/TERMINOLOGY.md +15 -5
  7. data/book/01-first-app.md +22 -17
  8. data/book/02-repaint.md +18 -5
  9. data/book/03-layout.md +28 -20
  10. data/book/05-focus.md +137 -19
  11. data/book/06-theming.md +103 -2
  12. data/book/07-components.md +567 -27
  13. data/book/08-testing.md +34 -4
  14. data/book/09-styled-text.md +3 -3
  15. data/book/README.md +7 -5
  16. data/examples/file_commander.rb +23 -17
  17. data/examples/hello_world.rb +17 -5
  18. data/examples/sampler.rb +527 -108
  19. data/ideas/arrow-key-navigation.md +17 -1
  20. data/ideas/modal-backdrop.md +24 -0
  21. data/ideas/new-components.md +28 -27
  22. data/lib/tuile/ansi.rb +10 -0
  23. data/lib/tuile/buffer.rb +51 -3
  24. data/lib/tuile/color.rb +143 -0
  25. data/lib/tuile/color_depth.rb +80 -0
  26. data/lib/tuile/component/abstract_string_field.rb +36 -0
  27. data/lib/tuile/component/button.rb +3 -3
  28. data/lib/tuile/component/checkbox.rb +3 -3
  29. data/lib/tuile/component/combo_box.rb +12 -3
  30. data/lib/tuile/component/confirm_window.rb +442 -0
  31. data/lib/tuile/component/has_content.rb +22 -9
  32. data/lib/tuile/component/has_value.rb +1 -1
  33. data/lib/tuile/component/info_window.rb +64 -16
  34. data/lib/tuile/component/layout.rb +0 -10
  35. data/lib/tuile/component/list.rb +22 -0
  36. data/lib/tuile/component/list_dropdown.rb +102 -11
  37. data/lib/tuile/component/log_text_view.rb +71 -0
  38. data/lib/tuile/component/log_window.rb +13 -48
  39. data/lib/tuile/component/menu_bar/cascade.rb +255 -0
  40. data/lib/tuile/component/menu_bar.rb +582 -0
  41. data/lib/tuile/component/notification.rb +24 -39
  42. data/lib/tuile/component/overlay.rb +192 -0
  43. data/lib/tuile/component/picker_window.rb +0 -5
  44. data/lib/tuile/component/popup.rb +61 -123
  45. data/lib/tuile/component/progress_bar.rb +1 -1
  46. data/lib/tuile/component/select.rb +17 -7
  47. data/lib/tuile/component/slot.rb +54 -0
  48. data/lib/tuile/component/tab_sheet.rb +231 -0
  49. data/lib/tuile/component/tabs.rb +528 -0
  50. data/lib/tuile/component/text_area.rb +5 -4
  51. data/lib/tuile/component/text_field.rb +23 -6
  52. data/lib/tuile/component/text_view.rb +8 -5
  53. data/lib/tuile/component/window.rb +22 -46
  54. data/lib/tuile/component.rb +186 -31
  55. data/lib/tuile/event_queue.rb +45 -1
  56. data/lib/tuile/fake_screen.rb +40 -2
  57. data/lib/tuile/keys.rb +72 -0
  58. data/lib/tuile/screen.rb +212 -113
  59. data/lib/tuile/screen_pane.rb +125 -41
  60. data/lib/tuile/styled_string.rb +80 -7
  61. data/lib/tuile/terminal_background.rb +74 -16
  62. data/lib/tuile/version.rb +1 -1
  63. data/sig/tuile.rbs +2527 -358
  64. metadata +13 -3
  65. 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.
@@ -232,6 +274,11 @@ module Tuile
232
274
  # {Color} instances only.
233
275
  class Color
234
276
  COLOR_SYMBOLS: ::Array[Symbol]
277
+ CUBE_LEVELS: ::Array[Integer]
278
+ CUBE_INDEX: ::Array[Integer]
279
+ ANSI16_RGB: ::Array[::Array[Integer]]
280
+ ANSI16_COLORS: ::Array[Color]
281
+ PALETTE_COLORS: ::Array[Color]
235
282
  PALETTE_NAMES: ::Hash[Symbol, Integer]
236
283
 
237
284
  # Coerces the input to a {Color}. `nil` passes through unchanged (callers
@@ -280,6 +327,36 @@ module Tuile
280
327
  # _@param_ `target` — `:fg` or `:bg`.
281
328
  def sgr_codes: (?Symbol target) -> ::Array[Integer]
282
329
 
330
+ # This color as the nearest one `depth` can actually show — the
331
+ # degradation {Buffer#flush} applies to every color on its way to the wire:
332
+ #
333
+ # Color.rgb(100, 100, 100).quantize(:palette256) # => Color.palette(241)
334
+ # Color.rgb(255, 0, 0).quantize(:ansi16) # => Color::BRIGHT_RED
335
+ # Color.rgb(255, 0, 0).quantize(:truecolor) # => itself, unchanged
336
+ #
337
+ # Returns **the same instance** whenever `depth` shows this color as-is —
338
+ # every named color at every depth, a palette index anywhere but
339
+ # `:ansi16`, RGB at `:truecolor` — so `color.quantize(depth).equal?(color)`
340
+ # *is* the "needs no translating" predicate, and the common path
341
+ # allocates nothing.
342
+ #
343
+ # == Implementation details
344
+ #
345
+ # RGB picks whichever is nearer in squared-RGB distance: the 6×6×6 cube
346
+ # (16..231, its per-channel nearest levels being the nearest cell outright
347
+ # — the axes are independent) or the 24-step grey ramp (232..255, whose
348
+ # nearest step is the one nearest the channel mean).
349
+ #
350
+ # Under `:ansi16` a color goes *direct* to the nearest of the 16, never
351
+ # via the 256-palette — two steps would compound the rounding — and the
352
+ # result is a *named* color, which keeps respecting the user's terminal
353
+ # scheme. Matching is against xterm's default RGBs for the 16, which that
354
+ # scheme may itself redefine: the one mapping here that can be honestly
355
+ # wrong.
356
+ #
357
+ # _@param_ `depth` — one of {ColorDepth::DEPTHS}.
358
+ def quantize: (Symbol depth) -> Color
359
+
283
360
  # Full SGR escape sequence for this color (e.g. `"\e[31m"`). Useful for
284
361
  # `print`-style direct emission; for composing with other attributes use
285
362
  # {#sgr_codes} instead.
@@ -294,6 +371,30 @@ module Tuile
294
371
 
295
372
  def inspect: () -> String
296
373
 
374
+ # This color's RGB — the palette cell's own coordinates when the value is
375
+ # an index. Only ever asked of a non-Symbol value; a named color has no
376
+ # RGB of its own, since the terminal's scheme decides what it looks like.
377
+ #
378
+ # _@return_ — red, green and blue, each 0..255.
379
+ def rgb_triple: () -> ::Array[Integer]
380
+
381
+ # Written flat — destructured rather than splatted, `x * x` rather than
382
+ # `x**2`, no distance helper — because it runs per style transition in
383
+ # {Buffer#flush}, and the tidy shape measures ~2x slower (see
384
+ # `benchmark/quantize.rb`).
385
+ #
386
+ # _@param_ `rgb` — red, green and blue, each 0..255.
387
+ #
388
+ # _@return_ — palette index, 16..255 — the nearer of this color's
389
+ # cube cell and its grey-ramp step. A tie goes to the cube, which spans
390
+ # the whole space where the ramp only covers the diagonal.
391
+ def nearest_palette: (::Array[Integer] rgb) -> Integer
392
+
393
+ # _@param_ `rgb` — red, green and blue, each 0..255.
394
+ #
395
+ # _@return_ — index into {COLOR_SYMBOLS} of the nearest of the 16.
396
+ def nearest_ansi16: (::Array[Integer] rgb) -> Integer
397
+
297
398
  # The underlying raw representation — a Symbol, Integer, or frozen
298
399
  # Array<Integer>.
299
400
  attr_reader value: (Symbol | Integer | ::Array[Integer])
@@ -593,7 +694,9 @@ module Tuile
593
694
  def self.display_width: (String grapheme) -> Integer
594
695
 
595
696
  # _@param_ `size` — grid dimensions in columns × rows.
596
- def initialize: (Size size) -> void
697
+ #
698
+ # _@param_ `color_depth` — what the terminal can show — one of {ColorDepth::DEPTHS}; {#flush} degrades every emitted color to it. Validated here rather than at paint time: a bad value would otherwise surface as an exception mid-frame, far from the mistake.
699
+ def initialize: (Size size, ?color_depth: Symbol) -> void
597
700
 
598
701
  # _@return_ — grid dimensions.
599
702
  def size: () -> Size
@@ -754,6 +857,26 @@ module Tuile
754
857
  # _@param_ `style` — style the terminal currently holds.
755
858
  def flush_row: (String _out, Integer y, StyledString::Style style) -> StyledString::Style
756
859
 
860
+ # `style` as {#color_depth} can actually show it, each color through
861
+ # {Color#quantize}. Applied *before* the {StyledString::Style#sgr_to}
862
+ # diff, so two RGBs that quantize onto the same cell emit nothing at all
863
+ # rather than a redundant SGR.
864
+ #
865
+ # The one-slot memo is load-bearing, not a micro-optimization: this runs
866
+ # per dirty *cell*, while a painted run shares one frozen
867
+ # {StyledString::Style} instance, so remembering just the last answer
868
+ # collapses the work onto actual style transitions. Without it a
869
+ # full-screen repaint of RGB-styled content measured 51 ms against 15 ms
870
+ # at `:truecolor` — a keyed cache is still the wrong answer
871
+ # (`D_color_depth`), but paying the arithmetic 8000 times for one span
872
+ # was too.
873
+ #
874
+ # _@param_ `style`
875
+ #
876
+ # _@return_ — `style` itself whenever nothing needed
877
+ # degrading — {Color#quantize}'s identity contract, extended.
878
+ def quantized_style: (StyledString::Style style) -> StyledString::Style
879
+
757
880
  # _@param_ `rect`
758
881
  #
759
882
  # _@return_ — cells within `rect`, row-major, clamped to
@@ -818,6 +941,11 @@ module Tuile
818
941
 
819
942
  attr_reader height: Integer
820
943
 
944
+ # What the terminal can show ({ColorDepth::DEPTHS}). Cells hold whatever
945
+ # color a component painted — {#region_ansi} and friends report that,
946
+ # unchanged — and only {#flush} degrades it on the way to the wire.
947
+ attr_reader color_depth: Symbol
948
+
821
949
  # One screen cell: a single grapheme cluster, the {StyledString::Style} it's
822
950
  # drawn in, and a dirty flag. Mutable by design (see {Buffer} "Dirty
823
951
  # tracking") — the grid rewrites cells in place. A continuation cell (right
@@ -879,10 +1007,15 @@ module Tuile
879
1007
  # Everything on screen hangs off {#pane} (a {ScreenPane}): the tiled
880
1008
  # {#content} (set via {#content=}, filling the whole terminal and laying
881
1009
  # 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.
1010
+ # {Component::Popup#open}, drawn on top of the content). Popups are *not*
1011
+ # sized from their content — each carries its own top-down
1012
+ # {Component::Popup#declared_size} — and they deliberately overdraw the content
1013
+ # without clipping.
1014
+ #
1015
+ # Tuile draws no chrome of its own: there is no status bar and no reserved
1016
+ # row, so {#content} gets the whole terminal. An app that wants a status line
1017
+ # builds one into its own layout and drives it from {#on_focus_changed=}
1018
+ # (`D_status_bar`).
886
1019
  #
887
1020
  # ## Repaint model
888
1021
  #
@@ -929,9 +1062,10 @@ module Tuile
929
1062
  # _@param_ `content`
930
1063
  def content=: (Component content) -> void
931
1064
 
932
- # _@return_ — currently active popup components (forwarded
933
- # to {ScreenPane}). The array must not be modified!
934
- def popups: () -> ::Array[Component]
1065
+ # _@return_ — the open overlay stack — both
1066
+ # {Component::Popup} modals and bare {Component::Overlay}s — in stacking
1067
+ # order (forwarded to {ScreenPane}). The array must not be modified!
1068
+ def popups: () -> ::Array[Component::Overlay]
935
1069
 
936
1070
  # `:idle` covers *both* ends of the screen's life — before the first
937
1071
  # {#run_event_loop} and after it returns — and a screen may cycle
@@ -957,27 +1091,11 @@ module Tuile
957
1091
  # _@param_ `component`
958
1092
  def invalidate: (Component component) -> void
959
1093
 
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
- # Internal — use {Component::Popup#open} instead. Adds the popup to
977
- # {#pane}, centers and focuses it.
1094
+ # Internal — use {Component::Overlay#open} instead. Adds the overlay to
1095
+ # {#pane}; a {Component::Popup} is additionally centered and focused.
978
1096
  #
979
- # _@param_ `window`
980
- def add_popup: (Component::Popup window) -> void
1097
+ # _@param_ `window` — any overlay, modal or not.
1098
+ def add_popup: (Component::Overlay window) -> void
981
1099
 
982
1100
  # Runs the event loop on the calling thread, taking over stdin (raw mode,
983
1101
  # echo off): keys and mouse events are dispatched via {#handle_key} /
@@ -988,7 +1106,9 @@ module Tuile
988
1106
  # ownership reverts to the creating thread once it returns.
989
1107
  #
990
1108
  # _@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
1109
+ #
1110
+ # _@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.
1111
+ def run_event_loop: (?capture_mouse: bool, ?bracketed_paste: bool) -> void
992
1112
 
993
1113
  # Advances focus to the next {Component#tab_stop?} in tree order, wrapping
994
1114
  # around. Scope is the topmost popup if one is open, otherwise {#content}
@@ -1021,18 +1141,14 @@ module Tuile
1021
1141
  # - **{EDITING_KEYS}** — `ENTER`, `BACKSPACE`, `DELETE` and the arrows,
1022
1142
  # which every editable widget needs.
1023
1143
  #
1024
- # screen.register_global_shortcut(Keys::CTRL_L,
1025
- # over_popups: true,
1026
- # hint: "^L #{screen.theme.hint("log")}") do
1144
+ # screen.register_global_shortcut(Keys::CTRL_L, over_popups: true) do
1027
1145
  # log_popup.open
1028
1146
  # end
1029
1147
  #
1030
1148
  # _@param_ `key` — unprintable key (e.g. {Keys::CTRL_L}, {Keys::ESC}).
1031
1149
  #
1032
1150
  # _@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
1151
+ def register_global_shortcut: (String key, ?over_popups: bool) -> void
1036
1152
 
1037
1153
  # Removes a shortcut previously installed by {#register_global_shortcut}.
1038
1154
  # No-op if `key` was not registered.
@@ -1040,16 +1156,13 @@ module Tuile
1040
1156
  # _@param_ `key`
1041
1157
  def unregister_global_shortcut: (String key) -> void
1042
1158
 
1043
- # _@return_ — current active tiled component.
1044
- def active_window: () -> Component?
1045
-
1046
- # Internal — use {Component::Popup#close} instead. Removes the popup
1159
+ # Internal — use {Component::Overlay#close} instead. Removes the overlay
1047
1160
  # from {#pane}, repairs focus, and repaints the scene.
1048
1161
  #
1049
- # Does nothing if the window is not open on this screen.
1162
+ # Does nothing if the overlay is not open on this screen.
1050
1163
  #
1051
- # _@param_ `window`
1052
- def remove_popup: (Component::Popup window) -> void
1164
+ # _@param_ `window` — any overlay, modal or not.
1165
+ def remove_popup: (Component::Overlay window) -> void
1053
1166
 
1054
1167
  # Invalidates the entire attached tree, forcing every component to repaint
1055
1168
  # on the next cycle. Needed whenever something overdraws the scene without
@@ -1060,12 +1173,12 @@ module Tuile
1060
1173
  # its own, so we accept the cost of a full repaint.
1061
1174
  def needs_full_repaint: () -> void
1062
1175
 
1063
- # Internal — use {Component::Popup#open?} instead.
1176
+ # Internal — use {Component::Overlay#open?} instead.
1064
1177
  #
1065
- # _@param_ `window`
1178
+ # _@param_ `window` — any overlay, modal or not.
1066
1179
  #
1067
- # _@return_ — true if this popup is currently mounted.
1068
- def has_popup?: (Component::Popup window) -> bool
1180
+ # _@return_ — true if this overlay is currently mounted.
1181
+ def has_popup?: (Component::Overlay window) -> bool
1069
1182
 
1070
1183
  # Testing only — creates new screen, locks the UI, and prevents any
1071
1184
  # redraws, so that test TTY is not painted over. {FakeScreen#initialize}
@@ -1081,15 +1194,35 @@ module Tuile
1081
1194
  def self.close: () -> void
1082
1195
 
1083
1196
  # Writes terminal-housekeeping escapes straight to stdout: {#clear},
1084
- # mouse-tracking start/stop, the color-scheme notify toggles, cursor-show
1085
- # on teardown. Component painting does *not* go through here anymore — it
1086
- # writes into {#buffer}, which {#repaint} diffs and {#emit}s. {FakeScreen}
1087
- # overrides this (and {#emit}) to capture into `@prints` instead of the
1088
- # test runner's stdout.
1197
+ # mouse-tracking start/stop, the color-scheme notify toggles, the OSC 11
1198
+ # background re-probe, cursor-show on teardown. Component painting does
1199
+ # *not* go through here anymore — it writes into {#buffer}, which
1200
+ # {#repaint} diffs and {#emit}s. {FakeScreen} overrides this (and
1201
+ # {#emit}) to capture into `@prints` instead of the test runner's stdout.
1202
+ #
1203
+ # Flushed, like {#emit}: none of these escapes ends in a newline, and a
1204
+ # buffered stdout would hold a *query* — one whose reply the app is
1205
+ # waiting on — until the next frame went out.
1089
1206
  #
1090
1207
  # _@param_ `args` — stuff to print.
1091
1208
  def print: (*String args) -> void
1092
1209
 
1210
+ # Rings the terminal bell ({Ansi::BEL}) — the signal for a keystroke that
1211
+ # went nowhere, e.g. a letter matching no menu mnemonic while a menu is
1212
+ # open.
1213
+ #
1214
+ # return true if activate_mnemonic(key)
1215
+ #
1216
+ # screen.beep # no match: the key is swallowed, say so
1217
+ # true
1218
+ #
1219
+ # Writes **immediately** rather than riding the next frame: a beep is not
1220
+ # part of a frame, and the keystrokes worth beeping at are precisely the
1221
+ # ones that invalidate nothing, so {#repaint} may never emit at all. Whether
1222
+ # the user hears anything is the terminal's setting to make, so there is no
1223
+ # Tuile-level enable/disable knob.
1224
+ def beep: () -> void
1225
+
1093
1226
  # Repaints the screen; tries to be as effective as possible, by only
1094
1227
  # considering invalidated components and flushing just the changed cells
1095
1228
  # of {#buffer}. Called once per event-loop tick (on {EventQueue::EmptyQueueEvent});
@@ -1103,23 +1236,41 @@ module Tuile
1103
1236
  # but only one focused.
1104
1237
  def cursor_position: () -> Point?
1105
1238
 
1106
- # Startup color scheme: `:light` when {TerminalBackground.detect}
1107
- # reports a light terminal background, `:dark` otherwise (including
1108
- # when detection is inconclusive). Runs in the constructor — the
1109
- # OSC 11 reply arrives on stdin, which is only safe to read before
1110
- # {EventQueue#start_key_thread} owns it. {FakeScreen} overrides this
1111
- # to pin `:dark`, keeping specs deterministic and off the test
1112
- # runner's TTY.
1113
- #
1114
- # _@return_ — `:dark` or `:light`.
1115
- def detect_scheme: () -> Symbol
1239
+ # The startup background probe, seeding {#theme} and
1240
+ # {#background_color}. Inconclusive detection means `:dark` with no
1241
+ # color. Runs in the constructor — the OSC 11 reply arrives on stdin,
1242
+ # which is only safe to read before {EventQueue#start_key_thread} owns
1243
+ # it. {FakeScreen} overrides this to pin the result, keeping specs
1244
+ # deterministic and off the test runner's TTY.
1245
+ def detect_background: () -> TerminalBackground::Result
1246
+
1247
+ # The startup color-depth probe, seeding {#color_depth}. Reads the
1248
+ # environment only, so unlike {#detect_background} it has no timing
1249
+ # constraint. {FakeScreen} overrides it to pin the result, keeping specs
1250
+ # off whatever `COLORTERM` the test runner happens to carry.
1251
+ def detect_color_depth: () -> Symbol
1116
1252
 
1117
1253
  # An OS appearance flip arrived (mode-2031 report): remember the
1118
- # scheme and apply the matching member of {#theme_def}.
1254
+ # scheme, apply the matching member of {#theme_def}, and re-probe for
1255
+ # the new background RGB, which the report does not carry.
1256
+ #
1257
+ # The query goes out from *this* thread — the event-loop thread, which
1258
+ # also owns {#emit} — so its bytes can never land inside a frame's
1259
+ # synchronized-output batch. The reply comes back through the key
1260
+ # thread as an {EventQueue::BackgroundColorEvent}.
1119
1261
  #
1120
1262
  # _@param_ `scheme` — `:dark` or `:light`.
1121
1263
  def on_color_scheme: (Symbol scheme) -> void
1122
1264
 
1265
+ # The re-probe answered: adopt the color and restyle, since an app's
1266
+ # background-derived tints are now a scheme behind. Deliberately keeps
1267
+ # the previous color until the reply lands rather than blanking it on
1268
+ # the flip — a terminal that reports mode-2031 flips but not OSC 11
1269
+ # would otherwise lose the color it gave us at startup, permanently.
1270
+ #
1271
+ # _@param_ `color`
1272
+ def on_background_color: (Color color) -> void
1273
+
1123
1274
  # Walks the current modal scope in pre-order, collects tab stops, and
1124
1275
  # advances focus by one (wrapping). When the focused component isn't in
1125
1276
  # the tab order (e.g. focus is parked on a popup/window chrome with no
@@ -1173,6 +1324,19 @@ module Tuile
1173
1324
  # _@param_ `event`
1174
1325
  def handle_mouse: (MouseEvent event) -> void
1175
1326
 
1327
+ # Delivers pasted text down the focus chain ({ScreenPane#handle_paste}).
1328
+ #
1329
+ # Deliberately *not* the key ladder: a paste is not a keystroke, so it
1330
+ # skips Tab traversal and the global-shortcut registry entirely and goes
1331
+ # straight to delivery. Unhandled text is dropped — there is no fallback
1332
+ # that replays it as keys, which would put back the very ambiguity mode
1333
+ # 2004 exists to remove.
1334
+ #
1335
+ # _@param_ `text`
1336
+ #
1337
+ # _@return_ — true if some component consumed it.
1338
+ def handle_paste: (String text) -> bool
1339
+
1176
1340
  def event_loop: () -> void
1177
1341
 
1178
1342
  # _@return_ — the structural root of the component tree.
@@ -1181,6 +1345,16 @@ module Tuile
1181
1345
  # _@return_ — `:light` or `:dark`
1182
1346
  attr_reader color_scheme: Symbol
1183
1347
 
1348
+ # How many colors this terminal can show ({ColorDepth::DEPTHS}), detected
1349
+ # at construction. {Buffer#flush} degrades every color it emits to this,
1350
+ # so an app may compute an RGB tint — say from {#background_color} — and
1351
+ # paint with it, whatever the terminal turns out to understand.
1352
+ #
1353
+ # Deliberately read-only: detection runs once and the answer can't change
1354
+ # mid-session. Override a terminal that reports itself wrong through
1355
+ # {ColorDepth::OVERRIDE_ENV} instead.
1356
+ attr_reader color_depth: Symbol
1357
+
1184
1358
  # _@return_ — the back buffer components paint into
1185
1359
  # ({Buffer#set_text} / {Buffer#fill} / {Buffer#set_char}).
1186
1360
  attr_reader buffer: Buffer
@@ -1225,14 +1399,58 @@ module Tuile
1225
1399
  # the user toggling the OS appearance.
1226
1400
  attr_accessor theme_def: ThemeDef
1227
1401
 
1402
+ # The terminal's own background, as it reported it — for deriving a
1403
+ # color *from* the background rather than picking one against it. A pane
1404
+ # tinted a few percent off it sits right on any terminal, where a fixed
1405
+ # near-neutral only sits right near the one it was tuned on:
1406
+ #
1407
+ # bg = screen.background_color
1408
+ # sidebar.bg_color =
1409
+ # bg ? Color.rgb(*bg.value.map { (_1 + 10).clamp(0, 255) }) : FALLBACK_TINT
1410
+ #
1411
+ # **Nil is the normal case, not an edge** — a terminal answering only
1412
+ # `COLORFGBG`, or neither probe, reports no RGB at all. Keep a fallback.
1413
+ #
1414
+ # Kept current across OS appearance flips, a frame behind: the flip
1415
+ # report carries light/dark only, so the screen re-probes and this
1416
+ # updates when the reply lands. A changed color then fires
1417
+ # {Component#on_theme_changed} across the tree exactly as a theme swap
1418
+ # does — a background-derived tint *is* a theme-derived color.
1419
+ attr_reader background_color: Color?
1420
+
1228
1421
  # _@return_ — the event queue.
1229
1422
  attr_reader event_queue: EventQueue
1230
1423
 
1231
1424
  # _@return_ — currently focused component.
1232
1425
  attr_accessor focused: Component?
1233
1426
 
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.
1427
+ # Called after the focused component *changes* — including to and from
1428
+ # `nil`, and including the focus repair that runs when a popup closes.
1429
+ # Takes no arguments; read {#focused} (and walk its `parent` chain) for the
1430
+ # new state.
1431
+ #
1432
+ # This is the hook an app drives its own status line from. Tuile owns no
1433
+ # status bar and reserves no row: build a {Component::Label} into your own
1434
+ # layout and fill it here (`D_status_bar`).
1435
+ #
1436
+ # screen.on_focus_changed = -> { bar.text = hint_for(screen.focused) }
1437
+ #
1438
+ # **Edge-triggered**, like {Component#on_attached}: re-assigning the
1439
+ # component that already has focus fires nothing, so a callback can be as
1440
+ # expensive as rebuilding a hint string without a `did it really change?`
1441
+ # guard of its own. That matters more than it looks — `ScreenPane#content=`
1442
+ # clears focus on every content swap, which on a level-triggered hook would
1443
+ # fire a nil→nil notification during assembly.
1444
+ #
1445
+ # It runs *after* the active-flag cascade and `on_focus`, so the tree is
1446
+ # settled. Two things a callback must tolerate: {#focused} being `nil`, and
1447
+ # firing during {#close} — teardown clears focus, exactly as it fires
1448
+ # {Component#on_detached}. A raising callback propagates out of {#focused=}
1449
+ # and leaves focus assigned; keep it trivial, as with the attach hooks.
1450
+ attr_accessor on_focus_changed: Proc?
1451
+
1452
+ # Entry in the global shortcut registry: the block to run, and whether it
1453
+ # pre-empts open popups.
1236
1454
  # @api private
1237
1455
  class Shortcut < Data
1238
1456
  # Returns the value of attribute block
@@ -1240,9 +1458,6 @@ module Tuile
1240
1458
 
1241
1459
  # Returns the value of attribute over_popups
1242
1460
  attr_reader over_popups: Object
1243
-
1244
- # Returns the value of attribute hint
1245
- attr_reader hint: Object
1246
1461
  end
1247
1462
  end
1248
1463
 
@@ -1285,8 +1500,68 @@ module Tuile
1285
1500
  # any stale invalidation entries are filtered out at drain time. Subclasses
1286
1501
  # can paint freely in {#repaint} without re-asserting attachment.
1287
1502
  class Component
1503
+ FINAL_METHODS: ::Array[Symbol]
1504
+
1505
+ # Raises unless `klass` inherits every {FINAL_METHODS} entry from
1506
+ # {Component}; memoized, so it costs one hash lookup per construction.
1507
+ #
1508
+ # Comparing each *resolved* method's `owner` is what makes this catch all
1509
+ # four routes in — `def`, `define_method`, an included module, a `prepend`.
1510
+ # A `method_added` hook would fire earlier but see only the first two.
1511
+ #
1512
+ # _@param_ `klass` — the class being instantiated.
1513
+ def self.verify_final!: (Class klass) -> void
1514
+
1288
1515
  def initialize: () -> void
1289
1516
 
1517
+ # _@return_ — `rect.size`.
1518
+ def size: () -> Size
1519
+
1520
+ # _@return_ — `rect.width`.
1521
+ def width: () -> Integer
1522
+
1523
+ # _@return_ — `rect.height`.
1524
+ def height: () -> Integer
1525
+
1526
+ # The size of the region this component paints, or `nil` (the default) to
1527
+ # declare nothing — in which case the whole {#rect} is treated as fair game
1528
+ # and the default {#repaint} blanks all of it. Override it when you paint
1529
+ # less: a one-row {Component::Checkbox} handed a tall column, or a
1530
+ # {Component::Select} used as a {Component::Popup}'s content and assigned the
1531
+ # whole inner box.
1532
+ #
1533
+ # It always sits at {#rect}'s top-left — which is why this is a {Size} and
1534
+ # not a {Rect}: an offset extent is not merely unsupported, it is
1535
+ # unrepresentable. Use {#extent_rect} where coordinates are wanted.
1536
+ #
1537
+ # **`nil` is not the same as `rect.size`.** `nil` says "I have not declared
1538
+ # what I paint, so clear everything before I do", which is what a
1539
+ # {Component::Label} with short text needs. A declared extent — even one that
1540
+ # happens to equal the rect, as a one-row {Component::Select} in a one-row
1541
+ # rect does — says "I paint this in full, don't blank it", which is what
1542
+ # keeps the default {#repaint} from dirtying cells it is about to redraw
1543
+ # (`D_progress_bar`). The base cannot tell those apart from the value alone;
1544
+ # that is what the `nil` carries.
1545
+ #
1546
+ # It flows **downward only**: no container consults it when dividing space,
1547
+ # so {#rect} still means exactly what the parent assigned (`D_extent`). Three
1548
+ # things read it, all of them this component or the framework painting it:
1549
+ # {#clear_outside_extent} blanks the dead tail, {#handle_mouse} hit-tests
1550
+ # against it so a click on that tail doesn't activate the widget, and a
1551
+ # dropdown anchors under it rather than under unused space.
1552
+ #
1553
+ # **An override promises to paint the extent in full**, so `super` in
1554
+ # {#repaint} blanks only what is outside it. The arithmetic is each widget's
1555
+ # own — caption width, painted strip, one row — and must not vary with
1556
+ # {#bg_color} (`D_boolean_fields`).
1557
+ def extent: () -> Size?
1558
+
1559
+ # {#extent} placed at {#rect}'s top-left, for the consumers that need
1560
+ # coordinates: `extent_rect.contains?(event.point)` in a {#handle_mouse}, and
1561
+ # the anchor a dropdown hangs from. Total — an undeclared {#extent} yields
1562
+ # the whole {#rect}, so a generic caller never sees `nil`.
1563
+ def extent_rect: () -> Rect
1564
+
1290
1565
  # _@return_ — the screen which owns this component.
1291
1566
  def screen: () -> Screen
1292
1567
 
@@ -1301,14 +1576,29 @@ module Tuile
1301
1576
  def effective_bg_color: () -> Color?
1302
1577
 
1303
1578
  # 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.
1579
+ # need: it clears the background — unless the direct children already tile
1580
+ # {#rect}, in which case there is no gap to wipe and blanking cells they are
1581
+ # about to repaint would only make them dirty — and then re-invalidates
1582
+ # those children so they paint over the cleared area. That is what makes
1583
+ # mixed-width form layouts safe.
1308
1584
  #
1309
1585
  # Call `super` from your own `repaint` to inherit this. Skip it only if you
1310
1586
  # paint the whole {#rect} yourself ({Window}'s border, {Component::List}'s
1311
1587
  # row-by-row paint). Never draw outside {#rect}. Only called when attached.
1588
+ #
1589
+ # **A widget that paints less than its rect declares an {#extent} rather than
1590
+ # skipping `super`.** The clear then covers only what is outside it, so the
1591
+ # cells it is about to repaint are not blanked first — blanking them would
1592
+ # mark them dirty and make {Buffer#flush} re-emit them (`D_progress_bar`).
1593
+ #
1594
+ # **The children are re-invalidated whether or not they tile.** A container
1595
+ # that paints nothing of its own can only redraw its area *through* them, so
1596
+ # a tiling container that skipped this would be a dead end in the cascade: an
1597
+ # ancestor's `clear_background` wipes the whole ancestor rect — siblings and
1598
+ # grandchildren included — and re-invalidates only its *direct* children, so
1599
+ # the notice has to keep travelling down or the cleared cells are never
1600
+ # repainted. Cheap by construction: repainting the same glyphs leaves
1601
+ # {Buffer::Cell} unchanged, so nothing extra reaches the wire.
1312
1602
  def repaint: () -> void
1313
1603
 
1314
1604
  # Called when a key is pressed; override to act on keys you care about (the
@@ -1322,8 +1612,35 @@ module Tuile
1322
1612
  # _@return_ — true if the key was handled, false if not.
1323
1613
  def handle_key: (String _key) -> bool
1324
1614
 
1325
- # Handles mouse event. Default implementation focuses this component when
1326
- # clicked (if {#focusable?}).
1615
+ # Called when text is pasted while this component is on the focus chain;
1616
+ # override to accept it (the default reports every paste unhandled, and
1617
+ # unhandled text is dropped). Arrives whole and `\n`-normalized, so
1618
+ # `text.lines.size` is the paste's line count and a single mutation can
1619
+ # absorb it:
1620
+ #
1621
+ # def handle_paste(text)
1622
+ # self.caption = "[Pasted #{text.lines.size} lines]"
1623
+ # true
1624
+ # end
1625
+ #
1626
+ # Reaching here means the terminal said "this came from the clipboard" —
1627
+ # {Component::AbstractStringField} inserts it at the caret, which is why a
1628
+ # subclass that rebinds ENTER to submit needs no paste handling of its own
1629
+ # to stop firing once per pasted line.
1630
+ #
1631
+ # _@param_ `_text` — the pasted text.
1632
+ #
1633
+ # _@return_ — true if the paste was consumed.
1634
+ def handle_paste: (String _text) -> bool
1635
+
1636
+ # Focuses this component when left-clicked (if {#focusable?}), then hands the
1637
+ # event down to every child whose {#rect} contains the point — which is how a
1638
+ # click descends the tiled tree to a leaf.
1639
+ #
1640
+ # A widget that resolves clicks *inside* its own rect — mapping a point to a
1641
+ # row, or toggling an overlay — overrides this and does not call `super`.
1642
+ # Such an override hit-tests {#extent_rect} rather than {#rect}, so a click
1643
+ # on the tail it doesn't paint never activates it.
1327
1644
  #
1328
1645
  # _@param_ `event`
1329
1646
  def handle_mouse: (MouseEvent event) -> void
@@ -1399,15 +1716,10 @@ module Tuile
1399
1716
  # _@return_ — absolute screen coordinates, or nil to hide.
1400
1717
  def cursor_position: () -> Point?
1401
1718
 
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
1719
  # Adopts `child`: places it in {#children} and wires its parent pointer.
1408
1720
  #
1409
- # add_child(@status_bar) # paints last
1410
- # add_child(popup, at: @children.index(@status_bar)) # …just before it
1721
+ # add_child(content, at: 0) # the tiled layer, painted beneath …
1722
+ # add_child(@footer) # … and chrome appended, painted over it
1411
1723
  #
1412
1724
  # _@param_ `child` — must not already have a parent.
1413
1725
  #
@@ -1506,6 +1818,14 @@ module Tuile
1506
1818
  # Children with empty rects contribute zero, since they paint nothing.
1507
1819
  def children_tile_rect?: () -> bool
1508
1820
 
1821
+ # Blanks the part of {#rect} outside {#extent} — the dead tail a widget that
1822
+ # paints less than it was given must not leave stale. Up to two regions,
1823
+ # since a narrowed extent leaves an L: the columns right of it, and the rows
1824
+ # below it. A `nil` extent declares nothing, so the whole rect is blanked.
1825
+ # Called by the default {#repaint}; a self-painter that skips `super` calls
1826
+ # it directly.
1827
+ def clear_outside_extent: () -> void
1828
+
1509
1829
  # Clears the background: fills every cell with a blank in the
1510
1830
  # {#effective_bg_color} (the terminal default when none is inherited).
1511
1831
  #
@@ -1584,6 +1904,10 @@ module Tuile
1584
1904
  # content (`text=`, `lines=`, …) is safe. Do not assign {Screen#theme=}
1585
1905
  # here. Subclasses overriding this must call `super` so an assigned
1586
1906
  # {#on_theme_changed=} listener keeps firing.
1907
+ #
1908
+ # Plumbing an app overrides and never calls, hence protected — and
1909
+ # {Screen}, not being a {Component}, fans it out through `__send__`, so an
1910
+ # override is free to declare any visibility (`D_hook_visibility`).
1587
1911
  attr_accessor on_theme_changed: Proc?
1588
1912
 
1589
1913
  # A scrollable list of typed items, one row each, with cursor support.
@@ -1696,6 +2020,22 @@ module Tuile
1696
2020
  # _@return_ — true if a match was found.
1697
2021
  def select_prev: (String query, ?include_current: bool) -> bool
1698
2022
 
2023
+ # Moves the cursor to the item at `index`, scrolling it into view and
2024
+ # firing {#on_cursor_changed} — the positional member of the
2025
+ # {#select_next} / {#select_prev} family, for a caller that already knows
2026
+ # *which* item it wants:
2027
+ #
2028
+ # list.select(items.index(chosen))
2029
+ #
2030
+ # Refuses an index the current {#cursor} can't reach — out of range, or
2031
+ # anything at all under {Cursor::None} — rather than stranding the cursor
2032
+ # off-content.
2033
+ #
2034
+ # _@param_ `index`
2035
+ #
2036
+ # _@return_ — whether the cursor moved there.
2037
+ def select: (Integer index) -> bool
2038
+
1699
2039
  # _@param_ `event`
1700
2040
  def handle_mouse: (MouseEvent event) -> void
1701
2041
 
@@ -2037,6 +2377,364 @@ module Tuile
2037
2377
  end
2038
2378
  end
2039
2379
 
2380
+ # A one-child region: a place in the tree reserved for content that may be
2381
+ # absent, arrive late, or be swapped. The occupant fills the slot's {#rect}.
2382
+ #
2383
+ # Wire one per region at construction, then swap occupants through
2384
+ # {HasContent#content=} — which is how {Window} holds its footer:
2385
+ #
2386
+ # @footer_slot = Slot.new
2387
+ # add_child(@footer_slot) # the region, wired once
2388
+ # …
2389
+ # @footer_slot.content = new_footer # the occupant, swapped at will
2390
+ #
2391
+ # Because the slot never leaves {Component#children}, a swap's insert index
2392
+ # is always 0 — never a function of which *other* regions happen to be
2393
+ # occupied, which is the whole reason to reach for one (`D_slots`).
2394
+ #
2395
+ # An empty slot does not collapse: it keeps its rect and clears it, so a
2396
+ # dialog with no message shows the hole. Close the gap with a zero extent
2397
+ # from the parent, or assign an empty {Rect} to suppress the clear entirely
2398
+ # (what {Window} does with an absent footer); never detach it.
2399
+ #
2400
+ # Transparent to input: not {Component#focusable?},
2401
+ # {Component#handle_mouse} descends through it, and a departing occupant's
2402
+ # focus repair is handed to the container.
2403
+ class Slot < Component
2404
+ include Tuile::Component::HasContent
2405
+
2406
+ # _@param_ `content` — the initial occupant.
2407
+ def initialize: (?Component? content) -> void
2408
+
2409
+ # Hands the repair to the container rather than doing it here: the default
2410
+ # would move focus to `self`, and a slot is inert — no cursor, no keys,
2411
+ # nothing to bubble from.
2412
+ #
2413
+ # _@param_ `child` — the just-detached occupant.
2414
+ def on_child_removed: (Component child) -> void
2415
+
2416
+ # _@param_ `content`
2417
+ def layout: (Component content) -> void
2418
+
2419
+ # _@param_ `rect`
2420
+ def rect=: (Rect rect) -> void
2421
+
2422
+ def on_focus: () -> void
2423
+ end
2424
+
2425
+ # A one-row strip of captions with exactly one of them selected — the map of
2426
+ # where the user is. Knows nothing about content: pair it with
2427
+ # {Component::TabSheet} to swap panes, or swap views yourself from
2428
+ # {#on_tab_selected}.
2429
+ #
2430
+ # ␣Details␣│␣Payment␣│␣Shipping␣
2431
+ # ^^^^^^^ selected: bold, and highlighted while the strip has focus
2432
+ #
2433
+ # tabs = Component::Tabs.new
2434
+ # tabs.add_tab("Details") # the first tab is selected
2435
+ # payment = tabs.add_tab("Payment")
2436
+ # tabs.on_tab_selected = ->(index, tab) { show(index) }
2437
+ # tabs.selected = payment # fires the listener
2438
+ # payment.caption = "Payment ⚠" # repaints the strip
2439
+ #
2440
+ # LEFT / RIGHT switch tabs immediately — no cursor to move first, no Enter
2441
+ # to confirm — clamping at both ends rather than wrapping; a left click
2442
+ # selects the tab under the pointer. Everything else bubbles to an ancestor,
2443
+ # Enter, Space, Up, Down, Home and End included, so a form's default button
2444
+ # and the app's own keys keep working while the strip has focus.
2445
+ #
2446
+ # One tab stop for the whole strip: Tab moves *past* it, never between its
2447
+ # tabs. For a key of your own that switches tabs from elsewhere in the app,
2448
+ # bind it yourself and call {#select_next} / {#select_previous}.
2449
+ #
2450
+ # {Tab} handles are minted by {#add_tab} and owned by the strip. There is no
2451
+ # `items=`: a tab is identity plus its own state, so the set grows and
2452
+ # shrinks one tab at a time. See book ch7 and `DECISIONS.md` `D_tabs`.
2453
+ #
2454
+ # == Sizing
2455
+ # Assign a {#rect} (typically from the surrounding {Layout}). One wider than
2456
+ # {#extent}`.width` leaves a dead tail; a narrower one **scrolls**. The strip
2457
+ # keeps the selected segment whole in view, moving its window by the minimum
2458
+ # needed, so arrowing into an off-screen tab brings that tab on screen — and
2459
+ # a click on a half-visible segment at an edge selects it and pulls it into
2460
+ # view. A `<` or `>` painted over an edge column says there is more strip
2461
+ # that way, as does the cut caption underneath it. The one thing that cannot
2462
+ # be shown whole is a caption wider than the entire rect: it shows its head
2463
+ # and clips its tail. The scroll offset itself is not API — the invariant is.
2464
+ #
2465
+ # == Implementation details
2466
+ # A segment is one space of padding, the caption, one space of padding, and
2467
+ # segments are joined by a single {DEFAULT_SEPARATOR} column. The padding
2468
+ # belongs to the segment: the highlight covers it and a click on it selects
2469
+ # the tab, while the separator column is chrome and selects nothing, like
2470
+ # the blank tail past {#extent}. One private `segments` method is the sole
2471
+ # source of that arithmetic — both the paint and the hit test read it, and
2472
+ # both offset it by the same scroll column, so a click cannot land on a tab
2473
+ # other than the one drawn under it — and it is
2474
+ # derived from the captions on each call rather than recorded during the
2475
+ # last paint, so a hit test is correct before the first paint.
2476
+ #
2477
+ # The selected caption is bold *always*, so the strip still says where you
2478
+ # are once focus has moved on, and additionally sits on
2479
+ # {Theme#active_bg_color} while the strip is on the focus chain. Bold is the
2480
+ # selection channel and not strip chrome: bolding every caption would leave
2481
+ # selection to the focus-gated background alone, and an unfocused strip
2482
+ # would then show no selection at all.
2483
+ class Tabs < Component
2484
+ DEFAULT_SEPARATOR: String
2485
+
2486
+ # _@param_ `separator` — see {#separator=}.
2487
+ def initialize: (?separator: (String | StyledString)) -> void
2488
+
2489
+ # _@return_ — `true` — the strip takes focus, so its arrows work.
2490
+ def focusable?: () -> bool
2491
+
2492
+ # _@return_ — `true` — one stop for the whole strip.
2493
+ def tab_stop?: () -> bool
2494
+
2495
+ # _@return_ — the selected tab; `nil` only while there are no tabs.
2496
+ def selected: () -> Tab?
2497
+
2498
+ # _@param_ `tab` — one of this strip's tabs.
2499
+ def selected=: (Tab tab) -> void
2500
+
2501
+ # Appends a tab and returns its handle. The first tab added becomes the
2502
+ # selection; later ones don't disturb it.
2503
+ #
2504
+ # _@param_ `caption` — parsed as {Tab#caption=} parses it.
2505
+ def add_tab: (?(String | StyledString)? caption) -> Tab
2506
+
2507
+ # Removes `tab` and detaches its handle permanently.
2508
+ #
2509
+ # The selection is never left dangling: removing the selected tab selects
2510
+ # whichever tab slid into its place (the new last tab, if it was the last),
2511
+ # and removing the final tab leaves {#selected} `nil`. Either way
2512
+ # {#on_tab_selected} fires — the empty case with `(nil, nil)`, since a
2513
+ # listener rendering from the selection has to be told to render nothing.
2514
+ #
2515
+ # _@param_ `tab` — one of this strip's tabs.
2516
+ def remove_tab: (Tab tab) -> void
2517
+
2518
+ # Selects the next tab, clamping at the last — the strip never wraps.
2519
+ # Public because it is the verb an app's own key binding drives.
2520
+ #
2521
+ # _@return_ — `false` only when there are no tabs.
2522
+ def select_next: () -> bool
2523
+
2524
+ # Selects the previous tab, clamping at the first.
2525
+ #
2526
+ # _@return_ — `false` only when there are no tabs.
2527
+ def select_previous: () -> bool
2528
+
2529
+ # The cells the strip actually paints: one row, as wide as its segments and
2530
+ # separators need, clipped to {#rect}. A layout routinely hands a strip a
2531
+ # window's full width for a 32-column strip — the extent is those 32
2532
+ # columns.
2533
+ #
2534
+ # Both the focus highlight and the click hit test use it, so a click on the
2535
+ # blank tail — or on a lower row, when the rect is taller than one —
2536
+ # selects nothing. It still *focuses*: {Component#handle_mouse}'s
2537
+ # click-to-focus is ungated by geometry.
2538
+ def extent: () -> Size
2539
+
2540
+ # Switches tabs on LEFT / RIGHT, consuming the key even at the ends of the
2541
+ # strip (the selection clamps). Every other key is left unhandled so it
2542
+ # bubbles to an ancestor; an empty strip handles nothing at all.
2543
+ #
2544
+ # _@param_ `key`
2545
+ def handle_key: (String key) -> bool
2546
+
2547
+ # Selects the tab under a left click; `super` runs first, so a click
2548
+ # anywhere in {#rect} still focuses.
2549
+ #
2550
+ # _@param_ `event`
2551
+ def handle_mouse: (MouseEvent event) -> void
2552
+
2553
+ def repaint: () -> void
2554
+
2555
+ # Re-syncs the scroll offset and repaints — what every change to the
2556
+ # captions, the separator or the selection ends in.
2557
+ def refresh: () -> void
2558
+
2559
+ # The rect's *width* is the only part of it the offset depends on, so this
2560
+ # hook is the whole geometry story; {Component#rect=} invalidates for us.
2561
+ def on_width_changed: () -> void
2562
+
2563
+ # Scrolls the minimum needed to show the selected segment whole, and is the
2564
+ # sole writer of {#left_column}. Idempotent, so every mutation site can
2565
+ # call it blindly; it returns the offset to `0` on its own once the strip
2566
+ # fits again, which is why no mutator owes a scroll-back branch.
2567
+ #
2568
+ # A segment wider than the whole rect cannot be shown whole: its head wins,
2569
+ # being the half of a caption that identifies it.
2570
+ def adjust_left_column: () -> void
2571
+
2572
+ # {StyledString#slice} *drops* a cluster straddling the window's edge
2573
+ # rather than half-painting it, which would leave the painted row a column
2574
+ # short and shift everything past the hole one column left — paint and hit
2575
+ # test would then disagree, silently and only for wide glyphs. So the
2576
+ # offset only ever lands on a cluster boundary. Snapping *forward* is the
2577
+ # safe direction: it gives up at most one column of the segment to the left
2578
+ # of the window, never of the one being revealed.
2579
+ #
2580
+ # _@param_ `column`
2581
+ #
2582
+ # _@return_ — the smallest cluster-boundary column `>= column`.
2583
+ def snap_to_glyph_start: (Integer column) -> Integer
2584
+
2585
+ # Paints the overflow cues over the windowed row's edge columns: `<` when
2586
+ # segments sit to the left of the window, `>` when more sit to the right.
2587
+ # ASCII by convention rather than by constant, as {Checkbox}'s brackets
2588
+ # are, and *overlaid* rather than given reserved columns — reserving would
2589
+ # make the window width a function of the offset computed from it.
2590
+ #
2591
+ # _@param_ `row` — the windowed row, as painted.
2592
+ def draw_cues: (StyledString row) -> void
2593
+
2594
+ # The cue keeps the style of the cell it covers, so one landing on the
2595
+ # selected segment doesn't punch a default-background hole in its
2596
+ # highlight.
2597
+ #
2598
+ # _@param_ `row` — the windowed row.
2599
+ #
2600
+ # _@param_ `column` — relative to {#rect}`.left`.
2601
+ #
2602
+ # _@param_ `glyph`
2603
+ def draw_cue: (StyledString row, Integer column, String glyph) -> void
2604
+
2605
+ # One `[tab, start_column, width]` triple per tab, in strip order, in
2606
+ # columns relative to {#rect}`.left`. A segment's width is its caption plus
2607
+ # the two padding columns; the separator columns between segments belong to
2608
+ # no segment.
2609
+ def segments: () -> ::Array[[Tab, Integer, Integer]]
2610
+
2611
+ # _@return_ — columns the strip would paint given an unlimited rect.
2612
+ def painted_width: () -> Integer
2613
+
2614
+ # _@param_ `point`
2615
+ #
2616
+ # _@return_ — the tab painted at `point`; `nil` for a separator
2617
+ # column, the blank tail, or a row the strip doesn't paint.
2618
+ def tab_at: (Point point) -> Tab?
2619
+
2620
+ # _@return_ — the whole strip as one row, unclipped: segments
2621
+ # left to right, joined by the separator. {#repaint} windows it to the
2622
+ # rect; nothing else may, since the window's own arithmetic is
2623
+ # {#adjust_left_column}'s.
2624
+ def strip_row: () -> StyledString
2625
+
2626
+ # _@param_ `tab`
2627
+ #
2628
+ # _@param_ `index`
2629
+ #
2630
+ # _@return_ — the caption between its padding columns, styled
2631
+ # for the selection.
2632
+ def segment_text: (Tab tab, Integer index) -> StyledString
2633
+
2634
+ # _@param_ `tab`
2635
+ #
2636
+ # _@return_ — the tab's position on this strip.
2637
+ def index_of!: (Tab tab) -> Integer
2638
+
2639
+ # _@param_ `delta` — `+1` / `-1`.
2640
+ #
2641
+ # _@return_ — `false` only when there are no tabs.
2642
+ def step_selection: (Integer delta) -> bool
2643
+
2644
+ # _@param_ `index`
2645
+ def select_at: (Integer? index) -> void
2646
+
2647
+ # Stores the selection, repaints, and fires {#on_tab_selected} when the
2648
+ # selected *tab* changed.
2649
+ #
2650
+ # `previous` is passed in rather than read here because a removal can leave
2651
+ # the index numerically unchanged while a different tab sits under it —
2652
+ # remove the selected middle tab of three and index 1 now holds what used
2653
+ # to be index 2. Comparing indices would swallow that notification.
2654
+ #
2655
+ # _@param_ `index` — the new selection.
2656
+ #
2657
+ # _@param_ `previous` — the tab selected before the caller's change.
2658
+ def apply_selection: (Integer? index, Tab? previous) -> void
2659
+
2660
+ # Called with `@tabs` already shortened and `@selected_index` still holding
2661
+ # the pre-removal position.
2662
+ #
2663
+ # _@param_ `removed_index` — the position the removed tab held.
2664
+ #
2665
+ # _@return_ — where the selection lands.
2666
+ def selection_after_removing: (Integer removed_index) -> Integer?
2667
+
2668
+ # Called on every change of {#selected} with the new selection —
2669
+ # `(index, tab)`, or `(nil, nil)` once the last tab has been removed.
2670
+ #
2671
+ # It reports that the selection *changed*, not that the user pressed
2672
+ # something: arrows, a click, {#selected=} / {#selected_index=}, the
2673
+ # autoselect of the first {#add_tab} and the re-selection that follows
2674
+ # removing the selected tab all fire it. Re-selecting the tab already
2675
+ # selected fires nothing.
2676
+ attr_accessor on_tab_selected: Proc?
2677
+
2678
+ # _@return_ — the tabs, in strip order. Read-only by convention
2679
+ # (like {Component#children}) — grow and shrink it through {#add_tab} /
2680
+ # {#remove_tab}, which keep the selection consistent. Enumerate it to
2681
+ # find a tab: `tabs.find { |t| t.caption.to_s == "Payment" }`.
2682
+ attr_reader tabs: ::Array[Tab]
2683
+
2684
+ # _@return_ — the column painted between two segments.
2685
+ attr_accessor separator: (StyledString | String)
2686
+
2687
+ # _@return_ — the selected tab's position; `nil` only while
2688
+ # there are no tabs.
2689
+ attr_accessor selected_index: Integer?
2690
+
2691
+ # _@return_ — the strip column painted in {#rect}'s leftmost cell —
2692
+ # the horizontal scroll offset. `0` unless the strip overflows its rect;
2693
+ # {#adjust_left_column} is its sole writer.
2694
+ attr_reader left_column: Integer
2695
+
2696
+ # A single tab: a caption, plus its identity on the strip.
2697
+ #
2698
+ # tab = tabs.add_tab("Payment")
2699
+ # tab.caption = "Payment ⚠" # repaints the strip
2700
+ # tab.remove # `tab` now raises on every mutator
2701
+ #
2702
+ # Apps don't construct tabs; {Tabs#add_tab} mints them. A removed handle
2703
+ # raises {RuntimeError} on every mutator and on every reader that consults
2704
+ # the strip — answering confidently about a tab the strip no longer holds
2705
+ # would hide the bug. {#caption} and {#attached?} stay readable (the
2706
+ # caption lives here, so an error message can still name it), and
2707
+ # {#remove} is a silent no-op so a cleanup path can call it blindly.
2708
+ class Tab
2709
+ # _@param_ `strip` — the owning strip.
2710
+ #
2711
+ # _@param_ `caption` — already coerced by the caller.
2712
+ def initialize: (Tabs strip, StyledString caption) -> void
2713
+
2714
+ # _@return_ — `true` while the tab is owned by its {Tabs}; `false`
2715
+ # permanently once removed.
2716
+ def attached?: () -> bool
2717
+
2718
+ # _@return_ — whether this is the strip's selected tab.
2719
+ def selected?: () -> bool
2720
+
2721
+ # Removes this tab from its strip and detaches the handle permanently —
2722
+ # {Tabs#remove_tab} has what that does to the selection. Idempotent on an
2723
+ # already-removed tab, unlike the mutators.
2724
+ def remove: () -> void
2725
+
2726
+ def inspect: () -> String
2727
+
2728
+ def detach: () -> void
2729
+
2730
+ def check_attached: () -> void
2731
+
2732
+ # _@return_ — the label painted on the strip. Safe to read on
2733
+ # a removed tab.
2734
+ attr_accessor caption: (StyledString | String)?
2735
+ end
2736
+ end
2737
+
2040
2738
  # A label which shows static text. No word-wrapping; long lines are
2041
2739
  # truncated with an ellipsis. Text is modeled as a {StyledString};
2042
2740
  # {#text=} accepts a {String} (parsed via {StyledString.parse}, so
@@ -2085,105 +2783,71 @@ module Tuile
2085
2783
  attr_accessor bg: (Color | Symbol | Integer | ::Array[Integer])?
2086
2784
  end
2087
2785
 
2088
- # An overlay that wraps any {Component} as its content. Popup itself
2089
- # paints nothing — it's a transparent host that handles its lifecycle
2090
- # ({#open} / {#close} / {#open?}, ESC/q to close) and holds a top-down
2091
- # {#size} the {Screen} applies.
2092
- #
2093
- # The popup does *not* size itself to its content. Its box is declared by
2094
- # {#size} — a {Fraction} (resolved against the screen every layout pass, so
2095
- # it tracks resize) or an absolute {Size} (clamped to the screen). The
2096
- # default is {Fraction::HALF}: half the screen, centered. The wrapped
2097
- # content then fills that box and handles its own overflow by wrapping and
2098
- # scrolling, so use content that can — a {Component::TextView} or
2099
- # {Component::TextArea} — for anything longer than fits. A
2100
- # {Component::Label} only truncates.
2101
- #
2102
- # Modal by default: it centers on the screen, grabs focus, eats keys, and
2103
- # blocks clicks beneath it. Pass `modal: false` for a non-modal overlay
2104
- # that floats above the content without taking focus or capturing input —
2105
- # the caller positions it (via {#rect=}), sizes it, and drives it from app
2106
- # code. That's the building block for an autocomplete/slash-command list
2107
- # anchored to a text field's caret: typing keeps focus in the input while
2108
- # the caller refills and drives the overlay.
2109
- #
2110
- # The wrapped content fills the popup's full {#rect}; if you want a frame
2111
- # and caption, wrap a {Component::Window} (or any subclass — including
2112
- # {Component::LogWindow}) and let it draw its own border:
2786
+ # A modal dialog: an {Component::Overlay} that centers itself on the screen,
2787
+ # grabs focus, scopes keys to its own subtree, blocks clicks beneath it, and
2788
+ # closes on ESC or `q`.
2113
2789
  #
2114
2790
  # window = Component::Window.new("Help")
2115
2791
  # window.content = Component::List.new.tap { _1.lines = lines }
2116
2792
  # Component::Popup.new(content: window).open
2117
2793
  #
2118
2794
  # Bare content also works (a {Component::Label}, a {Component::List}…), in
2119
- # which case the popup is borderless.
2795
+ # which case the popup is borderless. For a floating layer that does *not*
2796
+ # take focus or capture input — an autocomplete list anchored to a field, a
2797
+ # toast — use {Component::Overlay} directly.
2798
+ #
2799
+ # The popup does *not* size itself to its content. Its box is set by
2800
+ # {#declared_size} — a {Fraction} (resolved against the screen every layout
2801
+ # pass, so it tracks resize) or an absolute {Size} (clamped to the screen).
2802
+ # The default is {Fraction::HALF}: half the screen, centered. The wrapped content
2803
+ # then fills that box and handles its own overflow by wrapping and scrolling,
2804
+ # so use content that can — a {Component::TextView} or
2805
+ # {Component::TextArea} — for anything longer than fits. A
2806
+ # {Component::Label} only truncates.
2807
+ #
2808
+ # == Implementation details
2120
2809
  #
2121
2810
  # `q` and ESC close the popup — handled here, at the top of the popup's own
2122
2811
  # subtree, so the key only arrives after every component on the focus chain
2123
2812
  # declined it (see {ScreenPane#handle_key}). That's why typing `q` into a
2124
- # nested {Component::TextField} doesn't dismiss the popup: the field
2125
- # consumes it first.
2126
- class Popup < Component
2127
- include Tuile::Component::HasContent
2128
-
2813
+ # nested {Component::TextField} doesn't dismiss the popup: the field consumes
2814
+ # it first.
2815
+ #
2816
+ # A left click *outside* the popup closes it too — see
2817
+ # {Overlay#close_on_outside_click?} for the exact contract and
2818
+ # {Overlay#on_close=} for the notice a driver hears when it happens.
2819
+ #
2820
+ # UI-thread-confined, like every component (see {Screen}).
2821
+ class Popup < Tuile::Component::Overlay
2129
2822
  # _@param_ `content` — initial content; can be set later via {#content=}. The content fills the popup's {#rect}; it does not determine the popup's size.
2130
2823
  #
2131
- # _@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).
2824
+ # _@param_ `declared_size` — the popup's box, applied top-down. A {Fraction} is resolved against the screen each layout pass; a {Size} is clamped to the screen. Defaults to {Fraction::HALF}.
2132
2825
  #
2133
- # _@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
2826
+ # _@param_ `close_on_outside_click` — true (default) to dismiss on a left click that misses this popup. See {Overlay#close_on_outside_click?}.
2827
+ def initialize: (?content: Component?, ?declared_size: (Size | Fraction), ?close_on_outside_click: bool) -> void
2135
2828
 
2136
- # _@return_ — whether this popup is modal. See {#initialize}.
2829
+ # _@return_ — true — a Popup scopes key dispatch, grabs focus on
2830
+ # open, and blocks clicks on the content beneath it.
2137
2831
  def modal?: () -> bool
2138
2832
 
2833
+ # _@return_ — true — {ScreenPane#add_popup} focuses a popup on open,
2834
+ # and focus repair falls back to it when its subtree has no tab stop.
2139
2835
  def focusable?: () -> bool
2140
2836
 
2141
- # Reassigns the popup's rect, escalating to a full scene repaint when an
2142
- # open popup shrinks or moves so its new rect no longer covers the cells
2143
- # it previously painted. A popup overdraws the scene without clipping and
2144
- # nothing clears underneath it, so {Screen#repaint}'s popup-only fast path
2145
- # would repaint into the new rect and leave the vacated cells showing
2146
- # stale content. When the new rect fully covers the old one (the popup
2147
- # only grew), the fast path is correct and the full repaint is skipped.
2148
- #
2149
- # _@param_ `new_rect`
2150
- def rect=: (Rect new_rect) -> void
2151
-
2152
- # Mounts this popup on the {Screen}, re-resolving its {#size} against the
2153
- # current screen first.
2154
- #
2155
- # popup = Component::Popup.new(content: window).open # construct and mount
2156
- #
2157
- # There is deliberately no class-level `Popup.open` factory — see
2158
- # `DECISIONS.md` `D-popup-open`; returning `self` is what keeps the
2159
- # one-liner above available without one.
2160
- def open: () -> self
2161
-
2162
- # Removes this popup from the {Screen}. No-op if not currently open.
2163
- def close: () -> void
2164
-
2165
- # _@return_ — true if this popup is currently mounted on the screen.
2166
- def open?: () -> bool
2167
-
2168
- # Re-resolves {#size} against the current screen and repositions the popup
2169
- # *itself* (this is not laying out content — the popup's own rect): a
2170
- # modal popup recenters; a non-modal overlay keeps its caller-assigned
2171
- # top-left (only its size follows the screen). Called on {#open}, on
2172
- # {#size=}, and by the screen's layout pass (so a {Fraction} size tracks
2173
- # SIGWINCH).
2837
+ # Re-resolves {#declared_size} against the current screen and recenters the popup
2838
+ # *itself* (this is not laying out content — the popup's own rect). Called
2839
+ # on {Overlay#open}, on {#declared_size=}, and by the screen's layout pass,
2840
+ # so a {Fraction} tracks SIGWINCH.
2174
2841
  #
2175
- # The final rect is computed and assigned in one step rather than sizing
2176
- # at the origin and then centering: the intermediate origin rect rarely
2177
- # covers the previous one, which would make {#rect=}'s shrink/move
2842
+ # The final rect is computed and assigned in one step rather than sizing at
2843
+ # the origin and then centering: the intermediate origin rect rarely covers
2844
+ # the previous one, which would make {Overlay#rect=}'s shrink/move
2178
2845
  # detection fire a full repaint on every resize.
2179
2846
  def reposition: () -> void
2180
2847
 
2181
2848
  # Recenters the popup on the screen, preserving its current width/height.
2182
2849
  def center: () -> void
2183
2850
 
2184
- # Hint for the status bar: own "q Close" plus the wrapped content's hint.
2185
- def keyboard_hint: () -> String
2186
-
2187
2851
  # `q` and ESC close the popup. The popup sits on the focus chain of
2188
2852
  # whatever it wraps, so the key reaches here by bubbling up from the
2189
2853
  # focused content after that content declined to handle it.
@@ -2193,18 +2857,14 @@ module Tuile
2193
2857
  # _@return_ — true if the key was handled.
2194
2858
  def handle_key: (String key) -> bool
2195
2859
 
2196
- # Content fills the popup's full rect — Popup has no border to subtract.
2860
+ # The box this popup asks for, *as set* — a {Fraction} comes back
2861
+ # unresolved. {#rect} is the resolved answer. Named apart from `size`
2862
+ # because a component's `size` is its `rect.size`, which this is not: it
2863
+ # is an input re-read on every layout pass, not a report of the current
2864
+ # geometry.
2197
2865
  #
2198
- # _@param_ `content`
2199
- def layout: (Component content) -> void
2200
-
2201
- # _@param_ `event`
2202
- def handle_mouse: (MouseEvent event) -> void
2203
-
2204
- def on_focus: () -> void
2205
-
2206
- # _@return_ — the popup's declared size. See {#size=}.
2207
- attr_accessor size: (Size | Fraction)
2866
+ # _@return_ — see {#declared_size=}.
2867
+ attr_accessor declared_size: (Size | Fraction)
2208
2868
  end
2209
2869
 
2210
2870
  # A clickable button. Activated by Enter, Space, or a left mouse click;
@@ -2240,7 +2900,7 @@ module Tuile
2240
2900
  # It still *focuses*: {Component#handle_mouse}'s click-to-focus is ungated
2241
2901
  # by geometry. Same rule as {Checkbox#extent}, which documents the two
2242
2902
  # traps behind it.
2243
- def extent: () -> Rect
2903
+ def extent: () -> Size
2244
2904
 
2245
2905
  # Fires {#on_click} on a left click within {#extent}; `super` runs first, so
2246
2906
  # a click anywhere in {#rect} still focuses.
@@ -2303,11 +2963,6 @@ module Tuile
2303
2963
  # _@param_ `child`
2304
2964
  def remove: (Component child) -> void
2305
2965
 
2306
- # Dispatches the event to the child under the mouse cursor.
2307
- #
2308
- # _@param_ `event`
2309
- def handle_mouse: (MouseEvent event) -> void
2310
-
2311
2966
  def on_focus: () -> void
2312
2967
 
2313
2968
  # How much space a child gets along one axis of a {Box}: exactly {#cells},
@@ -2739,7 +3394,7 @@ module Tuile
2739
3394
  # Home/End are declined too, so they stay available app-wide.
2740
3395
  #
2741
3396
  # There is no type-ahead: a hidden prefix buffer *is* the ComboBox query with
2742
- # the feedback removed (`DECISIONS.md` `D-select`). Which is also why labels
3397
+ # the feedback removed (`DECISIONS.md` `D_select`). Which is also why labels
2743
3398
  # need no prefix-disambiguation.
2744
3399
  #
2745
3400
  # == Implementation details
@@ -2765,8 +3420,6 @@ module Tuile
2765
3420
 
2766
3421
  def tab_stop?: () -> bool
2767
3422
 
2768
- def keyboard_hint: () -> String
2769
-
2770
3423
  # Re-anchors the (open) dropdown after a move or resize.
2771
3424
  #
2772
3425
  # _@param_ `new_rect`
@@ -2788,7 +3441,15 @@ module Tuile
2788
3441
  # _@param_ `key`
2789
3442
  def handle_key: (String key) -> bool
2790
3443
 
2791
- # Toggles the dropdown on a left click anywhere in {#rect} — a field's
3444
+ # The one row this Select paints — the full width, at the top of {#rect}.
3445
+ # A single-slot container ({Component::Window}, {Component::Popup}) hands
3446
+ # its content the whole inner rect, so a Select is routinely assigned more
3447
+ # height than it uses; {#repaint} clears that tail, {#handle_mouse} refuses
3448
+ # clicks in it, and the dropdown hangs under this rather than under the
3449
+ # unused space.
3450
+ def extent: () -> Size
3451
+
3452
+ # Toggles the dropdown on a left click anywhere in {#extent} — a field's
2792
3453
  # affordance is its whole row, as the well advertises; `super` runs first,
2793
3454
  # so the click also focuses.
2794
3455
  #
@@ -2857,7 +3518,7 @@ module Tuile
2857
3518
  # a read-only display field could override back to `false`. Only
2858
3519
  # `focusable?` lives here — `tab_stop?` diverges between leaf fields and
2859
3520
  # composing wrappers, so it stays per-class (`DECISIONS.md`
2860
- # `D-integer-field`).
3521
+ # `D_integer_field`).
2861
3522
  def focusable?: () -> bool
2862
3523
 
2863
3524
  # _@return_ — the options.
@@ -2887,8 +3548,18 @@ module Tuile
2887
3548
 
2888
3549
  def focusable?: () -> bool
2889
3550
 
2890
- # _@param_ `event`
2891
- def handle_mouse: (MouseEvent event) -> void
3551
+ # _@return_ — optional focusable component occupying the
3552
+ # bottom border row, always spanning the full inner width.
3553
+ def footer: () -> Component?
3554
+
3555
+ # Mounts a component in the bottom border row, spanning the full inner
3556
+ # width and positioned automatically; `nil` removes it.
3557
+ #
3558
+ # Precedence: a footer component present hides {#footer_text}; absent, the
3559
+ # text embeds into the bottom border. No window needs both at once.
3560
+ #
3561
+ # _@param_ `new_footer`
3562
+ def footer=: (Component? new_footer) -> void
2892
3563
 
2893
3564
  # _@param_ `new_rect`
2894
3565
  def rect=: (Rect new_rect) -> void
@@ -2939,9 +3610,12 @@ module Tuile
2939
3610
  # _@param_ `fg` — the active-border color, or nil when inactive.
2940
3611
  def bottom_border: (Integer inner_w, Color? fg) -> StyledString
2941
3612
 
2942
- # Positions the footer over the bottom border row, spanning the full
3613
+ # Positions the footer slot over the bottom border row, spanning the full
2943
3614
  # inner width (the only dimension a bottom-row widget needs — the window
2944
3615
  # already knows it).
3616
+ #
3617
+ # An unoccupied slot gets an *empty* rect, not the row — a {Slot} clears
3618
+ # whatever it is given, which would blank the border underneath.
2945
3619
  def layout_footer: () -> void
2946
3620
 
2947
3621
  # Read through *this* method, never `@caption` — the ivar stays nil until
@@ -2959,17 +3633,169 @@ module Tuile
2959
3633
 
2960
3634
  def on_focus: () -> void
2961
3635
 
2962
- # _@return_ — optional focusable component occupying the
2963
- # bottom border row, always spanning the full inner width.
2964
- attr_accessor footer: Component?
2965
-
2966
3636
  # _@return_ — optional chrome embedded into the bottom border
2967
3637
  # line, mirroring {#caption} on the top line. Empty by default; hidden
2968
3638
  # whenever a {#footer} component is present.
2969
3639
  attr_accessor footer_text: (StyledString | String)?
2970
3640
  end
2971
3641
 
2972
- # A boolean input on one row. Space, Enter or a left click toggles it:
3642
+ # A component mounted on the {Screen}'s overlay stack rather than in the
3643
+ # tiled tree: it floats above the content at a rect the caller assigns, and
3644
+ # has an open/close lifecycle instead of a parent that lays it out.
3645
+ #
3646
+ # overlay = Component::Overlay.new(content: Component::Label.new("saved"))
3647
+ # overlay.rect = Rect.new(10, 4, 20, 1) # you place it — nothing else does
3648
+ # overlay.open # mounts it on the Screen
3649
+ # overlay.close
3650
+ #
3651
+ # That is the whole of it — floating, plus the lifecycle, {#on_close},
3652
+ # outside-click dismissal and {#owner}. {Component::Popup} is the subclass
3653
+ # that adds a declared size, self-centering, focus and key handling.
3654
+ #
3655
+ # The wrapped content fills the overlay's whole {#rect}; for a frame and a
3656
+ # caption, wrap a {Component::Window} and let it draw its own border.
3657
+ #
3658
+ # == Implementation details
3659
+ #
3660
+ # **{#focusable?} and {#modal?} move together — flip both or neither.** The
3661
+ # defaults here are inert (`false`, `false`): a bare overlay floats without
3662
+ # disturbing focus or key dispatch. {Component::Popup} flips both. What must
3663
+ # not appear is a *focusable non-modal* overlay: {ScreenPane#handle_key}
3664
+ # scopes delivery to the topmost modal popup or else the tiled content, so
3665
+ # such an overlay would hold focus outside the key scope, where delivery
3666
+ # reaches nobody and *every* keystroke goes dead until Tab recovers. A
3667
+ # non-modal overlay is therefore driven from its owner's key handler
3668
+ # ({Component::Select} forwarding to its dropdown) instead of claiming focus.
3669
+ #
3670
+ # **A derived position needs a {#reposition} override.** The default is a
3671
+ # no-op: the rect is whatever the caller last assigned. An overlay whose
3672
+ # position is computed — from the screen, or from an anchor — must recompute
3673
+ # it there, or it keeps a stale rect after a SIGWINCH and sits off-screen
3674
+ # entirely if the terminal narrowed. Closing on resize is equally legal
3675
+ # ({Component::MenuBar} drops its cascade from `rect=` rather than walking
3676
+ # every level to re-anchor it).
3677
+ #
3678
+ # UI-thread-confined, like every component (see {Screen}).
3679
+ class Overlay < Component
3680
+ include Tuile::Component::HasContent
3681
+
3682
+ # _@param_ `content` — initial content; can be set later via {#content=}. It fills the overlay's {#rect} and does not determine it.
3683
+ #
3684
+ # _@param_ `close_on_outside_click` — true (default) to dismiss on a left click that misses this overlay. See {#close_on_outside_click?}.
3685
+ def initialize: (?content: Component?, ?close_on_outside_click: bool) -> void
3686
+
3687
+ # _@return_ — false — a bare overlay scopes no keys and blocks no
3688
+ # clicks. {Component::Popup} overrides it, together with {#focusable?}.
3689
+ def modal?: () -> bool
3690
+
3691
+ # _@return_ — false — a bare overlay leaves focus where it was. See
3692
+ # the class docs: override it only together with {#modal?}.
3693
+ def focusable?: () -> bool
3694
+
3695
+ # _@return_ — false — Tab never lands on the overlay wrapper itself
3696
+ # (its content may still carry stops).
3697
+ def tab_stop?: () -> bool
3698
+
3699
+ # Whether a left click outside this overlay closes it (default true). The
3700
+ # pane does the closing — {ScreenPane#handle_mouse} snapshots the open
3701
+ # overlays *before* routing the click and closes the dismissable ones
3702
+ # *after*, so a widget that toggles its own overlay from a click on its
3703
+ # face (a {Component::Select}, a {Component::MenuBar} title) still toggles
3704
+ # correctly: the delivered click closes the overlay and the dismissal then
3705
+ # no-ops on it, rather than closing and reopening it. Only `:left`
3706
+ # dismisses; scroll and right clicks never do.
3707
+ #
3708
+ # **"Outside" spans the {#owner} chain, not just this rect.** A click
3709
+ # counts as inside this overlay when it lands in its rect *or* in any
3710
+ # overlay that belongs to it — so a dialog is not dismissed by a click on a
3711
+ # dropdown its own field opened, and a menu cascade is not dismissed by a
3712
+ # click on one of its deeper panels. Overlays with no owner relationship
3713
+ # are independent: clicking one dismisses the other, which is what a
3714
+ # window-like overlay should do. One that must survive unrelated clicks
3715
+ # entirely ({Component::Notification}) sets this false.
3716
+ #
3717
+ # Every dismissable overlay closes, not just the topmost, and stacking
3718
+ # order plays no part: a {Component::MenuBar} cascade must vanish whole on
3719
+ # one click on the background, not peel one panel per click.
3720
+ def close_on_outside_click?: () -> bool
3721
+
3722
+ # Reassigns the overlay's rect, escalating to a full scene repaint when an
3723
+ # open overlay shrinks or moves so its new rect no longer covers the cells
3724
+ # it previously painted. An overlay overdraws the scene without clipping
3725
+ # and nothing clears underneath it, so {Screen#repaint}'s overlay-only fast
3726
+ # path would repaint into the new rect and leave the vacated cells showing
3727
+ # stale content. When the new rect fully covers the old one (the overlay
3728
+ # only grew), the fast path is correct and the full repaint is skipped.
3729
+ #
3730
+ # _@param_ `new_rect`
3731
+ def rect=: (Rect new_rect) -> void
3732
+
3733
+ # Mounts this overlay on the {Screen}.
3734
+ #
3735
+ # overlay = Component::Overlay.new(content: label).open # construct and mount
3736
+ #
3737
+ # There is deliberately no class-level `Overlay.open` factory — see
3738
+ # `DECISIONS.md` `D_popup_open`; returning `self` is what keeps the
3739
+ # one-liner above available without one.
3740
+ def open: () -> self
3741
+
3742
+ # Removes this overlay from the {Screen}. No-op if not currently open.
3743
+ def close: () -> void
3744
+
3745
+ # _@return_ — true if this overlay is currently mounted on the screen.
3746
+ def open?: () -> bool
3747
+
3748
+ # Recomputes this overlay's own rect (not its content's layout). A no-op
3749
+ # here — the rect is whatever the caller assigned — and the hook a subclass
3750
+ # with a *derived* position overrides; see the class docs. Called on
3751
+ # {#open} and by the screen's layout pass, so an override tracks SIGWINCH.
3752
+ def reposition: () -> void
3753
+
3754
+ # Fires {#on_close}. A subclass overriding this **must** call `super`, or
3755
+ # the overlay's driver never hears that it closed.
3756
+ def on_detached: () -> void
3757
+
3758
+ # Content fills the overlay's full rect — an Overlay has no border to
3759
+ # subtract.
3760
+ #
3761
+ # _@param_ `content`
3762
+ def layout: (Component content) -> void
3763
+
3764
+ def on_focus: () -> void
3765
+
3766
+ # _@return_ — see {#close_on_outside_click?}.
3767
+ attr_writer close_on_outside_click: bool
3768
+
3769
+ # The component this overlay is *part of*, or `nil` (the default) when it
3770
+ # is an overlay in its own right. It exists for outside-click dismissal: a
3771
+ # click inside this overlay also counts as inside whatever overlay encloses
3772
+ # its owner, so the host is not dismissed by a click on a panel it put
3773
+ # there. See {#close_on_outside_click?}.
3774
+ #
3775
+ # Set it to the *driver* — {Component::ComboBox} hands its dropdown `self`
3776
+ # — rather than to the enclosing overlay: the driver knows what it is,
3777
+ # while the overlay above it is a tree relationship the pane resolves at
3778
+ # click time (so it cannot go stale). Any {Component} is accepted, and an
3779
+ # `Overlay` resolves to itself, which is how a
3780
+ # {Component::MenuBar::Cascade} chains each panel to the one it dropped out
3781
+ # of.
3782
+ attr_accessor owner: Component?
3783
+
3784
+ # A callback taking no arguments, fired once this overlay has left the
3785
+ # screen — **however it left**: {#close}, a direct {Screen#remove_popup},
3786
+ # an outside click, or teardown via {Screen#close}. That unconditionality
3787
+ # is the point, so it hangs off {#on_detached} rather than {#close}; a
3788
+ # driver keeping its own record of open overlays reconciles it here and
3789
+ # cannot drift ({Component::MenuBar::Cascade} is the worked example).
3790
+ #
3791
+ # It fires *after* the overlay is detached, so {#open?} is already false
3792
+ # and the usual {Component#on_detached} caveats apply: release state, don't
3793
+ # inspect the tree, keep it trivial (it may run while the pane is mid-way
3794
+ # through closing a batch of overlays, and a raise propagates).
3795
+ attr_accessor on_close: Proc?
3796
+ end
3797
+
3798
+ # A boolean input on one row. Space, Enter or a left click toggles it:
2973
3799
  #
2974
3800
  # [x] Enable syslog forwarding
2975
3801
  # [ ] Enable syslog forwarding
@@ -3051,7 +3877,7 @@ module Tuile
3051
3877
  # The extent ignores {Component#bg_color}: an inherited tint paints the dead
3052
3878
  # tail, but a hit test that silently widened with a background would be a
3053
3879
  # mode switch invisible in the code and untestable by inspection.
3054
- def extent: () -> Rect
3880
+ def extent: () -> Size
3055
3881
 
3056
3882
  # Toggles on Space or Enter. Every other key is left unhandled so it bubbles
3057
3883
  # to an ancestor.
@@ -3093,10 +3919,499 @@ module Tuile
3093
3919
  # a read-only display field could override back to `false`. Only
3094
3920
  # `focusable?` lives here — `tab_stop?` diverges between leaf fields and
3095
3921
  # composing wrappers, so it stays per-class (`DECISIONS.md`
3096
- # `D-integer-field`).
3922
+ # `D_integer_field`).
3097
3923
  def focusable?: () -> bool
3098
3924
  end
3099
3925
 
3926
+ # A one-row strip of menu captions, each dropping open a cascade of submenus
3927
+ # that nests as deep as you build it.
3928
+ #
3929
+ # ␣File␣␣Edit␣␣View␣ <- the strip; highlighted while focused
3930
+ # ␣New␣␣␣␣␣␣␣␣␣ <- the open menu, measured to its widest label
3931
+ # ␣Recent␣␣␣␣▸␣ <- a row that opens a submenu
3932
+ # ␣Quit␣␣␣␣␣␣␣␣ (the outer gutters are {List}'s)
3933
+ #
3934
+ # bar = Component::MenuBar.new
3935
+ # file = bar.add_item("File", mnemonic: "f")
3936
+ # file.add_item("New", mnemonic: "n") { new_document }
3937
+ # recent = file.add_item("Recent") # no block ⇒ a submenu holder
3938
+ # recent.add_item("notes.txt") { open("notes.txt") }
3939
+ # bar.add_item("Quit") { screen.close } # a top-level leaf: a button
3940
+ #
3941
+ # LEFT / RIGHT move along the strip; Enter, Space or Down opens the
3942
+ # highlighted menu. Inside a menu: Up / Down (and PgUp/PgDn, Ctrl+U/D) move
3943
+ # the highlight, Enter or Space activates a row or opens its submenu, RIGHT
3944
+ # opens a submenu, LEFT returns to the previous menu, ESC closes one level.
3945
+ # LEFT at the first level and RIGHT on a row with no submenu step to the
3946
+ # sibling menu, as they do in every menu bar. Book ch7 has the table.
3947
+ #
3948
+ # == Mnemonics
3949
+ # An item given a `mnemonic:` answers to that letter, underlined in its
3950
+ # caption wherever it occurs — on the strip and in the panels, focused or
3951
+ # not (there is no Alt key to reveal them with). Matching is **level-scoped
3952
+ # with no fallback**: the top-level items while the cascade is closed, the
3953
+ # deepest open panel's items while it is open, and nothing else is ever
3954
+ # consulted. So `f` then `q` walks File ▸ Quit as two ordinary keystrokes,
3955
+ # two items on *different* levels may share a letter with nothing to
3956
+ # arbitrate, and only siblings compete — a duplicate among them raises at
3957
+ # {#add_item}. A letter matching nothing on the live level is swallowed and
3958
+ # rings {Screen#beep}; it never falls out to a shallower level and switches
3959
+ # menus. A mnemonic shadows what the app (or an ancestor, including a
3960
+ # {Popup}'s `q`-to-close) would do with that key while the bar has focus.
3961
+ # A paste can never fire one — pasted text rides its own path off the key
3962
+ # ladder.
3963
+ #
3964
+ # {Item} handles are minted by {#add_item} and nest via the *same* method, so
3965
+ # depth is unlimited. There is no removal, no reordering and no dynamic
3966
+ # rebuilding: a menu is built once, at construction. See `DECISIONS.md`
3967
+ # `D_menu_bar`.
3968
+ #
3969
+ # == Sizing
3970
+ # Assign a {#rect} (typically one {Layout::Fixed}`[1]` row at the top of a
3971
+ # {Layout::Vertical}). One wider than {#extent}`.width` leaves a dead tail; a
3972
+ # narrower one **scrolls** to keep the highlighted segment whole, cueing the
3973
+ # hidden captions with a `<` or `>` over an edge column, exactly as {Tabs}
3974
+ # does — so a bar wider than its terminal stays wholly reachable by arrow,
3975
+ # mnemonic and click. Reassigning the rect
3976
+ # **closes** an open cascade: every panel position is derived from a segment
3977
+ # or a parent row, so after a resize they would all sit at stale columns, and
3978
+ # a resize with a menu open is rare enough that closing beats re-anchoring
3979
+ # every level.
3980
+ #
3981
+ # == Implementation details
3982
+ # Deliberately painted *unlike* {Tabs}, whose picture it would otherwise
3983
+ # share: no separator between segments, no bold, and no highlight at all
3984
+ # while unfocused — a menu bar has no persistent selection to show, and a
3985
+ # reader should not have to work out which of the two controls they are
3986
+ # looking at. Hit testing *is* {Tabs}': one private `segments` method feeds
3987
+ # both the paint and the click and both offset it by the same scroll column,
3988
+ # so a click cannot land on a caption other than the one drawn under it, and it is derived from the captions on each
3989
+ # call so a hit test is correct before the first paint.
3990
+ #
3991
+ # The open panels are overlays owned by a private {Cascade}, not children:
3992
+ # focus stays here for the whole interaction, so the strip receives every key
3993
+ # and forwards it. An open cascade swallows keys the cascade doesn't
3994
+ # recognize; a *closed* strip lets every printable bubble, so an app's
3995
+ # `s`-to-save keeps working while the bar has focus.
3996
+ #
3997
+ # A click outside an open cascade is not blocked — non-modal overlays block
3998
+ # nothing — but any click on a focusable component moves focus, and losing
3999
+ # focus closes the cascade.
4000
+ #
4001
+ # UI-thread-confined, like every component (see {Screen}).
4002
+ class MenuBar < Component
4003
+ def initialize: () -> void
4004
+
4005
+ # _@return_ — `true` — the strip takes focus, so its keys work.
4006
+ def focusable?: () -> bool
4007
+
4008
+ # _@return_ — `true` — one stop for the whole strip, as on {Tabs}.
4009
+ def tab_stop?: () -> bool
4010
+
4011
+ # _@return_ — the top-level items, in strip order. Read-only by
4012
+ # convention; grow it through {#add_item}.
4013
+ def items: () -> ::Array[Item]
4014
+
4015
+ # Appends a top-level item and returns its handle; nest submenus into it
4016
+ # with {Item#add_item}.
4017
+ #
4018
+ # _@param_ `caption` — parsed as {StyledString.parse} parses it.
4019
+ #
4020
+ # _@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.
4021
+ def add_item: (?(String | StyledString)? caption, ?mnemonic: String?) -> Item
4022
+
4023
+ # The cells the strip actually paints: one row, as wide as its segments
4024
+ # need, clipped to {#rect}.
4025
+ #
4026
+ # Both the highlight and the click hit test use it, so a click on the blank
4027
+ # tail — or on a lower row, when the rect is taller than one — opens
4028
+ # nothing. It still *focuses*: {Component#handle_mouse}'s click-to-focus is
4029
+ # ungated by geometry.
4030
+ def extent: () -> Size
4031
+
4032
+ # _@param_ `new_rect`
4033
+ def rect=: (Rect new_rect) -> void
4034
+
4035
+ # Closes the cascade when the strip leaves the focus chain, so tabbing (or
4036
+ # clicking) away doesn't strand an open menu.
4037
+ #
4038
+ # _@param_ `flag`
4039
+ def active=: (bool flag) -> void
4040
+
4041
+ # Closes the cascade, so a bar removed from the tree can't strand its
4042
+ # panels on the pane — they are the {ScreenPane}'s children, not the bar's,
4043
+ # so nothing else would take them down.
4044
+ def on_detached: () -> void
4045
+
4046
+ # Offers the key to the open cascade first, then to the strip's own
4047
+ # LEFT/RIGHT/Enter/Space/Down.
4048
+ #
4049
+ # With a cascade open, the only keys reaching the strip are the two the
4050
+ # cascade declines — LEFT at the first level, RIGHT on a row with no
4051
+ # submenu — and both step to the sibling menu.
4052
+ #
4053
+ # _@param_ `key`
4054
+ def handle_key: (String key) -> bool
4055
+
4056
+ # Opens the menu under a left click, or closes it when it is already the
4057
+ # open one; `super` runs first, so a click anywhere in {#rect} still
4058
+ # focuses.
4059
+ #
4060
+ # _@param_ `event`
4061
+ def handle_mouse: (MouseEvent event) -> void
4062
+
4063
+ def repaint: () -> void
4064
+
4065
+ # The sole writer of {#highlighted_index}: assigns, re-syncs the scroll
4066
+ # offset and repaints. Every path that moves the highlight — arrow,
4067
+ # mnemonic, click — goes through it, so the highlighted segment is on
4068
+ # screen *before* {Cascade} anchors a panel to it.
4069
+ #
4070
+ # _@param_ `index`
4071
+ def highlight=: (Integer index) -> void
4072
+
4073
+ # Re-syncs the scroll offset and repaints — what every change to the items
4074
+ # or the highlight ends in.
4075
+ def refresh: () -> void
4076
+
4077
+ # The rect's *width* is the only part of it the offset depends on, so this
4078
+ # hook is the whole geometry story; {Component#rect=} invalidates for us,
4079
+ # and {#rect=} closes the cascade rather than re-anchoring it.
4080
+ def on_width_changed: () -> void
4081
+
4082
+ # Scrolls the minimum needed to show the highlighted segment whole, and is
4083
+ # the sole writer of {#left_column}. Idempotent, so every mutation site can
4084
+ # call it blindly; it returns the offset to `0` on its own once the strip
4085
+ # fits again, which is why no mutator owes a scroll-back branch.
4086
+ #
4087
+ # A segment wider than the whole rect cannot be shown whole: its head wins,
4088
+ # being the half of a caption that identifies it.
4089
+ def adjust_left_column: () -> void
4090
+
4091
+ # {StyledString#slice} *drops* a cluster straddling the window's edge
4092
+ # rather than half-painting it, which would leave the painted row a column
4093
+ # short and shift everything past the hole one column left — paint and hit
4094
+ # test would then disagree, silently and only for wide glyphs. So the
4095
+ # offset only ever lands on a cluster boundary. Snapping *forward* is the
4096
+ # safe direction: it gives up at most one column of the segment to the left
4097
+ # of the window, never of the one being revealed.
4098
+ #
4099
+ # _@param_ `column`
4100
+ #
4101
+ # _@return_ — the smallest cluster-boundary column `>= column`.
4102
+ def snap_to_glyph_start: (Integer column) -> Integer
4103
+
4104
+ # Paints the overflow cues over the windowed row's edge columns: `<` when
4105
+ # segments sit to the left of the window, `>` when more sit to the right.
4106
+ # ASCII by convention rather than by constant, as {Checkbox}'s brackets
4107
+ # are, and *overlaid* rather than given reserved columns — reserving would
4108
+ # make the window width a function of the offset computed from it. Painted
4109
+ # focused or not: overflow is a fact about the captions and the rect, not
4110
+ # about focus.
4111
+ #
4112
+ # _@param_ `row` — the windowed row, as painted.
4113
+ def draw_cues: (StyledString row) -> void
4114
+
4115
+ # The cue keeps the style of the cell it covers, so one landing on the
4116
+ # highlighted segment doesn't punch a default-background hole in its
4117
+ # highlight.
4118
+ #
4119
+ # _@param_ `row` — the windowed row.
4120
+ #
4121
+ # _@param_ `column` — relative to {#rect}`.left`.
4122
+ #
4123
+ # _@param_ `glyph`
4124
+ def draw_cue: (StyledString row, Integer column, String glyph) -> void
4125
+
4126
+ # One `[item, start_column, width]` triple per top-level item, in strip
4127
+ # order, in columns relative to {#rect}`.left`. A segment is its caption
4128
+ # between two padding columns, and neighbours abut — the two blank columns
4129
+ # between captions are the segments' own padding, so a click on either
4130
+ # opens the menu it belongs to.
4131
+ def segments: () -> ::Array[[Item, Integer, Integer]]
4132
+
4133
+ # _@return_ — columns the strip would paint given an unlimited rect.
4134
+ def painted_width: () -> Integer
4135
+
4136
+ # _@param_ `point`
4137
+ #
4138
+ # _@return_ — the index of the item painted at `point`; `nil`
4139
+ # for the blank tail or a row the strip doesn't paint.
4140
+ def index_at: (Point point) -> Integer?
4141
+
4142
+ # _@param_ `index`
4143
+ #
4144
+ # _@return_ — the segment's cells on screen — the cascade's anchor.
4145
+ def segment_rect: (Integer index) -> Rect
4146
+
4147
+ # _@return_ — the whole strip as one row, unclipped. {#repaint}
4148
+ # windows it to the rect; nothing else may, since the window's own
4149
+ # arithmetic is {#adjust_left_column}'s.
4150
+ def strip_row: () -> StyledString
4151
+
4152
+ # _@param_ `item`
4153
+ #
4154
+ # _@param_ `index`
4155
+ #
4156
+ # _@return_ — the caption between its padding columns,
4157
+ # highlighted when it is the one Enter would open *and* the strip has
4158
+ # focus. An unfocused strip shows no highlight at all: there is no
4159
+ # persistent selection to report.
4160
+ def segment_text: (Item item, Integer index) -> StyledString
4161
+
4162
+ # Activates the item bound to `key` on the *live* level — the deepest open
4163
+ # panel while the cascade is open, the top-level strip while it is closed.
4164
+ # No fallback between the two: a letter matching nothing in the live set is
4165
+ # not offered to any other level.
4166
+ #
4167
+ # _@param_ `key`
4168
+ #
4169
+ # _@return_ — whether a mnemonic claimed the key.
4170
+ def handle_mnemonic: (String key) -> bool
4171
+
4172
+ # Moves the highlight along the strip, clamping at both ends. Consumes the
4173
+ # key even at an end, as {Tabs} does.
4174
+ #
4175
+ # _@param_ `delta` — `+1` / `-1`.
4176
+ #
4177
+ # _@return_ — `false` only when there are no items.
4178
+ def move_highlight: (Integer delta) -> bool
4179
+
4180
+ # Steps to the neighbouring menu, showing *its* menu instead — or closing
4181
+ # the cascade, when the neighbour is a top-level button with no menu to
4182
+ # show. The cascade is left alone when the highlight is already at an end:
4183
+ # reopening the same menu would throw away the submenu the user is standing
4184
+ # in.
4185
+ #
4186
+ # It deliberately never *activates*. An item arrowed past is highlighted,
4187
+ # not pressed, so a top-level button waits for Enter or Space — otherwise
4188
+ # walking the strip would fire every button on it.
4189
+ #
4190
+ # _@param_ `delta` — `+1` / `-1`.
4191
+ #
4192
+ # _@return_ — always `true`: an open menu swallows the key either way.
4193
+ def step_menu: (Integer delta) -> bool
4194
+
4195
+ # Opens the highlighted item's menu, or fires it when it is a top-level
4196
+ # button — the Enter/Space/Down/click path, and the only one that fires a
4197
+ # listener.
4198
+ #
4199
+ # _@return_ — `false` only when there are no items.
4200
+ def open_highlighted: () -> bool
4201
+
4202
+ # Shows the highlighted item's menu, closing the cascade when it has none.
4203
+ def show_highlighted_menu: () -> void
4204
+
4205
+ # _@return_ — which top-level item the strip highlights while
4206
+ # focused, and which menu Enter opens. `0` until the user moves.
4207
+ attr_reader highlighted_index: Integer
4208
+
4209
+ # _@return_ — the strip column painted in {#rect}'s leftmost cell —
4210
+ # the horizontal scroll offset. `0` unless the strip overflows its rect;
4211
+ # {#adjust_left_column} is its sole writer.
4212
+ attr_reader left_column: Integer
4213
+
4214
+ # One menu item: a caption, an optional click listener, and its children.
4215
+ #
4216
+ # file = bar.add_item("File") # minted by the bar
4217
+ # file.add_item("New") { create } # …and nested by the same method
4218
+ # file.items.size # => 1
4219
+ #
4220
+ # An item with children is a submenu and its own listener is dead
4221
+ # ({#submenu?} decides). An item with **neither** children nor a listener is
4222
+ # legal and inert: it highlights, Enter closes the menu, nothing happens —
4223
+ # an item that looks live but does nothing is the app's error to fix, not
4224
+ # the framework's to raise on.
4225
+ #
4226
+ # Apps don't construct items; {MenuBar#add_item} and {#add_item} do.
4227
+ class Item
4228
+ # _@param_ `caption` — already coerced by the caller.
4229
+ #
4230
+ # _@param_ `mnemonic` — already validated by the caller, in the case it was given in.
4231
+ #
4232
+ # _@param_ `on_click`
4233
+ def initialize: (StyledString caption, String? mnemonic, (Proc | Method)? on_click) -> void
4234
+
4235
+ # _@return_ — whether this item opens a submenu, i.e. has children.
4236
+ def submenu?: () -> bool
4237
+
4238
+ # Appends a child and returns its handle.
4239
+ #
4240
+ # _@param_ `caption` — parsed as {StyledString.parse} parses it.
4241
+ #
4242
+ # _@param_ `mnemonic` — the letter that activates this child while *this* item's children are the live level; see {MenuBar#add_item}.
4243
+ def add_item: (?(String | StyledString)? caption, ?mnemonic: String?) -> Item
4244
+
4245
+ def inspect: () -> String
4246
+
4247
+ # Rejects a mnemonic that couldn't work, or that would make two siblings
4248
+ # ambiguous — all three at *registration*, since none has a sane answer
4249
+ # at keypress time.
4250
+ #
4251
+ # _@param_ `mnemonic`
4252
+ def validate_mnemonic: (String? mnemonic) -> void
4253
+
4254
+ # {StyledString#slice} counts **columns** while a caption search yields a
4255
+ # **character** index, so the prefix is measured, never counted.
4256
+ #
4257
+ # _@param_ `caption`
4258
+ #
4259
+ # _@param_ `mnemonic` — in the case it was given in.
4260
+ def build_cued_caption: (StyledString caption, String? mnemonic) -> StyledString
4261
+
4262
+ # _@return_ — the label painted on the strip or the row.
4263
+ attr_reader caption: StyledString
4264
+
4265
+ # _@return_ — the downcased letter that activates this item
4266
+ # while its own level is the live one; `nil` when it has none.
4267
+ attr_reader mnemonic: String?
4268
+
4269
+ # _@return_ — {#caption} with the {#mnemonic} underlined —
4270
+ # what both paint sites draw. Equal to {#caption} when there is no
4271
+ # mnemonic or the caption doesn't contain it. Computed once, at
4272
+ # construction: caption and mnemonic are both fixed there, and
4273
+ # underline is a plain attribute with no theme or `bg_color` input, so
4274
+ # this is not a cached theme value.
4275
+ attr_reader cued_caption: StyledString
4276
+
4277
+ # _@return_ — this item's children, in menu order. Read-only by
4278
+ # convention, like {Component#children} — grow it through {#add_item}.
4279
+ attr_reader items: ::Array[Item]
4280
+
4281
+ # _@return_ — no-arg callable fired when the item is
4282
+ # activated (Enter, Space or a left click), exactly as
4283
+ # {Button#on_click}. Never fired on an item with children.
4284
+ attr_accessor on_click: (Proc | Method)?
4285
+ end
4286
+
4287
+ # The stack of open menu panels — one {ListDropdown} per level, the last
4288
+ # deepest — and the drill/pop/activate logic driving them. Private
4289
+ # machinery of {MenuBar}; an app never names it.
4290
+ #
4291
+ # cascade.open_below(segment_rect, item) # Enter/Down on the strip
4292
+ # return true if cascade.handle_key(key) # MenuBar#handle_key, first
4293
+ # cascade.close # focus lost, or rect changed
4294
+ #
4295
+ # A panel is a **non-modal overlay, not a child**, so it never takes focus:
4296
+ # focus stays on the {MenuBar} for the whole interaction and every key
4297
+ # arrives via {MenuBar#handle_key}, which offers it here first. That is
4298
+ # {Component::Select}'s architecture extended to N levels, and it is why
4299
+ # nothing in the key-dispatch ladder changes.
4300
+ #
4301
+ # Widths are measured here, per level — the panel is as wide as the level's
4302
+ # widest label — because {ListDropdown} deliberately measures nothing
4303
+ # itself (`DECISIONS.md` `D_select`).
4304
+ #
4305
+ # == Implementation details
4306
+ # While open it consumes **everything** except the two keys that mean
4307
+ # "leave this menu sideways", which only the strip can answer: LEFT at
4308
+ # depth 1, and RIGHT on a row with no submenu. An open menu is quasi-modal
4309
+ # — firing an app's `s`-to-save behind a visible panel is worse than a dead
4310
+ # keystroke.
4311
+ #
4312
+ # UI-thread-confined, like everything in the tree (see {Screen}).
4313
+ class Cascade
4314
+ SUBMENU_ARROW: String
4315
+ ARROW_WIDTH: Integer
4316
+
4317
+ def initialize: () -> void
4318
+
4319
+ # _@return_ — whether any panel is open.
4320
+ def open?: () -> bool
4321
+
4322
+ # _@return_ — how many panels are open; `0` when closed.
4323
+ def depth: () -> Integer
4324
+
4325
+ # Opens `item`'s children directly beneath `anchor`, closing anything
4326
+ # already open first.
4327
+ #
4328
+ # _@param_ `anchor` — the strip segment the menu drops from.
4329
+ #
4330
+ # _@param_ `item` — a childless one opens nothing.
4331
+ def open_below: (Rect anchor, Item item) -> void
4332
+
4333
+ # Closes every open panel, deepest first.
4334
+ def close: () -> void
4335
+
4336
+ # Offers a key to the deepest panel and to the cascade's own verbs.
4337
+ #
4338
+ # _@param_ `key`
4339
+ #
4340
+ # _@return_ — `true` when consumed — almost always, while open.
4341
+ # `false` when closed, and for the two sideways keys {MenuBar} answers
4342
+ # (see the class docs).
4343
+ def handle_key: (String key) -> bool
4344
+
4345
+ # Activates the deepest level's item bound to `key` — the drill-or-fire
4346
+ # the mnemonic shares with Enter. The highlight moves there *first*, so a
4347
+ # submenu anchors beside the row that opened it rather than beside
4348
+ # wherever the cursor happened to be.
4349
+ #
4350
+ # _@param_ `key` — a single printable, already downcased.
4351
+ #
4352
+ # _@return_ — whether an item on the deepest level claimed it. A
4353
+ # miss is never offered to a shallower level.
4354
+ def handle_mnemonic: (String key) -> bool
4355
+
4356
+ # _@return_ — the deepest open panel.
4357
+ def deepest: () -> ListDropdown
4358
+
4359
+ def activate_highlighted: () -> void
4360
+
4361
+ # Drills into `item`, or fires it and closes the cascade.
4362
+ #
4363
+ # _@param_ `level` — the panel the item belongs to.
4364
+ #
4365
+ # _@param_ `item` — `nil` (an off-content cursor) does nothing.
4366
+ def activate: (Integer level, Item? item) -> void
4367
+
4368
+ # Opens `item`'s children beside the row highlighted in `level`.
4369
+ #
4370
+ # _@param_ `level`
4371
+ #
4372
+ # _@param_ `item`
4373
+ def push_beside: (Integer level, Item item) -> void
4374
+
4375
+ # Mounts a panel for `item`'s children and yields it for geometry.
4376
+ #
4377
+ # _@param_ `item`
4378
+ def push: (Item item) ?{ (ListDropdown drop, Integer rows, Integer width) -> void } -> void
4379
+
4380
+ # Closes the deepest panel; at depth 1 that closes the cascade.
4381
+ def pop: () -> void
4382
+
4383
+ # _@param_ `count` — how many panels to keep.
4384
+ def truncate: (Integer count) -> void
4385
+
4386
+ # _@param_ `level`
4387
+ #
4388
+ # _@return_ — the item under `level`'s cursor; `nil` when it sits
4389
+ # off-content. The range guard matters: a cursor at `-1` would
4390
+ # otherwise index the *last* child.
4391
+ def highlighted: (Integer level) -> Item?
4392
+
4393
+ # _@param_ `items`
4394
+ #
4395
+ # _@return_ — item -> row: the label padded to the level's widest, plus
4396
+ # an arrow column when any sibling has a submenu — so every arrow lands
4397
+ # in the same column without asking the {List} how wide it ended up.
4398
+ def renderer_for: (::Array[Item] items) -> Proc
4399
+
4400
+ # _@param_ `items`
4401
+ #
4402
+ # _@return_ — the panel width: the rendered row plus {List}'s two
4403
+ # row gutters, plus a scrollbar column when the rows can't all be shown.
4404
+ # As in {Component::Select}, the scrollbar is predicted from the item
4405
+ # count rather than the final height — a panel the screen clamps
4406
+ # shorter than {ListDropdown::MAX_VISIBLE_ROWS} scrolls without having
4407
+ # bought that column, and ellipsizes one character early.
4408
+ def width_for: (::Array[Item] items) -> Integer
4409
+
4410
+ # _@param_ `items`
4411
+ def label_width_of: (::Array[Item] items) -> Integer
4412
+ end
4413
+ end
4414
+
3100
4415
  # A text field with a filtering dropdown: type to narrow the candidates,
3101
4416
  # arrow to move the highlight, Enter (or click) to accept. Its {#value} is
3102
4417
  # the *selected item* — of whatever type the items are — not the display
@@ -3109,7 +4424,7 @@ module Tuile
3109
4424
  # combo.value = some_user # selects it; field shows its label
3110
4425
  #
3111
4426
  # It's the assembly you'd otherwise wire by hand — a {TextField} plus a
3112
- # non-modal {Popup} over a {List} — promoted to one component. Give it a
4427
+ # an {Overlay} over a {List} — promoted to one component. Give it a
3113
4428
  # single-row {#rect}; it paints the field across that row with a `▾` in the
3114
4429
  # last column and floats the dropdown above or below.
3115
4430
  #
@@ -3144,8 +4459,6 @@ module Tuile
3144
4459
  # hardware cursor to its field).
3145
4460
  def cursor_position: () -> Point?
3146
4461
 
3147
- def keyboard_hint: () -> String
3148
-
3149
4462
  # Re-anchors the (open) dropdown after {HasContent#rect=} has resized the
3150
4463
  # field via {#layout}.
3151
4464
  #
@@ -3166,6 +4479,12 @@ module Tuile
3166
4479
 
3167
4480
  def repaint: () -> void
3168
4481
 
4482
+ # The one row this combo paints — the full width, at the top of {#rect}.
4483
+ # A single-slot container hands its content the whole inner rect, so a
4484
+ # ComboBox is routinely assigned more height than it uses; the dropdown
4485
+ # hangs under this rather than under the unused space below it.
4486
+ def extent: () -> Size
4487
+
3169
4488
  # Field spans the row bar the last column, which the `▾` occupies
3170
4489
  # ({HasContent} layout hook). One row, or none at all when the combo itself
3171
4490
  # was given none — a starved parent must not hand out a rect it doesn't own.
@@ -3248,7 +4567,7 @@ module Tuile
3248
4567
  # a read-only display field could override back to `false`. Only
3249
4568
  # `focusable?` lives here — `tab_stop?` diverges between leaf fields and
3250
4569
  # composing wrappers, so it stays per-class (`DECISIONS.md`
3251
- # `D-integer-field`).
4570
+ # `D_integer_field`).
3252
4571
  def focusable?: () -> bool
3253
4572
 
3254
4573
  def on_focus: () -> void
@@ -3304,7 +4623,7 @@ module Tuile
3304
4623
  # a read-only display field could override back to `false`. Only
3305
4624
  # `focusable?` lives here — `tab_stop?` diverges between leaf fields and
3306
4625
  # composing wrappers, so it stays per-class (`DECISIONS.md`
3307
- # `D-integer-field`).
4626
+ # `D_integer_field`).
3308
4627
  def focusable?: () -> bool
3309
4628
 
3310
4629
  # _@return_ — one-arg callable fired with the new value
@@ -3312,6 +4631,158 @@ module Tuile
3312
4631
  attr_accessor on_value_change: (Proc | Method)?
3313
4632
  end
3314
4633
 
4634
+ # A {Component::Tabs} strip on its top row plus the pane belonging to the
4635
+ # selected tab underneath it:
4636
+ #
4637
+ # ␣Details␣│␣Payment␣│␣Shipping␣
4638
+ # the selected tab's pane fills the rest of the rect
4639
+ #
4640
+ # sheet = Component::TabSheet.new
4641
+ # sheet.add_tab("Details", details_form) # selected, and shown
4642
+ # sheet.add_tab("Payment", payment_form)
4643
+ # sheet.select_next # shows payment_form
4644
+ # sheet.on_tab_selected = ->(index, tab) { log("now on #{tab&.caption}") }
4645
+ #
4646
+ # Tab lands on the strip first and enters the pane on the next press, which
4647
+ # is the browser's order; switching tabs does *not* move focus into the new
4648
+ # pane, but if focus was inside the pane that just went away it lands back
4649
+ # on the strip.
4650
+ #
4651
+ # == Hidden panes are detached
4652
+ # Only the selected tab's pane is in the component tree — the others are
4653
+ # detached, which is how Tuile hides a component (there is no visibility
4654
+ # flag, and an empty rect gates painting only). Consequences worth
4655
+ # designing around:
4656
+ #
4657
+ # - A hidden pane is invisible to *everything*: the Tab cycle, focus
4658
+ # cascades, repaint, the cursor, `on_tree` walks. No gates anywhere.
4659
+ # - Its state survives, because state is ivars — scroll position, caret,
4660
+ # list cursor, text are all exactly as the user left them, and mutating a
4661
+ # hidden pane is safe (`invalidate` while detached is a silent no-op).
4662
+ # - {Component#on_detached} / {Component#on_attached} fire on every switch,
4663
+ # so a pane holding a mounted-lifetime resource — a {Component::ProgressBar}'s
4664
+ # ticker — releases it while hidden and re-acquires it on return. A pane
4665
+ # that must keep something alive while hidden can't; that something
4666
+ # belongs in the model the pane renders, not in the pane.
4667
+ #
4668
+ # == Implementation details
4669
+ # `children` is `[strip, pane]`, the strip pinned at index 0, so pre-order
4670
+ # traversal gives the strip-then-pane Tab order for free. The swap follows
4671
+ # the slot-swap recipe {Component#detach_child} documents — detach, rewire,
4672
+ # `on_child_removed` last, so the focus repair sees the new occupant.
4673
+ #
4674
+ # Panes live in an identity-keyed `Tab => Component` map here rather than in
4675
+ # a slot on {Tabs::Tab}: the strip's tab array stays the sole ordering
4676
+ # authority, and the strip itself stays ignorant of panes. One idempotent
4677
+ # `sync_pane` is the sole writer of the visible pane, deriving it from
4678
+ # `strip.selected` on every call, so registering a pane and selecting a tab
4679
+ # can happen in either order.
4680
+ #
4681
+ # The sheet owns the strip's `on_tab_selected` (that is what drives the
4682
+ # swap); an app's listener goes on {#on_tab_selected} here, which fires
4683
+ # after the pane has been swapped in.
4684
+ class TabSheet < Component
4685
+ # _@param_ `separator` — the strip's separator; see {Tabs#separator=}.
4686
+ def initialize: (?separator: (String | StyledString)) -> void
4687
+
4688
+ # Adds a tab and the pane to show while it is selected. The first tab
4689
+ # added becomes the selection, so its pane is shown immediately.
4690
+ #
4691
+ # _@param_ `caption` — parsed as {Tabs::Tab#caption=} parses it.
4692
+ #
4693
+ # _@param_ `pane` — shown while this tab is selected, detached while it isn't.
4694
+ #
4695
+ # _@return_ — the new tab's handle.
4696
+ def add_tab: ((String | StyledString)? caption, Component pane) -> Tabs::Tab
4697
+
4698
+ # Removes a tab and forgets its pane, detaching it if it was the visible
4699
+ # one. The strip re-selects as {Tabs#remove_tab} describes, and this
4700
+ # sheet shows whatever it lands on.
4701
+ #
4702
+ # _@param_ `tab` — one of this sheet's tabs.
4703
+ #
4704
+ # _@return_ — the pane that tab owned.
4705
+ def remove_tab: (Tabs::Tab tab) -> Component?
4706
+
4707
+ # _@param_ `tab`
4708
+ #
4709
+ # _@return_ — the pane registered for `tab`; `nil` for a
4710
+ # removed tab, a tab of another sheet, or `nil`.
4711
+ def pane_for: (Tabs::Tab? tab) -> Component?
4712
+
4713
+ # _@return_ — the strip's tabs, in order.
4714
+ def tabs: () -> ::Array[Tabs::Tab]
4715
+
4716
+ # _@return_ — the selected tab.
4717
+ def selected: () -> Tabs::Tab?
4718
+
4719
+ # _@param_ `tab` — one of this sheet's tabs.
4720
+ def selected=: (Tabs::Tab tab) -> void
4721
+
4722
+ # _@return_ — the selected tab's position.
4723
+ def selected_index: () -> Integer?
4724
+
4725
+ # _@param_ `index` — a position in `0...tabs.size`.
4726
+ def selected_index=: (Integer index) -> void
4727
+
4728
+ # Selects the next tab, clamping at the last one.
4729
+ #
4730
+ # _@return_ — `false` only when there are no tabs.
4731
+ def select_next: () -> bool
4732
+
4733
+ # Selects the previous tab, clamping at the first one.
4734
+ #
4735
+ # _@return_ — `false` only when there are no tabs.
4736
+ def select_previous: () -> bool
4737
+
4738
+ # _@param_ `new_rect`
4739
+ def rect=: (Rect new_rect) -> void
4740
+
4741
+ # Sends focus to the strip: a sheet is a container, and the strip is where
4742
+ # a tab switch is driven from. The pane is a Tab press away.
4743
+ def on_focus: () -> void
4744
+
4745
+ # Lands focus on the strip rather than on `self` when the focused pane is
4746
+ # swapped out — a bare container can't use keys, and the user's last
4747
+ # action was a tab switch.
4748
+ #
4749
+ # _@param_ `child`
4750
+ def on_child_removed: (Component child) -> void
4751
+
4752
+ # Makes the visible pane match `strip.selected`, swapping if it doesn't.
4753
+ # Idempotent and the sole writer of `@pane`: it derives everything from
4754
+ # current state, so {#add_tab} can register a pane after the strip has
4755
+ # already selected its tab.
4756
+ def sync_pane: () -> void
4757
+
4758
+ # Drops entries whose tab is gone. {Tabs::Tab#remove} takes a tab off the
4759
+ # strip without passing through {#remove_tab}, and a detached tab can never
4760
+ # be selected again, so its entry is dead weight — it pins the pane against
4761
+ # garbage collection and makes {#add_tab} reject that pane as still in use.
4762
+ # Idempotent and the only cleaner, because the rule it enforces is an
4763
+ # invariant ("every key is a live tab of my strip") rather than a step in
4764
+ # one code path.
4765
+ def forget_removed_tabs: () -> void
4766
+
4767
+ def layout_pane: () -> void
4768
+
4769
+ # An app's own selection listener, called after the pane has been swapped
4770
+ # in — `(index, tab)`, or `(nil, nil)` once the last tab is gone. Same
4771
+ # contract as {Tabs#on_tab_selected}: it reports that the selection
4772
+ # changed, whatever changed it.
4773
+ attr_accessor on_tab_selected: Proc?
4774
+
4775
+ # _@return_ — the strip. Reach through it for the rest of its API —
4776
+ # `sheet.strip.separator = "|"` — but leave its `on_tab_selected` alone:
4777
+ # the sheet drives the pane swap through it, and {#on_tab_selected} is
4778
+ # where an app's listener goes.
4779
+ attr_reader strip: Tabs
4780
+
4781
+ # _@return_ — the pane currently in the tree — the selected
4782
+ # tab's, `nil` while the sheet has no tabs.
4783
+ attr_reader pane: Component?
4784
+ end
4785
+
3315
4786
  # A multi-line, word-wrapping text input.
3316
4787
  #
3317
4788
  # Sized by the caller — {#rect} is fixed; the area does not grow with
@@ -3327,10 +4798,11 @@ module Tuile
3327
4798
  # start of the next row in nearly all cases).
3328
4799
  #
3329
4800
  # 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.
4801
+ # {#on_change} is wired. {Keys::CTRL_J} does the same, since that is the
4802
+ # byte a terminal sends for a typed Ctrl+J. A *pasted* line break arrives
4803
+ # through {AbstractStringField#handle_paste} instead and never as a key at
4804
+ # all — so a subclass rebinding Enter to submit keeps working under a
4805
+ # multi-line paste, which lands as one draft.
3334
4806
  #
3335
4807
  # Up/Down move the caret between rows and, at the first/last row, snap to
3336
4808
  # the start/end of the text. A subclass can claim the key at that edge
@@ -3756,8 +5228,10 @@ module Tuile
3756
5228
 
3757
5229
  # Scrolls up half a viewport (`rect.height / 2`, at least one row),
3758
5230
  # 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.
5231
+ # What `Ctrl+U` does, minus the focus: dispatch delivers keys only along
5232
+ # the focus chain, so this is what a host with focus elsewhere — a chat
5233
+ # transcript under an input field — calls instead of forwarding a
5234
+ # synthetic keystroke that would lie about where focus is.
3761
5235
  def scroll_half_page_up: () -> void
3762
5236
 
3763
5237
  # The `Ctrl+D` twin of {#scroll_half_page_up}, clamped at the last row —
@@ -3768,6 +5242,11 @@ module Tuile
3768
5242
 
3769
5243
  def tab_stop?: () -> bool
3770
5244
 
5245
+ # Claims the scroll ladder: the arrows and `j`/`k`, PageUp/PageDown,
5246
+ # `Ctrl+U`/`Ctrl+D`, Home/`g`/End/`G`. Acts on the key alone — a clamped
5247
+ # scroll at either edge is still a handled key, and hand-feeding a key to
5248
+ # an unfocused view scrolls it (dispatch gates on focus, this doesn't).
5249
+ #
3771
5250
  # _@param_ `key`
3772
5251
  def handle_key: (String key) -> bool
3773
5252
 
@@ -4132,42 +5611,23 @@ module Tuile
4132
5611
  end
4133
5612
  end
4134
5613
 
4135
- # Shows a log. Construct your logger pointed at a {LogWindow::IO} to route
4136
- # log lines into this window:
5614
+ # A {Window} framing a {LogTextView} — the framed log pane. All the log
5615
+ # behavior lives on the view (see {LogTextView} for the adapter and the
5616
+ # wrap rationale); the window adds the border, caption and delegation:
4137
5617
  #
4138
5618
  # log_window = Tuile::Component::LogWindow.new
4139
5619
  # logger = Logger.new(Tuile::Component::LogWindow::IO.new(log_window))
4140
- #
4141
- # Any logger that writes formatted lines to an IO works the same way —
4142
- # for example `TTY::Logger` configured with the `:console` handler and
4143
- # `output: LogWindow::IO.new(window)`.
4144
5620
  class LogWindow < Tuile::Component::Window
5621
+ IO: Class
5622
+
4145
5623
  # _@param_ `caption`
4146
5624
  def initialize: (?String caption) -> void
4147
5625
 
4148
- # Appends given line to the log. Can be called from any thread. Does nothing if nil is passed in.
5626
+ # Appends the given line to the log, from any thread — delegates to
5627
+ # {LogTextView#log}.
4149
5628
  #
4150
- # _@param_ `string` — the line (or multiple lines) to log.
5629
+ # _@param_ `string` — the line (or multiple lines) to log; `nil` is a no-op.
4151
5630
  def log: (String? string) -> void
4152
-
4153
- # IO-shaped adapter that forwards each log line to the owning {LogWindow}.
4154
- # Implements both {#write} (stdlib `Logger`) and {#puts} (loggers that
4155
- # call `output.puts`, e.g. `TTY::Logger`).
4156
- class IO
4157
- # _@param_ `window`
4158
- def initialize: (LogWindow window) -> void
4159
-
4160
- # _@param_ `string`
4161
- def write: (String string) -> void
4162
-
4163
- # _@param_ `string`
4164
- def puts: (String string) -> void
4165
-
4166
- # Stdlib `Logger` only treats an object as an IO target when it
4167
- # responds to both {#write} and {#close}; otherwise it tries to
4168
- # interpret it as a filename. This is a no-op.
4169
- def close: () -> void
4170
- end
4171
5631
  end
4172
5632
 
4173
5633
  # A single-line text input with a real hardware caret, scrolling
@@ -4188,8 +5648,8 @@ module Tuile
4188
5648
  #
4189
5649
  # - an **index** counts characters into {#text} — {#caret},
4190
5650
  # {#max_text_length}, `text[i]`, every edit;
4191
- # - a **column** counts terminal cells — {#rect}, {#left_column},
4192
- # {#cursor_position}, a {MouseEvent}.
5651
+ # - a **column** counts terminal cells — {#rect}, {#cursor_position}, a
5652
+ # {MouseEvent}, and the private horizontal scroll offset `left_column`.
4193
5653
  #
4194
5654
  # They coincide only while every glyph is one column wide. A fullwidth CJK
4195
5655
  # char is two columns and a combining mark zero, so index 3 of `"日本語"` is
@@ -4223,6 +5683,14 @@ module Tuile
4223
5683
  # _@param_ `key`
4224
5684
  def handle_text_input_key: (String key) -> bool
4225
5685
 
5686
+ # Flattens the paste onto the field's one row — newlines become spaces —
5687
+ # and trims it to what {#max_text_length} still allows. Trimming rather
5688
+ # than rejecting: a paste that overshoots the cap fills the field, which
5689
+ # is what typing the same characters would have done.
5690
+ #
5691
+ # _@param_ `text`
5692
+ def preprocess_paste: (String text) -> String
5693
+
4226
5694
  def on_text_mutated: () -> void
4227
5695
 
4228
5696
  def on_caret_mutated: () -> void
@@ -4290,10 +5758,6 @@ module Tuile
4290
5758
  # _@return_ — maximum characters, or nil for unbounded (default).
4291
5759
  attr_accessor max_text_length: Integer?
4292
5760
 
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
5761
  # Optional callback fired when the UP arrow key is pressed. When set, UP
4298
5762
  # is consumed by the field; when nil, UP falls through to the parent
4299
5763
  # (default behavior). Only triggered by {Keys::UP_ARROW}, not by `k`,
@@ -4316,6 +5780,15 @@ module Tuile
4316
5780
  #
4317
5781
  # _@return_ — no-arg callable, or nil.
4318
5782
  attr_accessor on_enter: (Proc | Method)?
5783
+
5784
+ # Internal — the field's own scroll state, with no caller outside this
5785
+ # class: the paint, the cursor and the hit test all read the ivar, and
5786
+ # nothing above the field has a column to spend it on. Specs assert the
5787
+ # scrolling through `send`.
5788
+ #
5789
+ # _@return_ — text column drawn in the field's leftmost cell — the
5790
+ # horizontal scroll offset. Follows {#caret}, always on a glyph boundary.
5791
+ attr_reader left_column: Integer
4319
5792
  end
4320
5793
 
4321
5794
  # A single-line field whose {#value} is a `Float` (or `nil` when empty) —
@@ -4425,12 +5898,9 @@ module Tuile
4425
5898
  # Input fields are focusable by default (overrides {Component#focusable?});
4426
5899
  # a read-only display field could override back to `false`. Only
4427
5900
  # `focusable?` lives here — `tab_stop?` diverges between leaf fields and
4428
- # composing wrappers, so it stays per-class (`DECISIONS.md`
4429
- # `D-integer-field`).
4430
- def focusable?: () -> bool
4431
-
4432
- # _@param_ `event`
4433
- def handle_mouse: (MouseEvent event) -> void
5901
+ # composing wrappers, so it stays per-class (`DECISIONS.md`
5902
+ # `D_integer_field`).
5903
+ def focusable?: () -> bool
4434
5904
 
4435
5905
  # _@param_ `rect`
4436
5906
  def rect=: (Rect rect) -> void
@@ -4472,13 +5942,29 @@ module Tuile
4472
5942
  def caption=: ((String | StyledString)? new_caption) -> void
4473
5943
  end
4474
5944
 
4475
- # A mixin interface for a component with one child tops. The host must
4476
- # provide a protected `layout(content)` method which repositions the
4477
- # content component; the mixin manages `@content` itself.
5945
+ # A component that owns exactly one child *directly*, under the name
5946
+ # `content`. The includer initializes `@content` to nil and provides a
5947
+ # protected `layout(content)` positioning the child; the mixin owns the swap:
5948
+ #
5949
+ # class Slot < Component
5950
+ # include Component::HasContent
5951
+ #
5952
+ # def initialize = (super; @content = nil)
5953
+ #
5954
+ # protected
5955
+ #
5956
+ # def layout(content) = content.rect = rect
5957
+ # end
5958
+ #
5959
+ # Include it when the child is **permanent and integral** — a typed field's
5960
+ # inner {TextField}, an {Overlay}'s body. It does *not* mean "a component
5961
+ # with one child": an includer may hold others alongside, as {Window} does
5962
+ # with its footer. For a region an app swaps, hold a {Slot} instead.
5963
+ #
5964
+ # A tree walk finds content generically through `is_a?(HasContent)` plus a
5965
+ # `content` compare, which is why this is a mixin rather than a per-class
5966
+ # accessor — the same reason {HasCaption} is one.
4478
5967
  module HasContent
4479
- # _@param_ `event`
4480
- def handle_mouse: (MouseEvent event) -> void
4481
-
4482
5968
  # _@param_ `rect`
4483
5969
  def rect=: (Rect rect) -> void
4484
5970
 
@@ -4488,27 +5974,46 @@ module Tuile
4488
5974
  attr_accessor content: Component?
4489
5975
  end
4490
5976
 
4491
- # A {Window} preconfigured with a {List} of static lines. Useful for
4492
- # showing read-only information.
5977
+ # A {Window} with a read-only body, in one of two presentations:
5978
+ # **prose** ({#message=}) wraps in a scrollable {TextView}, **rows**
5979
+ # ({#lines=}) stay one per row in a {List}, truncating instead of
5980
+ # wrapping — the presentation for columnar output, where a wrap would
5981
+ # destroy the alignment. Usable tiled (add it to a {Layout}) or as a
5982
+ # popup via {.open}; the constructor and {.open} pick the presentation
5983
+ # from the body's type:
5984
+ #
5985
+ # Component::InfoWindow.open("Help", "A long explanation, wrapped to fit.")
5986
+ # Component::InfoWindow.open("Files", ["drwx src/", "-rw- README.md"])
4493
5987
  #
4494
- # Usable tiled (just add to a {Layout}) or as a popup via {.open}, which
4495
- # wraps it in a {Popup}.
5988
+ # Both write the same body slot — the last writer wins.
4496
5989
  class InfoWindow < Tuile::Component::Window
4497
- # _@param_ `caption`
5990
+ # _@param_ `caption` — the border title.
5991
+ #
5992
+ # _@param_ `body` — an `Array` is assigned through {#lines=}, anything else through {#message=}.
5993
+ def initialize: (?(String | StyledString)? caption, ?(String | StyledString | Component | ::Array[untyped])? body) -> void
5994
+
5995
+ # Sets the rows body: a {List} populated via {List#lines=} (so entries
5996
+ # are coerced, `\n`-split and rstripped exactly as there), one item per
5997
+ # row, long rows truncated — never wrapped. For prose use {#message=}.
4498
5998
  #
4499
- # _@param_ `lines` — initial content; each entry may contain Rainbow formatting.
4500
- def initialize: (?String caption, ?::Array[String] lines) -> void
5999
+ # _@param_ `lines` — entries are `String`, `StyledString`, or anything that responds to `#to_s`.
6000
+ def lines=: (::Array[untyped] lines) -> void
4501
6001
 
4502
6002
  # Opens the info window as a popup.
4503
6003
  #
4504
- # _@param_ `caption`
6004
+ # _@param_ `caption` — the border title.
4505
6005
  #
4506
- # _@param_ `lines` — the content, may contain formatting.
6006
+ # _@param_ `body` — dispatched by type as in {#initialize}.
4507
6007
  #
4508
- # _@param_ `size` — the popup's size, applied top-down; the list wraps and scrolls within it. Defaults to {Fraction::HALF}.
6008
+ # _@param_ `declared_size` — the popup's box, applied top-down; the body wraps or scrolls within it. Defaults to {Fraction::HALF}.
4509
6009
  #
4510
6010
  # _@return_ — the opened popup.
4511
- def self.open: (String caption, ::Array[String] lines, ?size: (Size | Fraction)) -> Popup
6011
+ def self.open: ((String | StyledString)? caption, ?(String | StyledString | Component | ::Array[untyped])? body, ?declared_size: (Size | Fraction)) -> Popup
6012
+
6013
+ # _@return_ — whatever {#message=}
6014
+ # was given — set a `String`, read that `String` back. After
6015
+ # {#lines=} it is the {List} that setter mounted.
6016
+ attr_accessor message: (String | StyledString | Component)?
4512
6017
  end
4513
6018
 
4514
6019
  # Single-select from a set of typed items, one row each. Arrows move a
@@ -4649,12 +6154,9 @@ module Tuile
4649
6154
  # a read-only display field could override back to `false`. Only
4650
6155
  # `focusable?` lives here — `tab_stop?` diverges between leaf fields and
4651
6156
  # composing wrappers, so it stays per-class (`DECISIONS.md`
4652
- # `D-integer-field`).
6157
+ # `D_integer_field`).
4653
6158
  def focusable?: () -> bool
4654
6159
 
4655
- # _@param_ `event`
4656
- def handle_mouse: (MouseEvent event) -> void
4657
-
4658
6160
  # _@param_ `rect`
4659
6161
  def rect=: (Rect rect) -> void
4660
6162
 
@@ -4691,19 +6193,23 @@ module Tuile
4691
6193
  # (floor {MIN_CAP_WIDTH}) and {HEIGHT_FRACTION} tall, and **grows but never
4692
6194
  # shrinks** while it lives; a long message wraps to {MAX_ROWS_PER_MESSAGE}
4693
6195
  # rows and is then ellipsized, and entries past the height cap wait unpainted.
4694
- # `DECISIONS.md` `D-notification` has why each of those is what it is.
6196
+ # `DECISIONS.md` `D_notification` has why each of those is what it is.
4695
6197
  #
4696
6198
  # Three things it deliberately doesn't do:
4697
6199
  #
4698
- # - **Take focus, or receive keys.** A non-modal popup sits off the
4699
- # 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}.
6200
+ # - **Take focus, or receive keys.** An {Overlay} sits off the
6201
+ # key-dispatch scope ({ScreenPane#handle_key}), so no key arrives here at
6202
+ # all. A left click *on the box* dismisses ({#handle_mouse}); an app
6203
+ # wanting a key registers a global shortcut and calls {Overlay#close}. A
6204
+ # click *elsewhere* does not — this is the one overlay with
6205
+ # {Overlay#close_on_outside_click?} false, since a toast is timed and an
6206
+ # unrelated click is not about it.
4702
6207
  # - **Follow a theme flip.** A `Theme::Ref` `color:` is resolved once, when
4703
6208
  # the message is added — a toast lives seconds, so there is no
4704
6209
  # {Component#on_theme_changed} rebuild.
4705
- # - **Take a size.** {#size=} raises; the messages decide.
4706
- class Notification < Tuile::Component::Popup
6210
+ # - **Take a size.** An {Overlay} has no declared box; the messages decide
6211
+ # this one's, in {#reposition}.
6212
+ class Notification < Tuile::Component::Overlay
4707
6213
  MAX_MESSAGES: Integer
4708
6214
  MAX_ROWS_PER_MESSAGE: Integer
4709
6215
  DISPLAY_SECONDS: Float
@@ -4726,21 +6232,6 @@ module Tuile
4726
6232
 
4727
6233
  def initialize: () -> void
4728
6234
 
4729
- # Load-bearing, not cosmetic: focus landing inside a non-modal popup sits
4730
- # outside the key-dispatch scope, where {ScreenPane#handle_key} delivers to
4731
- # nobody — every keystroke would go dead until the user pressed Tab.
4732
- #
4733
- # _@return_ — false.
4734
- def focusable?: () -> bool
4735
-
4736
- # _@return_ — false — see {#focusable?}.
4737
- def tab_stop?: () -> bool
4738
-
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
6235
  # Appends a message, dropping it (with a {Tuile.logger} warning) once
4745
6236
  # {MAX_MESSAGES} are held. Public so a caller holding the instance can
4746
6237
  # append without repeating {show}'s lookup.
@@ -4752,7 +6243,7 @@ module Tuile
4752
6243
 
4753
6244
  # Recomputes the box from its messages and re-anchors it to the screen's
4754
6245
  # top-right corner — so a SIGWINCH re-wraps and re-anchors, where
4755
- # {Popup#reposition} would have kept the stale left column of a *derived*
6246
+ # {Overlay#reposition} would have kept the stale left column of a *derived*
4756
6247
  # position (off-screen entirely if the terminal narrowed).
4757
6248
  #
4758
6249
  # Rebuilds the {TextView}'s text too, and every mutation routes through
@@ -4761,19 +6252,13 @@ module Tuile
4761
6252
  # from the width.
4762
6253
  def reposition: () -> void
4763
6254
 
4764
- # A notification is sized by its messages, so this always raises. Failing
4765
- # loudly beats accepting a size the next {#reposition} would discard.
4766
- #
4767
- # _@param_ `_new_size`
4768
- def size=: ((Size | Fraction) _new_size) -> void
4769
-
4770
6255
  # A left click dismisses the whole box, every message with it. Other buttons
4771
6256
  # are consumed and inert — including the scroll wheel, which would otherwise
4772
6257
  # nuke the box on a stray spin.
4773
6258
  #
4774
6259
  # Deliberately *replaces* rather than augments: neither `super` nor
4775
6260
  # {HasContent#handle_mouse} may run, since both end at a
4776
- # `screen.focused = …` inside this subtree (see {#focusable?}).
6261
+ # `screen.focused = …` inside this subtree (see {Overlay#focusable?}).
4777
6262
  #
4778
6263
  # _@param_ `event`
4779
6264
  def handle_mouse: (MouseEvent event) -> void
@@ -4874,7 +6359,7 @@ module Tuile
4874
6359
  # The `█`/`░` pair is the same one {VerticalScrollBar} uses — East-Asian
4875
6360
  # Ambiguous and Neutral respectively, so under an ambiguous-as-wide terminal
4876
6361
  # the rendered length would vary with the fill level. Shipped anyway, per
4877
- # `DECISIONS.md` `D-ambiguous-width`: a bar that rhymes with the scrollbar
6362
+ # `DECISIONS.md` `D_ambiguous_width`: a bar that rhymes with the scrollbar
4878
6363
  # beats a third convention, and if that bet is ever reversed both swap
4879
6364
  # together.
4880
6365
  class ProgressBar < Component
@@ -5080,12 +6565,9 @@ module Tuile
5080
6565
  # a read-only display field could override back to `false`. Only
5081
6566
  # `focusable?` lives here — `tab_stop?` diverges between leaf fields and
5082
6567
  # composing wrappers, so it stays per-class (`DECISIONS.md`
5083
- # `D-integer-field`).
6568
+ # `D_integer_field`).
5084
6569
  def focusable?: () -> bool
5085
6570
 
5086
- # _@param_ `event`
5087
- def handle_mouse: (MouseEvent event) -> void
5088
-
5089
6571
  # _@param_ `rect`
5090
6572
  def rect=: (Rect rect) -> void
5091
6573
 
@@ -5094,7 +6576,7 @@ module Tuile
5094
6576
 
5095
6577
  # A borderless, tinted, non-focusable floating selection list — the dropdown
5096
6578
  # a *driver* drops open, drives by forwarding movement keys, and commits a
5097
- # pick from: a non-modal {Popup} wrapping a {List} that never takes focus, so
6579
+ # pick from: an {Overlay} wrapping a {List} that never takes focus, so
5098
6580
  # focus stays on the driver while the caller refills the rows, moves the
5099
6581
  # highlight, and reads the pick.
5100
6582
  #
@@ -5109,9 +6591,10 @@ module Tuile
5109
6591
  # drop.choose if key == Keys::ENTER # commit the highlight
5110
6592
  #
5111
6593
  # 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
6594
+ # {#anchor_to} (below a field) and {#anchor_beside} (beside a parent row, for
6595
+ # a cascading submenu). What stays with the driver: the width **policy**
6596
+ # (neither placement method measures anything itself), filtering, row
6597
+ # rendering, the commit action, and ESC/Enter handling. ESC and Enter carry driver-specific tails (ESC may
5115
6598
  # revert a query; Enter may commit via {#choose} *or* via a separate submit
5116
6599
  # path), so {#move} claims neither — the driver calls {#choose} and {#close}
5117
6600
  # from its own branches.
@@ -5123,7 +6606,7 @@ module Tuile
5123
6606
  # different tint (a `Theme.ref(:token)` keeps the flip-tracking).
5124
6607
  #
5125
6608
  # UI-thread-confined, like every component (see {Screen}).
5126
- class ListDropdown < Tuile::Component::Popup
6609
+ class ListDropdown < Tuile::Component::Overlay
5127
6610
  MOVE_KEYS: ::Array[String]
5128
6611
  MAX_VISIBLE_ROWS: Integer
5129
6612
 
@@ -5141,12 +6624,24 @@ module Tuile
5141
6624
  # _@param_ `proc` — commit callback; see {List#on_item_chosen}.
5142
6625
  def on_item_chosen=: ((Proc | Method)? proc) -> void
5143
6626
 
6627
+ # _@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.
6628
+ def on_cursor_changed=: ((Proc | Method)? proc) -> void
6629
+
5144
6630
  # _@param_ `cursor` — the highlight; see {List#cursor=}.
5145
6631
  def cursor=: (List::Cursor cursor) -> void
5146
6632
 
5147
6633
  # _@return_ — the list's cursor (the current highlight).
5148
6634
  def cursor: () -> List::Cursor
5149
6635
 
6636
+ # Moves the highlight to the item at `index`, scrolling it into view; see
6637
+ # {List#select}. The positional counterpart of {#move}, for a driver that
6638
+ # picked a row by something other than a key — a mnemonic letter, say.
6639
+ #
6640
+ # _@param_ `index`
6641
+ #
6642
+ # _@return_ — whether the highlight moved there.
6643
+ def select: (Integer index) -> bool
6644
+
5150
6645
  # Sizes and places the dropdown against `anchor`: directly beneath it,
5151
6646
  # flipped above when `rows` won't fit below, clamped — with the list
5152
6647
  # scrolling — when neither side has room. Horizontally the left edges line
@@ -5158,7 +6653,15 @@ module Tuile
5158
6653
  # Vertical flips but horizontal slides because covering the driver would
5159
6654
  # hide what is being chosen, while sharing its columns is the point.
5160
6655
  #
5161
- # _@param_ `anchor` — the driver's rect; the dropdown never covers it.
6656
+ # **`anchor` is the region actually occupied, and may be taller than one
6657
+ # row** — "beneath" means the row *after* it, so a multi-row driver (a
6658
+ # {Component::TextArea} carrying an autocomplete menu) is cleared entirely
6659
+ # rather than overdrawn from its second row down. A widget that paints one
6660
+ # row but may be *assigned* more height passes its face, not its rect:
6661
+ # {ComboBox} and {Select} both do, since a {Window} content slot hands them
6662
+ # the full inner height.
6663
+ #
6664
+ # _@param_ `anchor` — the region the driver occupies, of any height; the dropdown never covers it.
5162
6665
  #
5163
6666
  # _@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).
5164
6667
  #
@@ -5172,6 +6675,49 @@ module Tuile
5172
6675
  ?max_rows: Integer
5173
6676
  ) -> void
5174
6677
 
6678
+ # Sizes and places the dropdown *beside* `anchor` — the placement a
6679
+ # cascading submenu wants, where {#anchor_to} is the placement a field's
6680
+ # dropdown wants.
6681
+ #
6682
+ # sub.anchor_beside(parent.cursor_row_rect, rows: kids.size, width: measured)
6683
+ #
6684
+ # Horizontally it sits against `anchor`'s right edge, **flipping** to its
6685
+ # left when the right has no room (and clamping to the screen when neither
6686
+ # side does). Vertically it **slides**: the panel's first row lines up with
6687
+ # the anchored row, sliding up only far enough to keep the panel on screen.
6688
+ #
6689
+ # The two axes are the mirror image of {#anchor_to}'s, for the same reason:
6690
+ # never cover the thing being chosen from. A field's dropdown must not
6691
+ # cover the field, so it flips *vertically* and shares its columns; a
6692
+ # submenu must not cover its parent panel, so it flips *horizontally* and
6693
+ # shares its rows.
6694
+ #
6695
+ # _@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.
6696
+ #
6697
+ # _@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).
6698
+ #
6699
+ # _@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).
6700
+ #
6701
+ # _@param_ `max_rows` — rows shown before the list scrolls.
6702
+ def anchor_beside: (
6703
+ Rect anchor,
6704
+ rows: Integer,
6705
+ width: Integer,
6706
+ ?max_rows: Integer
6707
+ ) -> void
6708
+
6709
+ # The highlighted row's rect on screen — what a cascading submenu anchors
6710
+ # against, via {#anchor_beside}.
6711
+ #
6712
+ # It lives here rather than in the driver because {ListDropdown} owns the
6713
+ # list's geometry: a driver computing `top + position - scroll_top_row`
6714
+ # itself would have to reach through to the private list.
6715
+ #
6716
+ # _@return_ — one row spanning the panel's width, or `nil` when
6717
+ # the cursor is off-content ({List::Cursor::None}, an empty list) or its
6718
+ # row is scrolled out of the viewport.
6719
+ def cursor_row_rect: () -> Rect?
6720
+
5175
6721
  # Forwards a cursor-movement key to the list. The driver calls this from
5176
6722
  # its own key handler; a truthy return means "consumed — stop here", falsy
5177
6723
  # means "not mine — proceed with normal editing/dispatch". Only {MOVE_KEYS}
@@ -5203,6 +6749,54 @@ module Tuile
5203
6749
  end
5204
6750
  end
5205
6751
 
6752
+ # A {TextView} purpose-built for log output: auto-scroll is on, the
6753
+ # scrollbar is visible, and lines arrive via {#log} — from any thread.
6754
+ # Point a logger at a {LogTextView::IO} to route its lines here:
6755
+ #
6756
+ # view = Tuile::Component::LogTextView.new
6757
+ # logger = Logger.new(Tuile::Component::LogTextView::IO.new(view))
6758
+ # logger.info("started") # appears in the view, from any thread
6759
+ #
6760
+ # Any logger that writes formatted lines to an IO works the same way —
6761
+ # for example `TTY::Logger` configured with the `:console` handler and
6762
+ # `output: LogTextView::IO.new(view)`.
6763
+ #
6764
+ # Add it to a layout as-is for a frameless log pane, or use {LogWindow}
6765
+ # for the framed assembly. A {TextView} rather than a {List}: long lines
6766
+ # (stacktraces, wide log records) word-wrap rather than ellipsize — a
6767
+ # truncated log line hides the very detail you opened the log to read.
6768
+ class LogTextView < Tuile::Component::TextView
6769
+ def initialize: () -> void
6770
+
6771
+ # Appends the given line to the log. Safe to call from any thread —
6772
+ # the one exception to the UI-thread confinement every component
6773
+ # carries: the append is marshalled through `event_queue.submit`, so
6774
+ # it lands once a loop drains the queue (deferred before the first
6775
+ # loop; silently dropped after the loop has returned).
6776
+ #
6777
+ # _@param_ `string` — the line (or multiple lines) to log; `nil` is a no-op.
6778
+ def log: (String? string) -> void
6779
+
6780
+ # IO-shaped adapter that forwards each log line to its sink's `#log`.
6781
+ # Implements both {#write} (stdlib `Logger`) and {#puts} (loggers that
6782
+ # call `output.puts`, e.g. `TTY::Logger`).
6783
+ class IO
6784
+ # _@param_ `sink` — anything responding to `#log`.
6785
+ def initialize: ((LogTextView | LogWindow) sink) -> void
6786
+
6787
+ # _@param_ `string`
6788
+ def write: (String string) -> void
6789
+
6790
+ # _@param_ `string`
6791
+ def puts: (String string) -> void
6792
+
6793
+ # Stdlib `Logger` only treats an object as an IO target when it
6794
+ # responds to both {#write} and {#close}; otherwise it tries to
6795
+ # interpret it as a filename. This is a no-op.
6796
+ def close: () -> void
6797
+ end
6798
+ end
6799
+
5206
6800
  # A {Window} that lists options identified by single keyboard keys, asks
5207
6801
  # the user to pick one, and fires a callback with the picked key.
5208
6802
  #
@@ -5225,8 +6819,6 @@ module Tuile
5225
6819
  # _@param_ `key`
5226
6820
  def handle_key: (String key) -> bool
5227
6821
 
5228
- def keyboard_hint: () -> String
5229
-
5230
6822
  # Opens a picker as a popup. Picking an option fires `block`, then
5231
6823
  # closes the popup; ESC / `q` close without firing `block`.
5232
6824
  #
@@ -5395,12 +6987,9 @@ module Tuile
5395
6987
  # a read-only display field could override back to `false`. Only
5396
6988
  # `focusable?` lives here — `tab_stop?` diverges between leaf fields and
5397
6989
  # composing wrappers, so it stays per-class (`DECISIONS.md`
5398
- # `D-integer-field`).
6990
+ # `D_integer_field`).
5399
6991
  def focusable?: () -> bool
5400
6992
 
5401
- # _@param_ `event`
5402
- def handle_mouse: (MouseEvent event) -> void
5403
-
5404
6993
  # _@param_ `rect`
5405
6994
  def rect=: (Rect rect) -> void
5406
6995
 
@@ -5411,6 +7000,254 @@ module Tuile
5411
7000
  attr_accessor item_label: (Proc | Method)
5412
7001
  end
5413
7002
 
7003
+ # The confirm dialog: a {Window} asking a question — a caption, a prose
7004
+ # {#message}, a centered row of buttons — plus the one-button degenerate
7005
+ # case, the alert. Three factories cover the common shapes and open the
7006
+ # dialog as a centered, content-sized {Popup}:
7007
+ #
7008
+ # Component::ConfirmWindow.alert("Export failed", "Contact support@example.com.")
7009
+ # Component::ConfirmWindow.confirm("Delete Report Q4?", "This cannot be undone.",
7010
+ # confirm: "Delete") { delete! }
7011
+ # Component::ConfirmWindow.yes_no("Overwrite file?", "target.txt already exists.") { overwrite! }
7012
+ #
7013
+ # The component itself is the builder — declare any button set through
7014
+ # {#button}, then {#open}:
7015
+ #
7016
+ # dialog = Component::ConfirmWindow.new("Unsaved changes")
7017
+ # dialog.message = "Save your changes before leaving?"
7018
+ # dialog.button("Save") { save! }
7019
+ # dialog.button("Discard") { discard! }
7020
+ # dialog.button("Cancel") # no action: pressing it dismisses
7021
+ # dialog.on_dismiss = -> { stay_put }
7022
+ # dialog.open
7023
+ #
7024
+ # **Every button closes the dialog.** A button with a block then fires it; a
7025
+ # button without one is a Cancel. ESC, `q`, an outside click and a Cancel
7026
+ # button are all one outcome — {#on_dismiss}, fired exactly once, and only
7027
+ # when no action button was chosen. There is deliberately no keep-open knob:
7028
+ # a dialog that leads somewhere opens the next window from its callback.
7029
+ #
7030
+ # Keys: Left/Right (and Tab) move between the buttons, Enter/Space press the
7031
+ # focused one, and each button answers to its underlined mnemonic letter
7032
+ # (see {#button}). The message scrolls without taking focus —
7033
+ # {BODY_SCROLL_KEYS} are handed to it from anywhere in the dialog. Focus
7034
+ # opens on the first button; Shift+Tab reaches the message, a tab stop of
7035
+ # its own.
7036
+ #
7037
+ # The popup sizes itself from what the dialog owns — caption, message,
7038
+ # buttons — capped at half the screen ({#measured_size}), re-measured when
7039
+ # any of them changes. Usable tiled too (add it to a {Layout}): buttons then
7040
+ # fire their callbacks with nothing to close.
7041
+ #
7042
+ # There is deliberately no content slot: the body is prose ({#message=}
7043
+ # takes a component for the rare rich body, but the dialog then cannot
7044
+ # measure it). A dialog collecting *input* is not a confirm dialog — build a
7045
+ # `Popup.new(content: your_layout)`. See `DECISIONS.md` `D_confirm_window`
7046
+ # for the API rationale.
7047
+ class ConfirmWindow < Tuile::Component::Window
7048
+ BODY_SCROLL_KEYS: ::Array[String]
7049
+ RESERVED_MNEMONICS: ::Array[String]
7050
+ HEIGHT_CHROME: Integer
7051
+ WIDTH_CHROME: Integer
7052
+
7053
+ # _@param_ `caption` — the border title, coerced the same way {HasCaption#caption=} coerces it.
7054
+ def initialize: (?(String | StyledString)? caption) -> void
7055
+
7056
+ # Also re-measures the popup: the caption participates in the width.
7057
+ #
7058
+ # _@param_ `new_caption`
7059
+ def caption=: ((String | StyledString)? new_caption) -> void
7060
+
7061
+ # Appends a button and returns it. A button with a block is an action
7062
+ # button: pressing it closes the dialog, then fires the block. A button
7063
+ # without one is a Cancel: pressing it closes the dialog, then fires
7064
+ # {#on_dismiss}.
7065
+ #
7066
+ # dialog.button("Delete") { delete! } # mnemonic d, underlined
7067
+ # dialog.button("Keep", mnemonic: "e") # explicit letter
7068
+ # dialog.button("Cancel", mnemonic: nil) # no mnemonic
7069
+ #
7070
+ # The mnemonic — a printable letter activating the button from anywhere in
7071
+ # the dialog, case-insensitively — is underlined in the caption. Any case
7072
+ # is accepted, and the underline prefers the exact case given, so the case
7073
+ # picks which occurrence is cued (`"Save As"` with `"A"` underlines the
7074
+ # *As*), exactly as on {MenuBar#add_item}. The default `:auto` derives the
7075
+ # caption's first letter (cueing it as displayed) and is best-effort:
7076
+ # silently skipped when that letter is reserved, taken, or unusable. An
7077
+ # explicit letter is a promise and raises when it cannot be kept.
7078
+ #
7079
+ # Declare buttons before {#open}: adding one to an open dialog re-measures
7080
+ # the popup, but momentarily bounces focus off the button row.
7081
+ #
7082
+ # _@param_ `caption` — the button label.
7083
+ #
7084
+ # _@param_ `mnemonic` — `:auto` (default), a printable one-column character, or `nil` for none.
7085
+ #
7086
+ # _@return_ — the appended button.
7087
+ def button: ((String | StyledString) caption, ?mnemonic: (Symbol | String)?) -> Button
7088
+
7089
+ # Opens the dialog as a centered modal {Popup} sized by {#measured_size}
7090
+ # and returns that popup. ESC, `q` and an outside click dismiss it
7091
+ # (firing {#on_dismiss}); every button closes it too.
7092
+ #
7093
+ # _@return_ — the mounted popup; `popup.close` closes programmatically,
7094
+ # as {#close} also does.
7095
+ def open: () -> Popup
7096
+
7097
+ # Closes the popup {#open} mounted, which counts as a dismissal
7098
+ # ({#on_dismiss} fires). No-op when not open, or when used tiled.
7099
+ def close: () -> void
7100
+
7101
+ # The popup box this dialog wants: wide enough for the caption, the widest
7102
+ # message line and the button row, tall enough for the wrapped message
7103
+ # plus the button row — each capped at half of `reference`. The re-grow
7104
+ # rule's caller-side measure query: the dialog measures only content it
7105
+ # *owns*, so a {Component} assigned to {#message=} yields the full
7106
+ # half-screen box.
7107
+ #
7108
+ # _@param_ `reference` — the screen size to cap against.
7109
+ def measured_size: (?Size reference) -> Size
7110
+
7111
+ # Focus lands on the first button rather than cascading into the message
7112
+ # body, which sits before the button row in the tree.
7113
+ def on_focus: () -> void
7114
+
7115
+ # Handles the dialog-wide keys: Left/Right move between the buttons,
7116
+ # {BODY_SCROLL_KEYS} are hand-fed to the message body, and a mnemonic
7117
+ # letter presses its button. Reached by bubbling — the focused button or
7118
+ # body sees the key first, so a focused body consumes its own scroll keys
7119
+ # before this runs.
7120
+ #
7121
+ # _@param_ `key`
7122
+ #
7123
+ # _@return_ — true if the key was handled.
7124
+ def handle_key: (String key) -> bool
7125
+
7126
+ # Opens an acknowledgement dialog: a message and one button, whose press
7127
+ # is the dismissal. The button exists for discoverability — ESC and `q`
7128
+ # close too, but Tuile advertises no keys anywhere, and the button is
7129
+ # clickable.
7130
+ #
7131
+ # _@param_ `caption` — the border title.
7132
+ #
7133
+ # _@param_ `message` — see {#message=}.
7134
+ #
7135
+ # _@param_ `button` — the button label.
7136
+ #
7137
+ # _@return_ — the mounted popup.
7138
+ def self.alert: ((String | StyledString)? caption, (String | StyledString | Component)? message, ?button: (String | StyledString)) -> Popup
7139
+
7140
+ # Opens a two-button question: the block is the action, the cancel button
7141
+ # (with ESC, `q` and an outside click) is the dismissal.
7142
+ #
7143
+ # Component::ConfirmWindow.confirm("Delete Report Q4?", "This cannot be undone.",
7144
+ # confirm: "Delete") { delete! }
7145
+ #
7146
+ # _@param_ `caption` — the border title.
7147
+ #
7148
+ # _@param_ `message` — see {#message=}.
7149
+ #
7150
+ # _@param_ `confirm` — the action button's label.
7151
+ #
7152
+ # _@param_ `cancel` — the dismissal button's label.
7153
+ #
7154
+ # _@param_ `on_dismiss` — see {#on_dismiss}.
7155
+ #
7156
+ # _@return_ — the mounted popup.
7157
+ def self.confirm: (
7158
+ (String | StyledString)? caption,
7159
+ (String | StyledString | Component)? message,
7160
+ ?confirm: (String | StyledString),
7161
+ ?cancel: (String | StyledString),
7162
+ ?on_dismiss: Proc?
7163
+ ) -> Popup
7164
+
7165
+ # {.confirm} with Yes/No labels — the other canonical phrasing.
7166
+ #
7167
+ # _@param_ `caption` — the border title.
7168
+ #
7169
+ # _@param_ `message` — see {#message=}.
7170
+ #
7171
+ # _@param_ `on_dismiss` — see {#on_dismiss}.
7172
+ #
7173
+ # _@return_ — the mounted popup.
7174
+ def self.yes_no: ((String | StyledString)? caption, (String | StyledString | Component)? message, ?on_dismiss: Proc?) -> Popup
7175
+
7176
+ # The chosen-button path, in the settled order: mark chosen, close the
7177
+ # popup (whose on_close then skips the dismissal), fire the callback last
7178
+ # so it sees the dialog already gone — a callback opening a follow-up
7179
+ # popup gets clean focus-repair state.
7180
+ #
7181
+ # _@param_ `btn`
7182
+ def activate: (Button btn) -> void
7183
+
7184
+ # The popup's on_close: fires {#on_dismiss} unless a button was chosen —
7185
+ # which makes ESC, `q`, an outside click, {#close}, a direct
7186
+ # {Screen#remove_popup} and teardown all one event, fired exactly once.
7187
+ def notice_dismissed: () -> void
7188
+
7189
+ # _@param_ `delta` — -1 or 1.
7190
+ #
7191
+ # _@return_ — false when focus is not on a button (the key bubbles on).
7192
+ def focus_button_step: (Integer delta) -> bool
7193
+
7194
+ # Validates an explicit mnemonic (raising, as {MenuBar#add_item} does —
7195
+ # none of these has a sane answer at keypress time) or best-effort derives
7196
+ # one from the caption's first letter (returning nil where the explicit
7197
+ # path would raise).
7198
+ #
7199
+ # _@param_ `caption`
7200
+ #
7201
+ # _@param_ `mnemonic`
7202
+ #
7203
+ # _@return_ — the downcased letter, or nil for none.
7204
+ def resolve_mnemonic: (StyledString caption, (Symbol | String)? mnemonic) -> String?
7205
+
7206
+ # _@param_ `caption`
7207
+ def derive_mnemonic: (StyledString caption) -> String?
7208
+
7209
+ # {StyledString#slice} counts **columns** while a caption search yields a
7210
+ # **character** index, so the prefix is measured, never counted (the
7211
+ # {MenuBar::Item} cue, duplicated per `D_float_field`'s shallow-shell rule).
7212
+ #
7213
+ # _@param_ `caption`
7214
+ #
7215
+ # _@param_ `cue` — the letter in the case it was given in — exact case first, so the case picks which occurrence is underlined.
7216
+ def underline_mnemonic: (StyledString caption, String cue) -> StyledString
7217
+
7218
+ # Re-declares the button row's cross extent — the buttons' summed natural
7219
+ # width — so the {Layout::Vertical} centers it. Remove-and-re-add is the
7220
+ # only way to change a {Layout::Box} constraint; the row stays after the
7221
+ # body because both `add`s append.
7222
+ def recenter_button_row: () -> void
7223
+
7224
+ # _@return_ — columns of the button row at its natural width.
7225
+ def button_row_width: () -> Integer
7226
+
7227
+ # Re-measures the popup while open; a no-op tiled or before {#open}.
7228
+ def resize_popup: () -> void
7229
+
7230
+ # Callback taking no arguments, fired when the dialog is dismissed — ESC,
7231
+ # `q`, an outside click, or a {#button} declared without a block. Fires
7232
+ # exactly once per {#open}, and never when an action button was chosen.
7233
+ attr_accessor on_dismiss: Proc?
7234
+
7235
+ # _@return_ — whatever {#message=} was
7236
+ # given — set a `String`, read that `String` back. The component
7237
+ # rendering it is derived, never returned.
7238
+ attr_accessor message: (String | StyledString | Component)?
7239
+
7240
+ # The {Popup} that {#open} wraps the dialog in: its declared size is
7241
+ # derived from the dialog on every {#reposition}, so a message change and
7242
+ # a SIGWINCH both re-measure against the current screen.
7243
+ class MeasuredPopup < Tuile::Component::Popup
7244
+ # _@param_ `window`
7245
+ def initialize: (ConfirmWindow window) -> void
7246
+
7247
+ def reposition: () -> void
7248
+ end
7249
+ end
7250
+
5414
7251
  # A {TextField} that paints one mask glyph per character instead of the
5415
7252
  # text. Editing, caret, clicks and horizontal scrolling are the field's,
5416
7253
  # unchanged:
@@ -5585,12 +7422,9 @@ module Tuile
5585
7422
  # a read-only display field could override back to `false`. Only
5586
7423
  # `focusable?` lives here — `tab_stop?` diverges between leaf fields and
5587
7424
  # composing wrappers, so it stays per-class (`DECISIONS.md`
5588
- # `D-integer-field`).
7425
+ # `D_integer_field`).
5589
7426
  def focusable?: () -> bool
5590
7427
 
5591
- # _@param_ `event`
5592
- def handle_mouse: (MouseEvent event) -> void
5593
-
5594
7428
  # _@param_ `rect`
5595
7429
  def rect=: (Rect rect) -> void
5596
7430
 
@@ -5637,6 +7471,8 @@ module Tuile
5637
7471
  #
5638
7472
  # - {#preprocess_text} — input filter (e.g. {TextField} truncates to
5639
7473
  # fit `rect.width - 1`).
7474
+ # - {#preprocess_paste} — the same for {#handle_paste}, which lands a
7475
+ # whole clipboard at the caret in one mutation.
5640
7476
  # - {#on_text_mutated} / {#on_caret_mutated} — post-mutation side
5641
7477
  # effects (e.g. {TextArea} invalidates its wrap cache and scrolls to
5642
7478
  # keep the caret visible).
@@ -5668,6 +7504,34 @@ module Tuile
5668
7504
  # _@param_ `key`
5669
7505
  def handle_key: (String key) -> bool
5670
7506
 
7507
+ # Inserts pasted text at the caret as **one** mutation, so {#on_change}
7508
+ # fires once for the whole paste rather than once per character.
7509
+ # {#preprocess_paste} filters it first.
7510
+ #
7511
+ # _@param_ `text`
7512
+ #
7513
+ # _@return_ — always true — a field consumes every paste, an empty
7514
+ # one included.
7515
+ def handle_paste: (String text) -> bool
7516
+
7517
+ # Input filter for {#handle_paste}, the paste-side counterpart of
7518
+ # {#preprocess_text}. Strips the C0 control characters a text buffer
7519
+ # cannot hold — a raw `\e` or `\t` reaching {Buffer} would move the real
7520
+ # terminal cursor mid-frame — keeping `\n`, and turning a tab into a
7521
+ # single space so pasted code keeps its word gaps. {TextField} narrows it
7522
+ # further; an app wanting tab *expansion* overrides {#handle_paste}.
7523
+ #
7524
+ # _@param_ `text`
7525
+ def preprocess_paste: (String text) -> String
7526
+
7527
+ # Inserts `str` at the caret, leaving the caret behind it. The bulk
7528
+ # counterpart of a subclass's per-key insert.
7529
+ #
7530
+ # _@param_ `str`
7531
+ #
7532
+ # _@return_ — true if the text changed.
7533
+ def insert_text: (String str) -> bool
7534
+
5671
7535
  # Renders `text` on the field's background well, looked up from the
5672
7536
  # current {Screen#theme} at paint time: {Theme#active_bg_color} when this
5673
7537
  # input is on the active (focus) chain, {Theme#input_bg_color} otherwise —
@@ -5769,7 +7633,7 @@ module Tuile
5769
7633
  # a read-only display field could override back to `false`. Only
5770
7634
  # `focusable?` lives here — `tab_stop?` diverges between leaf fields and
5771
7635
  # composing wrappers, so it stays per-class (`DECISIONS.md`
5772
- # `D-integer-field`).
7636
+ # `D_integer_field`).
5773
7637
  def focusable?: () -> bool
5774
7638
 
5775
7639
  # _@return_ — current text contents.
@@ -5877,6 +7741,48 @@ module Tuile
5877
7741
  attr_reader light: Theme
5878
7742
  end
5879
7743
 
7744
+ # How many colors the terminal on the other end can actually show — the
7745
+ # depth {Color#quantize} degrades a color to:
7746
+ #
7747
+ # ColorDepth.detect # => :truecolor
7748
+ # ColorDepth.detect(env: { "TERM" => "xterm-256color" }) # => :palette256
7749
+ # ColorDepth.detect(env: {}) # => :ansi16
7750
+ #
7751
+ # Env-only: no terminal round-trip, so unlike {TerminalBackground.detect}
7752
+ # there is no stdin timing to respect, and the answer cannot go stale
7753
+ # mid-session the way a background color can.
7754
+ #
7755
+ # Terminals lie in both directions — `COLORTERM` frequently doesn't survive
7756
+ # ssh (it isn't in the default `SendEnv` set) or tmux — so {OVERRIDE_ENV}
7757
+ # beats every other signal, the escape hatch for a terminal detected wrong.
7758
+ # Misdetection otherwise lands *conservatively*: a truecolor tmux
7759
+ # advertising only `tmux-256color` reads as `:palette256`, which renders
7760
+ # coarser but never mangled.
7761
+ #
7762
+ # == Implementation details
7763
+ #
7764
+ # Terminfo is deliberately not consulted — its `RGB` boolean and
7765
+ # `colors#0x1000000` would mean shelling out to `tput`/`infocmp` at every
7766
+ # startup, and the env ladder plus the override already covers the real
7767
+ # terminal matrix.
7768
+ module ColorDepth
7769
+ DEPTHS: ::Array[Symbol]
7770
+ OVERRIDE_ENV: String
7771
+ TRUECOLOR_COLORTERM: ::Array[String]
7772
+
7773
+ # The terminal's color depth, from {OVERRIDE_ENV}, else `COLORTERM`,
7774
+ # else `TERM` (a `-direct` entry means 24-bit, a `256color` one the
7775
+ # palette), else the 16-color floor.
7776
+ #
7777
+ # _@param_ `env` — environment to read; defaults to `ENV` (which duck-types the `[]` lookup).
7778
+ #
7779
+ # _@return_ — one of {DEPTHS}.
7780
+ def self.detect: (?env: ::Hash[String, String]) -> Symbol
7781
+
7782
+ # _@param_ `value` — the raw {OVERRIDE_ENV} value.
7783
+ def self.parse_override: (String value) -> Symbol
7784
+ end
7785
+
5880
7786
  # An event queue. The idea is that all UI-related updates run from the thread
5881
7787
  # which runs the event queue only; this removes any need for locking and/or
5882
7788
  # need for thread-safety mechanisms.
@@ -5974,6 +7880,28 @@ module Tuile
5974
7880
  attr_reader key: String
5975
7881
  end
5976
7882
 
7883
+ # Text arrived from the clipboard rather than the keyboard: the terminal
7884
+ # bracketed it in {Keys::PASTE_START} … {Keys::PASTE_END} because
7885
+ # {Screen#run_event_loop} enabled mode 2004. The whole paste is one event,
7886
+ # so a component sees one mutation instead of a keystroke per character —
7887
+ # and a pasted line break can no longer be mistaken for a typed Enter.
7888
+ #
7889
+ # {Screen#event_loop} routes it to {Component#handle_paste} down the focus
7890
+ # chain. It never enters the key ladder: no Tab traversal, no global
7891
+ # shortcut, no {Component#handle_key}.
7892
+ #
7893
+ # @!attribute [r] text
7894
+ # @return [String] the pasted text, `\n`-normalized by
7895
+ # {Keys.normalize_paste}.
7896
+ class PasteEvent
7897
+ # _@param_ `text`
7898
+ def initialize: (text: String) -> void
7899
+
7900
+ # _@return_ — the pasted text, `\n`-normalized by
7901
+ # {Keys.normalize_paste}.
7902
+ attr_reader text: String
7903
+ end
7904
+
5977
7905
  # An error event, causes {EventQueue#run_loop} to throw `StandardError` with
5978
7906
  # {#error} as its origin.
5979
7907
  #
@@ -6032,6 +7960,26 @@ module Tuile
6032
7960
  attr_reader scheme: Symbol
6033
7961
  end
6034
7962
 
7963
+ # The terminal reported its background color — an OSC 11 reply, read
7964
+ # back as a whole "key" by {Keys.getkey}.
7965
+ #
7966
+ # Only ever an answer to a {TerminalBackground::QUERY} someone wrote,
7967
+ # and only the color: a {ColorSchemeEvent} is what prompts the re-probe
7968
+ # and what carries the light/dark half. See {Screen#background_color}.
7969
+ #
7970
+ # @!attribute [r] color
7971
+ # @return [Color] the reported background, 24-bit RGB.
7972
+ class BackgroundColorEvent
7973
+ # _@param_ `key` — key read via {Keys.getkey}.
7974
+ #
7975
+ # _@return_ — nil when `key` is not an OSC 11
7976
+ # background reply.
7977
+ def self.parse: (String key) -> BackgroundColorEvent?
7978
+
7979
+ # _@return_ — the reported background, 24-bit RGB.
7980
+ attr_reader color: Color
7981
+ end
7982
+
6035
7983
  # Emitted once when the queue is cleared, all messages are processed and the
6036
7984
  # event loop will block waiting for more messages. Perfect time for
6037
7985
  # repainting windows.
@@ -6110,15 +8058,52 @@ module Tuile
6110
8058
  # _@param_ `str`
6111
8059
  def emit: (String str) -> void
6112
8060
 
8061
+ # Pastes `text` into the focused component, as a real terminal would with
8062
+ # bracketed paste on:
8063
+ #
8064
+ # area.focus
8065
+ # Screen.instance.paste("one\r\ntwo")
8066
+ # area.text # => "one\ntwo" — one mutation, no ENTER anywhere
8067
+ #
8068
+ # Goes through {Keys.normalize_paste} first, so a spec can hand it the
8069
+ # CR-flavored line endings terminals actually deliver and still assert
8070
+ # against `\n`.
8071
+ #
8072
+ # _@param_ `text`
8073
+ #
8074
+ # _@return_ — true if some component consumed it.
8075
+ def paste: (String text) -> bool
8076
+
6113
8077
  # _@param_ `component` — the component to check.
6114
8078
  def invalidated?: (Component component) -> bool
6115
8079
 
6116
8080
  def invalidated_clear: () -> void
6117
8081
 
8082
+ # Plays the terminal answering the OSC 11 re-probe, so a spec can
8083
+ # exercise app code that derives colors from {#background_color}:
8084
+ #
8085
+ # Screen.instance.background_color = Color.rgb(30, 30, 46)
8086
+ #
8087
+ # Takes the same path a real reply does — a changed color fires
8088
+ # {Component#on_theme_changed} across the tree and invalidates it.
8089
+ # There is no such writer on {Screen}: the value is a report from the
8090
+ # terminal, not a setting.
8091
+ #
8092
+ # _@param_ `color`
8093
+ def background_color=: (Color color) -> void
8094
+
6118
8095
  # No terminal probing in tests: skip {TerminalBackground.detect}
6119
8096
  # (which would write an OSC 11 query to the test runner's TTY and
6120
- # steal its input) and pin the deterministic default.
6121
- def detect_scheme: () -> Symbol
8097
+ # steal its input) and pin the deterministic default. The color is nil
8098
+ # — the case every app must handle anyway — until a spec assigns one
8099
+ # through {#background_color=}.
8100
+ def detect_background: () -> TerminalBackground::Result
8101
+
8102
+ # Pins the depth rather than reading the test runner's environment, so a
8103
+ # spec asserting flushed bytes gets the same answer on a truecolor
8104
+ # terminal, under `TERM=dumb` in CI, and inside tmux. A spec exercising
8105
+ # degradation builds its own {Buffer} with the depth it wants.
8106
+ def detect_color_depth: () -> Symbol
6122
8107
 
6123
8108
  # _@return_ — whatever {#print} / {#emit} produced so far.
6124
8109
  # Component painting lands in {#buffer}, not here — assert on
@@ -6184,33 +8169,40 @@ module Tuile
6184
8169
  #
6185
8170
  # {Screen} is a singleton runtime owner (event loop, lock, terminal IO,
6186
8171
  # 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.
8172
+ # {#content} and the {#popups} stack. Putting them under a single Component
8173
+ # parent gives focus traversal a real root, makes {Component#attached?} a
8174
+ # one-liner, and lets popup-focus repair fall out of the standard
8175
+ # {Component#on_child_removed} hook.
8176
+ #
8177
+ # The pane owns no chrome of its own — no status bar, no reserved row.
8178
+ # {#content} gets the full pane rect, and an app that wants a status line
8179
+ # builds one into its own layout and drives it from
8180
+ # {Screen#on_focus_changed=} (`D_status_bar`).
6191
8181
  #
6192
8182
  # The pane is not a {Component::Layout}: popups deliberately overlap content
6193
8183
  # (Z-ordered, full overdraw, no clipping) and key/mouse dispatch follows
6194
8184
  # modal-popup rules rather than active-child dispatch.
6195
8185
  class ScreenPane < Tuile::Component
8186
+ FINAL_METHODS: ::Array[Symbol]
8187
+
6196
8188
  def initialize: () -> void
6197
8189
 
6198
8190
  def focusable?: () -> bool
6199
8191
 
6200
- # Adds a popup and invalidates it for repaint. A modal popup is centered
6201
- # and grabs focus; a non-modal overlay ({Component::Popup#modal?} false) is
6202
- # left wherever the caller positions it and does *not* take focus, so the
6203
- # component that was focused keeps the cursor and keeps receiving keys —
6204
- # the overlay floats above the content, driven from app code.
8192
+ # Adds an overlay and invalidates it for repaint. A {Component::Popup} is
8193
+ # centered and grabs focus; a bare {Component::Overlay} is left wherever
8194
+ # the caller positioned it and does *not* take focus, so the component that
8195
+ # was focused keeps the cursor and keeps receiving keys — the overlay
8196
+ # floats above the content, driven from app code.
6205
8197
  #
6206
- # The *whole subtree* is invalidated, not just the popup wrapper (which
8198
+ # The *whole subtree* is invalidated, not just the overlay wrapper (which
6207
8199
  # paints nothing on its own): a reopened popup may land on cells that the
6208
8200
  # tiled content has since overpainted, and if its rect is unchanged from
6209
8201
  # last time its content components won't re-invalidate themselves — so
6210
- # without this the popup's contents would stay blank on reopen.
8202
+ # without this the overlay's contents would stay blank on reopen.
6211
8203
  #
6212
- # _@param_ `window`
6213
- def add_popup: (Component::Popup window) -> void
8204
+ # _@param_ `window` — any overlay, modal or not.
8205
+ def add_popup: (Component::Overlay window) -> void
6214
8206
 
6215
8207
  # Removes a popup. If the popup held focus, focus shifts to the now-topmost
6216
8208
  # remaining popup, falling back to the focus snapshotted when the popup
@@ -6235,11 +8227,11 @@ module Tuile
6235
8227
  # _@return_ — true if this pane currently hosts the popup.
6236
8228
  def has_popup?: (Component window) -> bool
6237
8229
 
6238
- # _@return_ — the topmost *modal* popup, or nil when
6239
- # 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.
8230
+ # _@return_ — the topmost modal overlay, or nil when
8231
+ # only bare {Component::Overlay}s (or none) are open. This is the "modal
8232
+ # owner": the popup that scopes key dispatch, blocks mouse clicks, and
8233
+ # confines Tab cycling. Bare overlays are excluded — they float above the
8234
+ # content without capturing input.
6243
8235
  def modal_popup: () -> Component::Popup?
6244
8236
 
6245
8237
  # Re-lays out children whenever the pane's own rect changes.
@@ -6247,11 +8239,11 @@ module Tuile
6247
8239
  # _@param_ `new_rect`
6248
8240
  def rect=: (Rect new_rect) -> void
6249
8241
 
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).
8242
+ # Gives {#content} the whole pane rect — the pane reserves nothing for
8243
+ # itself. Each popup re-resolves its {Component::Popup#declared_size} against the new
8244
+ # screen via {Component::Popup#reposition} — so a {Fraction} size tracks
8245
+ # resize — repositioning itself (modal popups recenter; non-modal overlays
8246
+ # keep the top-left their owner assigned).
6255
8247
  def layout: () -> void
6256
8248
 
6257
8249
  # Pane paints nothing itself; its children paint over the entire rect.
@@ -6278,6 +8270,15 @@ module Tuile
6278
8270
  # _@return_ — true if the key was handled.
6279
8271
  def handle_key: (String key) -> bool
6280
8272
 
8273
+ # Delivers pasted text along the same focus chain {#handle_key} bubbles
8274
+ # along, and with the same scoping — first {Component#handle_paste}
8275
+ # returning true wins.
8276
+ #
8277
+ # _@param_ `text`
8278
+ #
8279
+ # _@return_ — true if the text was consumed.
8280
+ def handle_paste: (String text) -> bool
8281
+
6281
8282
  # Mouse events check popups in reverse stacking order (topmost first), and
6282
8283
  # fall through to content only when no popup is hit *and* no modal popup is
6283
8284
  # open. This preserves modal click-blocking — an open modal eats clicks
@@ -6285,6 +8286,35 @@ module Tuile
6285
8286
  # inside it route to it (e.g. click-to-select), clicks elsewhere reach the
6286
8287
  # content beneath.
6287
8288
  #
8289
+ # A left click also *dismisses* the open popups it landed outside of that
8290
+ # asked for it ({Component::Overlay#close_on_outside_click?}). That is a
8291
+ # second thing happening on a click, but not a second dispatch: the click is
8292
+ # still delivered exactly once, down one chain, and a dismissed popup is
8293
+ # closed rather than told.
8294
+ #
8295
+ # "Outside" is measured against the {Component::Overlay#owner} chain, not
8296
+ # against one rect and not against stacking order: the popup the click hit
8297
+ # is kept, and so is every popup that one *belongs to*, transitively. That
8298
+ # is what stops a dialog being dismissed by a click on a dropdown its own
8299
+ # field opened, and a menu cascade being dismissed by a click on one of its
8300
+ # own deeper panels. Order carries no meaning here — between unrelated
8301
+ # overlays it is merely the order they opened in — so ownership is declared
8302
+ # rather than inferred from the stack.
8303
+ #
8304
+ # Two halves of the ordering are load-bearing, and both are specced:
8305
+ #
8306
+ # - **Snapshot before routing.** A popup the delivered click *opens* must
8307
+ # not be in the set (it would immediately dismiss itself — every
8308
+ # {Component::Select} would be unopenable by mouse).
8309
+ # - **Close after routing.** A widget toggling its own overlay from a click
8310
+ # on its face closes it during delivery, and {Component::Popup#close} is
8311
+ # idempotent, so the dismissal no-ops. Close *first* and the widget sees
8312
+ # a shut overlay and reopens it — a Select's dropdown could then never be
8313
+ # dismissed by clicking the Select.
8314
+ #
8315
+ # The snapshot is a fresh array for a third reason: a handler may close
8316
+ # further popups, and `@popups` must not be mutated mid-iteration.
8317
+ #
6288
8318
  # _@param_ `event`
6289
8319
  def handle_mouse: (MouseEvent event) -> void
6290
8320
 
@@ -6303,6 +8333,23 @@ module Tuile
6303
8333
  # _@param_ `child`
6304
8334
  def on_child_removed: (Component child) -> void
6305
8335
 
8336
+ # The overlays a click counts as landing *inside*: the one it hit, plus
8337
+ # every overlay that one belongs to, up the {Component::Overlay#owner}
8338
+ # chain. An owner is any component, so it is resolved to the overlay
8339
+ # enclosing it (an overlay resolves to itself) — which keeps the
8340
+ # relationship a live tree question rather than one frozen when the overlay
8341
+ # opened. The `include?` guard makes a mis-wired cycle terminate instead of
8342
+ # hanging the UI thread.
8343
+ #
8344
+ # _@param_ `hit` — the overlay the click landed in, if any.
8345
+ def kept_by: (Component::Overlay? hit) -> ::Array[Component::Overlay]
8346
+
8347
+ # _@param_ `component`
8348
+ #
8349
+ # _@return_ — `component` itself when it is an
8350
+ # overlay, else the nearest overlay above it, else nil.
8351
+ def enclosing_popup: (Component? component) -> Component::Overlay?
8352
+
6306
8353
  # Delivers `key` to {Screen#focused} and bubbles it up the ancestor chain,
6307
8354
  # stopping at (and including) `scope`. Delivers to no one — returning false
6308
8355
  # — when focus is nil or sits outside `scope`; the latter is what makes an
@@ -6316,6 +8363,14 @@ module Tuile
6316
8363
  # _@return_ — true if some component on the chain handled the key.
6317
8364
  def bubble_key: (String key, Component scope) -> bool
6318
8365
 
8366
+ # {Screen#focused} and its ancestors up to and including `scope`.
8367
+ #
8368
+ # _@param_ `scope` — the modal scope root (topmost popup or content).
8369
+ #
8370
+ # _@return_ — the chain, innermost first; nil when
8371
+ # focus is nil or sits outside `scope`.
8372
+ def focus_chain: (Component scope) -> ::Array[Component]?
8373
+
6319
8374
  # First {Component#tab_stop?} in `root`'s subtree (pre-order), falling
6320
8375
  # back to `root` itself when the subtree has no tab stops. Returns `nil`
6321
8376
  # if `root` is `nil`.
@@ -6326,18 +8381,16 @@ module Tuile
6326
8381
  # _@return_ — the tiled content component.
6327
8382
  attr_accessor content: Component?
6328
8383
 
6329
- # _@return_ — overlay popups in stacking order; last is
6330
- # topmost. Holds both modal popups and non-modal overlays
6331
- # ({Component::Popup#modal?}). The array must not be mutated by callers.
6332
- attr_reader popups: ::Array[Component]
6333
-
6334
- # _@return_ — the bottom status bar.
6335
- attr_reader status_bar: Component::Label
8384
+ # _@return_ — the open overlays in stacking order;
8385
+ # last is topmost. Holds both {Component::Popup} modals and bare
8386
+ # {Component::Overlay}s ({Component::Overlay#modal?}). The array must not
8387
+ # be mutated by callers.
8388
+ attr_reader popups: ::Array[Component::Overlay]
6336
8389
  end
6337
8390
 
6338
8391
  # An immutable string-with-styling, modeled as a sequence of {Span}s where
6339
8392
  # each span carries a complete {Style} (`fg`, `bg`, `bold`, `italic`,
6340
- # `underline`, `strikethrough`). Spans are non-overlapping and fully tile
8393
+ # `underline`, `strikethrough`, `inverse`). Spans are non-overlapping and fully tile
6341
8394
  # the string — every character has exactly one resolved style, no overlay
6342
8395
  # layers to merge, so the style at any column is just its span's `style`
6343
8396
  # rather than a replay of the SGR state machine. The book's chapter 9 is
@@ -6368,7 +8421,7 @@ module Tuile
6368
8421
  # ## Parser
6369
8422
  #
6370
8423
  # {.parse} is strict by default — it recognizes only the SGR codes for
6371
- # {Style}'s attributes (fg/bg/bold/italic/underline/strikethrough) and
8424
+ # {Style}'s attributes (fg/bg/bold/italic/underline/strikethrough/inverse) and
6372
8425
  # raises {ParseError} on anything else, keeping the `parse(to_ansi(x)) == x`
6373
8426
  # round-trip honest. Pass `lenient: true` to instead discard everything it
6374
8427
  # can't model (unmodeled SGR, cursor moves, OSC/DCS, stray escapes) and keep
@@ -6503,6 +8556,10 @@ module Tuile
6503
8556
  # so a log line keeps its red error-level bg while its plain text picks up an
6504
8557
  # inherited panel tint.
6505
8558
  #
8559
+ # An `inverse` span counts as backgrounded and is skipped even when its `bg`
8560
+ # member is nil: SGR 7 swaps the pair in effect, so a bg filled under it
8561
+ # would recolor the span's *glyphs*, not the ground behind them.
8562
+ #
6506
8563
  # _@param_ `bg` — background color, coerced via {Color.coerce}. `nil` returns `self` unchanged.
6507
8564
  def under_bg: ((Color | Symbol | Integer | ::Array[Integer])? bg) -> StyledString
6508
8565
 
@@ -6514,6 +8571,54 @@ module Tuile
6514
8571
  # _@param_ `fg` — foreground color, coerced via {Color.coerce}. `nil` clears fg back to the terminal default.
6515
8572
  def with_fg: ((Color | Symbol | Integer | ::Array[Integer])? fg) -> StyledString
6516
8573
 
8574
+ # Returns a new {StyledString} with `bold` applied to every span, preserving
8575
+ # each span's text and other style attributes (`fg`, `bg`, `italic`,
8576
+ # `underline`, `strikethrough`). The bold-attribute counterpart of
8577
+ # {#with_bg} / {#with_fg}: it emphasizes a whole run of app-authored,
8578
+ # possibly multi-span content — a widget marking one caption out of several
8579
+ # as selected, where the caption may already carry its own colors.
8580
+ #
8581
+ # There is deliberately no `under_bold` (the fill-unset counterpart
8582
+ # {#under_bg} provides for backgrounds): a background is inherited down the
8583
+ # component tree, so a span with none has a meaningful "unset" state to
8584
+ # fill, while `bold` is a plain per-span attribute that is either on or
8585
+ # off. Pass `bold: false` to clear it.
8586
+ #
8587
+ # _@param_ `bold` — whether the spans should be bold.
8588
+ def with_bold: (?bold: bool) -> StyledString
8589
+
8590
+ # Returns a new {StyledString} with `underline` applied to every span,
8591
+ # preserving each span's text and other style attributes (`fg`, `bg`,
8592
+ # `bold`, `italic`, `strikethrough`). Slice and rejoin to underline *part*
8593
+ # of a string, which is what a one-character cue needs:
8594
+ #
8595
+ # cap = StyledString.parse("File")
8596
+ # cap.slice(0, 1).with_underline + cap.slice(1, cap.display_width - 1)
8597
+ # # => "File" with the F underlined — a menu mnemonic
8598
+ #
8599
+ # Note {#slice} counts **columns**, not characters, so a caption with a
8600
+ # wide glyph before the cue needs the prefix measured rather than counted.
8601
+ #
8602
+ # There is deliberately no `under_underline`, for the reason {#with_bold}
8603
+ # spells out. Pass `underline: false` to clear it.
8604
+ #
8605
+ # _@param_ `underline` — whether the spans should be underlined.
8606
+ def with_underline: (?underline: bool) -> StyledString
8607
+
8608
+ # Returns a new {StyledString} with `inverse` applied to every span,
8609
+ # preserving each span's text and other style attributes. Inverse swaps
8610
+ # whatever fg/bg are actually in effect at each cell — terminal defaults
8611
+ # included — so a focus chip built with it reads as "backgrounded" on any
8612
+ # terminal palette without picking a single color:
8613
+ #
8614
+ # StyledString.plain(" 1 VMs ").with_inverse # the inverted-chip idiom
8615
+ #
8616
+ # There is deliberately no `under_inverse`, for the reason {#with_bold}
8617
+ # spells out. Pass `inverse: false` to clear it.
8618
+ #
8619
+ # _@param_ `inverse` — whether the spans should be inverted.
8620
+ def with_inverse: (?inverse: bool) -> StyledString
8621
+
6517
8622
  def inspect: () -> String
6518
8623
 
6519
8624
  def build_ansi: () -> String
@@ -6607,6 +8712,9 @@ module Tuile
6607
8712
  # @return [Boolean]
6608
8713
  # @!attribute [r] strikethrough
6609
8714
  # @return [Boolean]
8715
+ # @!attribute [r] inverse
8716
+ # @return [Boolean] swap the fg/bg *in effect* at the cell (SGR 7) —
8717
+ # including terminal defaults, which `fg`/`bg` can't name.
6610
8718
  class Style
6611
8719
  DEFAULT: Style
6612
8720
 
@@ -6621,13 +8729,16 @@ module Tuile
6621
8729
  # _@param_ `underline`
6622
8730
  #
6623
8731
  # _@param_ `strikethrough`
8732
+ #
8733
+ # _@param_ `inverse`
6624
8734
  def self.new: (
6625
8735
  ?fg: (Color | Symbol | Integer | ::Array[Integer])?,
6626
8736
  ?bg: (Color | Symbol | Integer | ::Array[Integer])?,
6627
8737
  ?bold: bool,
6628
8738
  ?italic: bool,
6629
8739
  ?underline: bool,
6630
- ?strikethrough: bool
8740
+ ?strikethrough: bool,
8741
+ ?inverse: bool
6631
8742
  ) -> Style
6632
8743
 
6633
8744
  def default?: () -> bool
@@ -6665,6 +8776,10 @@ module Tuile
6665
8776
  attr_reader underline: bool
6666
8777
 
6667
8778
  attr_reader strikethrough: bool
8779
+
8780
+ # _@return_ — swap the fg/bg *in effect* at the cell (SGR 7) —
8781
+ # including terminal defaults, which `fg`/`bg` can't name.
8782
+ attr_reader inverse: bool
6668
8783
  end
6669
8784
 
6670
8785
  # A maximal run of text sharing a single {Style}. `text` is plain — it
@@ -6823,8 +8938,9 @@ module Tuile
6823
8938
  end
6824
8939
  end
6825
8940
 
6826
- # Detects whether the terminal background is light or dark, so {Screen}
6827
- # can pick {Theme::LIGHT} or {Theme::DARK} automatically at startup.
8941
+ # Detects the terminal's background color — both the light/dark scheme
8942
+ # {Screen} picks {Theme::LIGHT} or {Theme::DARK} from, and, when the
8943
+ # terminal answers the query, the actual RGB behind it.
6828
8944
  #
6829
8945
  # Two mechanisms, in order of reliability:
6830
8946
  #
@@ -6837,13 +8953,19 @@ module Tuile
6837
8953
  # bounded by a short timeout.
6838
8954
  # 2. **`COLORFGBG` env var** — rxvt/konsole export `"fg;bg"` ANSI palette
6839
8955
  # indices. Less reliable (stale across SSH/tmux, often unset); used
6840
- # only when OSC 11 yields nothing.
8956
+ # only when OSC 11 yields nothing. Palette indices carry no RGB, so
8957
+ # this path fills in {Result#scheme} and leaves {Result#color} nil.
6841
8958
  #
6842
8959
  # **Timing matters**: the OSC 11 reply arrives on stdin, so the query
6843
8960
  # must complete before {EventQueue#start_key_thread} owns stdin —
6844
8961
  # otherwise the reply bytes get consumed as garbage keystrokes. {Screen}
6845
8962
  # calls {.detect} from its constructor, which apps run before
6846
8963
  # {Screen#run_event_loop}; don't call this after the event loop started.
8964
+ #
8965
+ # Once the loop *is* running the query is still available, from the other
8966
+ # side: {Screen} writes {QUERY} on every OS appearance flip and the key
8967
+ # thread — which owns stdin by then — reads the reply back through
8968
+ # {Keys.getkey} and {.parse}. See {Screen#background_color}.
6847
8969
  module TerminalBackground
6848
8970
  QUERY_TIMEOUT: Float
6849
8971
  QUERY: String
@@ -6854,6 +8976,9 @@ module Tuile
6854
8976
  # Detects the terminal background. Queries OSC 11 when both `input`
6855
8977
  # and `output` are TTYs, falling back to `COLORFGBG`.
6856
8978
  #
8979
+ # TerminalBackground.detect
8980
+ # # => #<data Result scheme=:dark, color=#<Tuile::Color [30, 30, 46]>>
8981
+ #
6857
8982
  # _@param_ `input` — where the OSC 11 reply arrives (the TTY input).
6858
8983
  #
6859
8984
  # _@param_ `output` — where the query is written (the TTY output).
@@ -6862,25 +8987,41 @@ module Tuile
6862
8987
  #
6863
8988
  # _@param_ `timeout` — max seconds to wait for the OSC 11 reply.
6864
8989
  #
6865
- # _@return_ — `:light`, `:dark`, or nil when undetectable.
8990
+ # _@return_ — nil when the background is undetectable —
8991
+ # neither mechanism answered.
6866
8992
  def self.detect: (
6867
8993
  ?input: IO,
6868
8994
  ?output: IO,
6869
8995
  ?env: ::Hash[String, String],
6870
8996
  ?timeout: Numeric
6871
- ) -> Symbol?
8997
+ ) -> Result?
6872
8998
 
6873
- # Writes the OSC 11 query and classifies the reply. The whole
6874
- # exchange runs with `input` in raw mode: the reply has no trailing
6875
- # newline, so a canonical-mode read would block past the timeout,
6876
- # and echo would smear the reply bytes onto the screen.
8999
+ # Parses an OSC 11 reply — the terminal's answer to {QUERY}, matched
9000
+ # anywhere in `reply`.
9001
+ #
9002
+ # TerminalBackground.parse("\e]11;rgb:1e1e/1e1e/2e2e\a").color
9003
+ # # => #<Tuile::Color [30, 30, 46]>
9004
+ #
9005
+ # Public because a reply also arrives *mid-session*, long after
9006
+ # {.detect}'s own bounded read: once the key thread owns stdin, a
9007
+ # whole reply surfaces as one "key" from {Keys.getkey}.
9008
+ #
9009
+ # _@param_ `reply` — raw terminal output that may contain a reply.
9010
+ #
9011
+ # _@return_ — nil when `reply` holds no OSC 11 reply.
9012
+ def self.parse: (String reply) -> Result?
9013
+
9014
+ # Writes the OSC 11 query and parses the reply. The whole exchange
9015
+ # runs with `input` in raw mode: the reply has no trailing newline,
9016
+ # so a canonical-mode read would block past the timeout, and echo
9017
+ # would smear the reply bytes onto the screen.
6877
9018
  #
6878
9019
  # _@param_ `input`
6879
9020
  #
6880
9021
  # _@param_ `output`
6881
9022
  #
6882
9023
  # _@param_ `timeout`
6883
- def self.query_osc11: (IO input, IO output, Numeric timeout) -> Symbol?
9024
+ def self.query_osc11: (IO input, IO output, Numeric timeout) -> Result?
6884
9025
 
6885
9026
  # Accumulates reply bytes until a BEL/ST terminator or the deadline.
6886
9027
  # Terminals that don't support OSC 11 never reply — returning
@@ -6891,13 +9032,20 @@ module Tuile
6891
9032
  # _@param_ `timeout`
6892
9033
  def self.read_reply: (IO input, Numeric timeout) -> String
6893
9034
 
6894
- # Relative luminance of the reported background, scaled per
6895
- # component hex width (xterm replies 4 digits per channel, others 2).
9035
+ # Light or dark, by relative luminance against a 0.5 threshold.
6896
9036
  #
6897
- # _@param_ `components` — three hex strings.
9037
+ # _@param_ `components` — red, green and blue, each 0.0..1.0.
6898
9038
  #
6899
9039
  # _@return_ — `:light` or `:dark`.
6900
- def self.classify: (::Array[String] components) -> Symbol
9040
+ def self.classify: (::Array[Float] components) -> Symbol
9041
+
9042
+ # The reported background as a 24-bit {Color}. Quantizing xterm's
9043
+ # 16-bit-per-channel reply down to 8 loses nothing a terminal can
9044
+ # display, and keeps the exposed value in the type the rest of Tuile
9045
+ # paints with.
9046
+ #
9047
+ # _@param_ `components` — red, green and blue, each 0.0..1.0.
9048
+ def self.to_color: (::Array[Float] components) -> Color
6901
9049
 
6902
9050
  # `COLORFGBG` is `"fg;bg"` (rxvt sometimes `"fg;default;bg"`) with
6903
9051
  # ANSI palette indices. White-ish backgrounds — 7 (white) and the
@@ -6906,6 +9054,27 @@ module Tuile
6906
9054
  #
6907
9055
  # _@param_ `value`
6908
9056
  def self.from_colorfgbg: (String? value) -> Symbol?
9057
+
9058
+ # What a detection found: the light/dark `scheme`, and the background
9059
+ # `color` when a terminal actually reported one.
9060
+ #
9061
+ # `color` is nil whenever the scheme came from the `COLORFGBG`
9062
+ # fallback, so a consumer deriving a tint from the background must
9063
+ # handle nil — plenty of terminals answer neither query.
9064
+ #
9065
+ # @!attribute [r] scheme
9066
+ # @return [Symbol] `:light` or `:dark`.
9067
+ # @!attribute [r] color
9068
+ # @return [Color, nil] the reported background as 24-bit RGB, or nil
9069
+ # when only `COLORFGBG` answered.
9070
+ class Result < Data
9071
+ # _@return_ — `:light` or `:dark`.
9072
+ attr_reader scheme: Symbol
9073
+
9074
+ # _@return_ — the reported background as 24-bit RGB, or nil
9075
+ # when only `COLORFGBG` answered.
9076
+ attr_reader color: Color?
9077
+ end
6909
9078
  end
6910
9079
 
6911
9080
  # A vertical scrollbar that computes which character to draw at each row.