tuile 0.13.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +81 -37
  3. data/COMPARISON.md +101 -0
  4. data/DECISIONS.md +1095 -195
  5. data/README.md +19 -19
  6. data/TERMINOLOGY.md +6 -5
  7. data/book/03-layout.md +17 -10
  8. data/book/05-focus.md +4 -1
  9. data/book/06-theming.md +98 -0
  10. data/book/07-components.md +169 -19
  11. data/book/08-testing.md +16 -0
  12. data/book/09-styled-text.md +3 -3
  13. data/examples/file_commander.rb +1 -1
  14. data/examples/sampler.rb +143 -43
  15. data/ideas/arrow-key-navigation.md +2 -2
  16. data/ideas/modal-backdrop.md +24 -0
  17. data/ideas/new-components.md +24 -24
  18. data/lib/tuile/buffer.rb +51 -3
  19. data/lib/tuile/color.rb +143 -0
  20. data/lib/tuile/color_depth.rb +80 -0
  21. data/lib/tuile/component/button.rb +3 -3
  22. data/lib/tuile/component/checkbox.rb +3 -3
  23. data/lib/tuile/component/combo_box.rb +9 -2
  24. data/lib/tuile/component/confirm_window.rb +442 -0
  25. data/lib/tuile/component/has_content.rb +22 -9
  26. data/lib/tuile/component/has_value.rb +1 -1
  27. data/lib/tuile/component/info_window.rb +64 -16
  28. data/lib/tuile/component/layout.rb +0 -10
  29. data/lib/tuile/component/list_dropdown.rb +18 -10
  30. data/lib/tuile/component/log_text_view.rb +71 -0
  31. data/lib/tuile/component/log_window.rb +13 -48
  32. data/lib/tuile/component/menu_bar/cascade.rb +3 -3
  33. data/lib/tuile/component/menu_bar.rb +5 -5
  34. data/lib/tuile/component/notification.rb +16 -34
  35. data/lib/tuile/component/overlay.rb +192 -0
  36. data/lib/tuile/component/popup.rb +59 -187
  37. data/lib/tuile/component/progress_bar.rb +1 -1
  38. data/lib/tuile/component/select.rb +14 -6
  39. data/lib/tuile/component/slot.rb +54 -0
  40. data/lib/tuile/component/tab_sheet.rb +0 -11
  41. data/lib/tuile/component/tabs.rb +5 -5
  42. data/lib/tuile/component/window.rb +22 -46
  43. data/lib/tuile/component.rb +149 -19
  44. data/lib/tuile/event_queue.rb +21 -1
  45. data/lib/tuile/fake_screen.rb +26 -2
  46. data/lib/tuile/keys.rb +7 -0
  47. data/lib/tuile/screen.rb +120 -38
  48. data/lib/tuile/screen_pane.rb +37 -35
  49. data/lib/tuile/styled_string.rb +40 -7
  50. data/lib/tuile/terminal_background.rb +74 -16
  51. data/lib/tuile/version.rb +1 -1
  52. data/sig/tuile.rbs +1157 -368
  53. metadata +8 -1
data/sig/tuile.rbs CHANGED
@@ -274,6 +274,11 @@ module Tuile
274
274
  # {Color} instances only.
275
275
  class Color
276
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]
277
282
  PALETTE_NAMES: ::Hash[Symbol, Integer]
278
283
 
279
284
  # Coerces the input to a {Color}. `nil` passes through unchanged (callers
@@ -322,6 +327,36 @@ module Tuile
322
327
  # _@param_ `target` — `:fg` or `:bg`.
323
328
  def sgr_codes: (?Symbol target) -> ::Array[Integer]
324
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
+
325
360
  # Full SGR escape sequence for this color (e.g. `"\e[31m"`). Useful for
326
361
  # `print`-style direct emission; for composing with other attributes use
327
362
  # {#sgr_codes} instead.
@@ -336,6 +371,30 @@ module Tuile
336
371
 
337
372
  def inspect: () -> String
338
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
+
339
398
  # The underlying raw representation — a Symbol, Integer, or frozen
340
399
  # Array<Integer>.
341
400
  attr_reader value: (Symbol | Integer | ::Array[Integer])
@@ -635,7 +694,9 @@ module Tuile
635
694
  def self.display_width: (String grapheme) -> Integer
636
695
 
637
696
  # _@param_ `size` — grid dimensions in columns × rows.
638
- 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
639
700
 
640
701
  # _@return_ — grid dimensions.
641
702
  def size: () -> Size
@@ -796,6 +857,26 @@ module Tuile
796
857
  # _@param_ `style` — style the terminal currently holds.
797
858
  def flush_row: (String _out, Integer y, StyledString::Style style) -> StyledString::Style
798
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
+
799
880
  # _@param_ `rect`
800
881
  #
801
882
  # _@return_ — cells within `rect`, row-major, clamped to
@@ -860,6 +941,11 @@ module Tuile
860
941
 
861
942
  attr_reader height: Integer
862
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
+
863
949
  # One screen cell: a single grapheme cluster, the {StyledString::Style} it's
864
950
  # drawn in, and a dirty flag. Mutable by design (see {Buffer} "Dirty
865
951
  # tracking") — the grid rewrites cells in place. A continuation cell (right
@@ -923,13 +1009,13 @@ module Tuile
923
1009
  # out its own children), the modal/overlay {#popups} stack (opened via
924
1010
  # {Component::Popup#open}, drawn on top of the content). Popups are *not*
925
1011
  # sized from their content — each carries its own top-down
926
- # {Component::Popup#size} — and they deliberately overdraw the content
1012
+ # {Component::Popup#declared_size} — and they deliberately overdraw the content
927
1013
  # without clipping.
928
1014
  #
929
1015
  # Tuile draws no chrome of its own: there is no status bar and no reserved
930
1016
  # row, so {#content} gets the whole terminal. An app that wants a status line
931
1017
  # builds one into its own layout and drives it from {#on_focus_changed=}
932
- # (`D-status-bar`).
1018
+ # (`D_status_bar`).
933
1019
  #
934
1020
  # ## Repaint model
935
1021
  #
@@ -976,9 +1062,10 @@ module Tuile
976
1062
  # _@param_ `content`
977
1063
  def content=: (Component content) -> void
978
1064
 
979
- # _@return_ — currently active popup components (forwarded
980
- # to {ScreenPane}). The array must not be modified!
981
- 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]
982
1069
 
983
1070
  # `:idle` covers *both* ends of the screen's life — before the first
984
1071
  # {#run_event_loop} and after it returns — and a screen may cycle
@@ -1004,11 +1091,11 @@ module Tuile
1004
1091
  # _@param_ `component`
1005
1092
  def invalidate: (Component component) -> void
1006
1093
 
1007
- # Internal — use {Component::Popup#open} instead. Adds the popup to
1008
- # {#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.
1009
1096
  #
1010
- # _@param_ `window`
1011
- def add_popup: (Component::Popup window) -> void
1097
+ # _@param_ `window` — any overlay, modal or not.
1098
+ def add_popup: (Component::Overlay window) -> void
1012
1099
 
1013
1100
  # Runs the event loop on the calling thread, taking over stdin (raw mode,
1014
1101
  # echo off): keys and mouse events are dispatched via {#handle_key} /
@@ -1069,13 +1156,13 @@ module Tuile
1069
1156
  # _@param_ `key`
1070
1157
  def unregister_global_shortcut: (String key) -> void
1071
1158
 
1072
- # Internal — use {Component::Popup#close} instead. Removes the popup
1159
+ # Internal — use {Component::Overlay#close} instead. Removes the overlay
1073
1160
  # from {#pane}, repairs focus, and repaints the scene.
1074
1161
  #
1075
- # Does nothing if the window is not open on this screen.
1162
+ # Does nothing if the overlay is not open on this screen.
1076
1163
  #
1077
- # _@param_ `window`
1078
- def remove_popup: (Component::Popup window) -> void
1164
+ # _@param_ `window` — any overlay, modal or not.
1165
+ def remove_popup: (Component::Overlay window) -> void
1079
1166
 
1080
1167
  # Invalidates the entire attached tree, forcing every component to repaint
1081
1168
  # on the next cycle. Needed whenever something overdraws the scene without
@@ -1086,12 +1173,12 @@ module Tuile
1086
1173
  # its own, so we accept the cost of a full repaint.
1087
1174
  def needs_full_repaint: () -> void
1088
1175
 
1089
- # Internal — use {Component::Popup#open?} instead.
1176
+ # Internal — use {Component::Overlay#open?} instead.
1090
1177
  #
1091
- # _@param_ `window`
1178
+ # _@param_ `window` — any overlay, modal or not.
1092
1179
  #
1093
- # _@return_ — true if this popup is currently mounted.
1094
- def has_popup?: (Component::Popup window) -> bool
1180
+ # _@return_ — true if this overlay is currently mounted.
1181
+ def has_popup?: (Component::Overlay window) -> bool
1095
1182
 
1096
1183
  # Testing only — creates new screen, locks the UI, and prevents any
1097
1184
  # redraws, so that test TTY is not painted over. {FakeScreen#initialize}
@@ -1107,11 +1194,15 @@ module Tuile
1107
1194
  def self.close: () -> void
1108
1195
 
1109
1196
  # Writes terminal-housekeeping escapes straight to stdout: {#clear},
1110
- # mouse-tracking start/stop, the color-scheme notify toggles, cursor-show
1111
- # on teardown. Component painting does *not* go through here anymore — it
1112
- # writes into {#buffer}, which {#repaint} diffs and {#emit}s. {FakeScreen}
1113
- # overrides this (and {#emit}) to capture into `@prints` instead of the
1114
- # 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.
1115
1206
  #
1116
1207
  # _@param_ `args` — stuff to print.
1117
1208
  def print: (*String args) -> void
@@ -1145,23 +1236,41 @@ module Tuile
1145
1236
  # but only one focused.
1146
1237
  def cursor_position: () -> Point?
1147
1238
 
1148
- # Startup color scheme: `:light` when {TerminalBackground.detect}
1149
- # reports a light terminal background, `:dark` otherwise (including
1150
- # when detection is inconclusive). Runs in the constructor — the
1151
- # OSC 11 reply arrives on stdin, which is only safe to read before
1152
- # {EventQueue#start_key_thread} owns it. {FakeScreen} overrides this
1153
- # to pin `:dark`, keeping specs deterministic and off the test
1154
- # runner's TTY.
1155
- #
1156
- # _@return_ — `:dark` or `:light`.
1157
- 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
1158
1252
 
1159
1253
  # An OS appearance flip arrived (mode-2031 report): remember the
1160
- # 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}.
1161
1261
  #
1162
1262
  # _@param_ `scheme` — `:dark` or `:light`.
1163
1263
  def on_color_scheme: (Symbol scheme) -> void
1164
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
+
1165
1274
  # Walks the current modal scope in pre-order, collects tab stops, and
1166
1275
  # advances focus by one (wrapping). When the focused component isn't in
1167
1276
  # the tab order (e.g. focus is parked on a popup/window chrome with no
@@ -1236,6 +1345,16 @@ module Tuile
1236
1345
  # _@return_ — `:light` or `:dark`
1237
1346
  attr_reader color_scheme: Symbol
1238
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
+
1239
1358
  # _@return_ — the back buffer components paint into
1240
1359
  # ({Buffer#set_text} / {Buffer#fill} / {Buffer#set_char}).
1241
1360
  attr_reader buffer: Buffer
@@ -1280,6 +1399,25 @@ module Tuile
1280
1399
  # the user toggling the OS appearance.
1281
1400
  attr_accessor theme_def: ThemeDef
1282
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
+
1283
1421
  # _@return_ — the event queue.
1284
1422
  attr_reader event_queue: EventQueue
1285
1423
 
@@ -1293,7 +1431,7 @@ module Tuile
1293
1431
  #
1294
1432
  # This is the hook an app drives its own status line from. Tuile owns no
1295
1433
  # status bar and reserves no row: build a {Component::Label} into your own
1296
- # layout and fill it here (`D-status-bar`).
1434
+ # layout and fill it here (`D_status_bar`).
1297
1435
  #
1298
1436
  # screen.on_focus_changed = -> { bar.text = hint_for(screen.focused) }
1299
1437
  #
@@ -1362,8 +1500,68 @@ module Tuile
1362
1500
  # any stale invalidation entries are filtered out at drain time. Subclasses
1363
1501
  # can paint freely in {#repaint} without re-asserting attachment.
1364
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
+
1365
1515
  def initialize: () -> void
1366
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
+
1367
1565
  # _@return_ — the screen which owns this component.
1368
1566
  def screen: () -> Screen
1369
1567
 
@@ -1388,6 +1586,11 @@ module Tuile
1388
1586
  # paint the whole {#rect} yourself ({Window}'s border, {Component::List}'s
1389
1587
  # row-by-row paint). Never draw outside {#rect}. Only called when attached.
1390
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
+ #
1391
1594
  # **The children are re-invalidated whether or not they tile.** A container
1392
1595
  # that paints nothing of its own can only redraw its area *through* them, so
1393
1596
  # a tiling container that skipped this would be a dead end in the cascade: an
@@ -1430,8 +1633,14 @@ module Tuile
1430
1633
  # _@return_ — true if the paste was consumed.
1431
1634
  def handle_paste: (String _text) -> bool
1432
1635
 
1433
- # Handles mouse event. Default implementation focuses this component when
1434
- # clicked (if {#focusable?}).
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.
1435
1644
  #
1436
1645
  # _@param_ `event`
1437
1646
  def handle_mouse: (MouseEvent event) -> void
@@ -1609,6 +1818,14 @@ module Tuile
1609
1818
  # Children with empty rects contribute zero, since they paint nothing.
1610
1819
  def children_tile_rect?: () -> bool
1611
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
+
1612
1829
  # Clears the background: fills every cell with a blank in the
1613
1830
  # {#effective_bg_color} (the terminal default when none is inherited).
1614
1831
  #
@@ -1687,6 +1904,10 @@ module Tuile
1687
1904
  # content (`text=`, `lines=`, …) is safe. Do not assign {Screen#theme=}
1688
1905
  # here. Subclasses overriding this must call `super` so an assigned
1689
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`).
1690
1911
  attr_accessor on_theme_changed: Proc?
1691
1912
 
1692
1913
  # A scrollable list of typed items, one row each, with cursor support.
@@ -2156,6 +2377,51 @@ module Tuile
2156
2377
  end
2157
2378
  end
2158
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
+
2159
2425
  # A one-row strip of captions with exactly one of them selected — the map of
2160
2426
  # where the user is. Knows nothing about content: pair it with
2161
2427
  # {Component::TabSheet} to swap panes, or swap views yourself from
@@ -2183,7 +2449,7 @@ module Tuile
2183
2449
  #
2184
2450
  # {Tab} handles are minted by {#add_tab} and owned by the strip. There is no
2185
2451
  # `items=`: a tab is identity plus its own state, so the set grows and
2186
- # shrinks one tab at a time. See book ch7 and `DECISIONS.md` `D-tabs`.
2452
+ # shrinks one tab at a time. See book ch7 and `DECISIONS.md` `D_tabs`.
2187
2453
  #
2188
2454
  # == Sizing
2189
2455
  # Assign a {#rect} (typically from the surrounding {Layout}). One wider than
@@ -2269,7 +2535,7 @@ module Tuile
2269
2535
  # blank tail — or on a lower row, when the rect is taller than one —
2270
2536
  # selects nothing. It still *focuses*: {Component#handle_mouse}'s
2271
2537
  # click-to-focus is ungated by geometry.
2272
- def extent: () -> Rect
2538
+ def extent: () -> Size
2273
2539
 
2274
2540
  # Switches tabs on LEFT / RIGHT, consuming the key even at the ends of the
2275
2541
  # strip (the selection clamps). Every other key is left unhandled so it
@@ -2517,130 +2783,65 @@ module Tuile
2517
2783
  attr_accessor bg: (Color | Symbol | Integer | ::Array[Integer])?
2518
2784
  end
2519
2785
 
2520
- # An overlay that wraps any {Component} as its content. Popup itself
2521
- # paints nothing — it's a transparent host that handles its lifecycle
2522
- # ({#open} / {#close} / {#open?}, ESC/q to close) and holds a top-down
2523
- # {#size} the {Screen} applies.
2524
- #
2525
- # The popup does *not* size itself to its content. Its box is declared by
2526
- # {#size} — a {Fraction} (resolved against the screen every layout pass, so
2527
- # it tracks resize) or an absolute {Size} (clamped to the screen). The
2528
- # default is {Fraction::HALF}: half the screen, centered. The wrapped
2529
- # content then fills that box and handles its own overflow by wrapping and
2530
- # scrolling, so use content that can — a {Component::TextView} or
2531
- # {Component::TextArea} — for anything longer than fits. A
2532
- # {Component::Label} only truncates.
2533
- #
2534
- # Modal by default: it centers on the screen, grabs focus, eats keys, and
2535
- # blocks clicks beneath it. Pass `modal: false` for a non-modal overlay
2536
- # that floats above the content without taking focus or capturing input —
2537
- # the caller positions it (via {#rect=}), sizes it, and drives it from app
2538
- # code. That's the building block for an autocomplete/slash-command list
2539
- # anchored to a text field's caret: typing keeps focus in the input while
2540
- # the caller refills and drives the overlay.
2541
- #
2542
- # The wrapped content fills the popup's full {#rect}; if you want a frame
2543
- # and caption, wrap a {Component::Window} (or any subclass — including
2544
- # {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`.
2545
2789
  #
2546
2790
  # window = Component::Window.new("Help")
2547
2791
  # window.content = Component::List.new.tap { _1.lines = lines }
2548
2792
  # Component::Popup.new(content: window).open
2549
2793
  #
2550
2794
  # Bare content also works (a {Component::Label}, a {Component::List}…), in
2551
- # 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
2552
2809
  #
2553
2810
  # `q` and ESC close the popup — handled here, at the top of the popup's own
2554
2811
  # subtree, so the key only arrives after every component on the focus chain
2555
2812
  # declined it (see {ScreenPane#handle_key}). That's why typing `q` into a
2556
- # nested {Component::TextField} doesn't dismiss the popup: the field
2557
- # consumes it first.
2813
+ # nested {Component::TextField} doesn't dismiss the popup: the field consumes
2814
+ # it first.
2558
2815
  #
2559
- # A left click *outside* the popup closes it too, modal or not — see
2560
- # {#close_on_outside_click?} for the exact contract and {#on_close=} for
2561
- # the notice a driver hears when it happens.
2562
- class Popup < Component
2563
- include Tuile::Component::HasContent
2564
-
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
2565
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.
2566
2823
  #
2567
- # _@param_ `modal` — true (default) for a centered, focus-grabbing, input-capturing modal; false for a non-modal overlay the caller positions and drives (see the class docs).
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}.
2568
2825
  #
2569
- # _@param_ `size` — the popup's size, applied top-down. A {Fraction} is resolved against the screen each layout pass; a {Size} is clamped to the screen. Defaults to {Fraction::HALF}.
2570
- #
2571
- # _@param_ `close_on_outside_click` — true (default) to dismiss on a left click that misses this popup. See {#close_on_outside_click?}.
2572
- def initialize: (
2573
- ?content: Component?,
2574
- ?modal: bool,
2575
- ?size: (Size | Fraction),
2576
- ?close_on_outside_click: bool
2577
- ) -> void
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
2578
2828
 
2579
- # _@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.
2580
2831
  def modal?: () -> bool
2581
2832
 
2582
- # Whether a left click outside this popup closes it (default true, modal or
2583
- # not). The pane does the closing — {ScreenPane#handle_mouse} snapshots
2584
- # the open popups *before* routing the click and closes the dismissable
2585
- # ones *after*, so a widget that toggles its own overlay from a click on
2586
- # its face (a {Component::Select}, a {Component::MenuBar} title) still
2587
- # toggles correctly: the delivered click closes the overlay and the
2588
- # dismissal then no-ops on it, rather than closing and reopening it. Only
2589
- # `:left` dismisses; scroll and right clicks never do.
2590
- #
2591
- # **"Outside" spans the {#owner} chain, not just this rect.** A click
2592
- # counts as inside this popup when it lands in its rect *or* in any popup
2593
- # that belongs to it — so a dialog is not dismissed by a click on a
2594
- # dropdown its own field opened, and a menu cascade is not dismissed by a
2595
- # click on one of its deeper panels. Popups with no owner relationship are
2596
- # independent: clicking one dismisses the other, which is what a
2597
- # window-like overlay should do. A popup that must survive unrelated
2598
- # clicks entirely ({Component::Notification}) sets this false.
2599
- #
2600
- # Every dismissable popup closes, not just the topmost, and stacking order
2601
- # plays no part: a {Component::MenuBar} cascade must vanish whole on one
2602
- # click on the background, not peel one panel per click.
2603
- def close_on_outside_click?: () -> bool
2604
-
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.
2605
2835
  def focusable?: () -> bool
2606
2836
 
2607
- # Reassigns the popup's rect, escalating to a full scene repaint when an
2608
- # open popup shrinks or moves so its new rect no longer covers the cells
2609
- # it previously painted. A popup overdraws the scene without clipping and
2610
- # nothing clears underneath it, so {Screen#repaint}'s popup-only fast path
2611
- # would repaint into the new rect and leave the vacated cells showing
2612
- # stale content. When the new rect fully covers the old one (the popup
2613
- # only grew), the fast path is correct and the full repaint is skipped.
2614
- #
2615
- # _@param_ `new_rect`
2616
- def rect=: (Rect new_rect) -> void
2617
-
2618
- # Mounts this popup on the {Screen}, re-resolving its {#size} against the
2619
- # current screen first.
2620
- #
2621
- # popup = Component::Popup.new(content: window).open # construct and mount
2622
- #
2623
- # There is deliberately no class-level `Popup.open` factory — see
2624
- # `DECISIONS.md` `D-popup-open`; returning `self` is what keeps the
2625
- # one-liner above available without one.
2626
- def open: () -> self
2627
-
2628
- # Removes this popup from the {Screen}. No-op if not currently open.
2629
- def close: () -> void
2630
-
2631
- # _@return_ — true if this popup is currently mounted on the screen.
2632
- def open?: () -> bool
2633
-
2634
- # Re-resolves {#size} against the current screen and repositions the popup
2635
- # *itself* (this is not laying out content — the popup's own rect): a
2636
- # modal popup recenters; a non-modal overlay keeps its caller-assigned
2637
- # top-left (only its size follows the screen). Called on {#open}, on
2638
- # {#size=}, and by the screen's layout pass (so a {Fraction} size tracks
2639
- # 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.
2640
2841
  #
2641
- # The final rect is computed and assigned in one step rather than sizing
2642
- # at the origin and then centering: the intermediate origin rect rarely
2643
- # 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
2644
2845
  # detection fire a full repaint on every resize.
2645
2846
  def reposition: () -> void
2646
2847
 
@@ -2656,53 +2857,14 @@ module Tuile
2656
2857
  # _@return_ — true if the key was handled.
2657
2858
  def handle_key: (String key) -> bool
2658
2859
 
2659
- # Fires {#on_close}. A subclass overriding this **must** call `super`, or
2660
- # the popup's driver never hears that it closed.
2661
- def on_detached: () -> void
2662
-
2663
- # 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.
2664
2865
  #
2665
- # _@param_ `content`
2666
- def layout: (Component content) -> void
2667
-
2668
- # _@param_ `event`
2669
- def handle_mouse: (MouseEvent event) -> void
2670
-
2671
- def on_focus: () -> void
2672
-
2673
- # _@return_ — the popup's declared size. See {#size=}.
2674
- attr_accessor size: (Size | Fraction)
2675
-
2676
- # _@return_ — see {#close_on_outside_click?}.
2677
- attr_writer close_on_outside_click: bool
2678
-
2679
- # The component this overlay is *part of*, or `nil` (the default) when it
2680
- # is an overlay in its own right. It exists for outside-click dismissal:
2681
- # a click inside this popup also counts as inside whatever popup encloses
2682
- # its owner, so the host is not dismissed by a click on a panel it put
2683
- # there. See {#close_on_outside_click?}.
2684
- #
2685
- # Set it to the *driver* — {Component::ComboBox} hands its dropdown
2686
- # `self` — rather than to the enclosing popup: the driver knows what it
2687
- # is, while the popup above it is a tree relationship the pane resolves
2688
- # at click time (so it cannot go stale). Any {Component} is accepted, and
2689
- # a `Popup` resolves to itself, which is how a
2690
- # {Component::MenuBar::Cascade} chains each panel to the one it dropped
2691
- # out of.
2692
- attr_accessor owner: Component?
2693
-
2694
- # A callback taking no arguments, fired once this popup has left the
2695
- # screen — **however it left**: {#close}, a direct {Screen#remove_popup},
2696
- # an outside click, or teardown via {Screen#close}. That unconditionality
2697
- # is the point, so it hangs off {#on_detached} rather than {#close}; a
2698
- # driver keeping its own record of open popups reconciles it here and
2699
- # cannot drift ({Component::MenuBar::Cascade} is the worked example).
2700
- #
2701
- # It fires *after* the popup is detached, so {#open?} is already false and
2702
- # the usual {Component#on_detached} caveats apply: release state, don't
2703
- # inspect the tree, keep it trivial (it may run while the pane is mid-way
2704
- # through closing a batch of popups, and a raise propagates).
2705
- attr_accessor on_close: Proc?
2866
+ # _@return_ — see {#declared_size=}.
2867
+ attr_accessor declared_size: (Size | Fraction)
2706
2868
  end
2707
2869
 
2708
2870
  # A clickable button. Activated by Enter, Space, or a left mouse click;
@@ -2738,7 +2900,7 @@ module Tuile
2738
2900
  # It still *focuses*: {Component#handle_mouse}'s click-to-focus is ungated
2739
2901
  # by geometry. Same rule as {Checkbox#extent}, which documents the two
2740
2902
  # traps behind it.
2741
- def extent: () -> Rect
2903
+ def extent: () -> Size
2742
2904
 
2743
2905
  # Fires {#on_click} on a left click within {#extent}; `super` runs first, so
2744
2906
  # a click anywhere in {#rect} still focuses.
@@ -2801,11 +2963,6 @@ module Tuile
2801
2963
  # _@param_ `child`
2802
2964
  def remove: (Component child) -> void
2803
2965
 
2804
- # Dispatches the event to the child under the mouse cursor.
2805
- #
2806
- # _@param_ `event`
2807
- def handle_mouse: (MouseEvent event) -> void
2808
-
2809
2966
  def on_focus: () -> void
2810
2967
 
2811
2968
  # How much space a child gets along one axis of a {Box}: exactly {#cells},
@@ -3237,7 +3394,7 @@ module Tuile
3237
3394
  # Home/End are declined too, so they stay available app-wide.
3238
3395
  #
3239
3396
  # There is no type-ahead: a hidden prefix buffer *is* the ComboBox query with
3240
- # 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
3241
3398
  # need no prefix-disambiguation.
3242
3399
  #
3243
3400
  # == Implementation details
@@ -3284,7 +3441,15 @@ module Tuile
3284
3441
  # _@param_ `key`
3285
3442
  def handle_key: (String key) -> bool
3286
3443
 
3287
- # 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
3288
3453
  # affordance is its whole row, as the well advertises; `super` runs first,
3289
3454
  # so the click also focuses.
3290
3455
  #
@@ -3353,7 +3518,7 @@ module Tuile
3353
3518
  # a read-only display field could override back to `false`. Only
3354
3519
  # `focusable?` lives here — `tab_stop?` diverges between leaf fields and
3355
3520
  # composing wrappers, so it stays per-class (`DECISIONS.md`
3356
- # `D-integer-field`).
3521
+ # `D_integer_field`).
3357
3522
  def focusable?: () -> bool
3358
3523
 
3359
3524
  # _@return_ — the options.
@@ -3383,8 +3548,18 @@ module Tuile
3383
3548
 
3384
3549
  def focusable?: () -> bool
3385
3550
 
3386
- # _@param_ `event`
3387
- 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
3388
3563
 
3389
3564
  # _@param_ `new_rect`
3390
3565
  def rect=: (Rect new_rect) -> void
@@ -3435,9 +3610,12 @@ module Tuile
3435
3610
  # _@param_ `fg` — the active-border color, or nil when inactive.
3436
3611
  def bottom_border: (Integer inner_w, Color? fg) -> StyledString
3437
3612
 
3438
- # Positions the footer over the bottom border row, spanning the full
3613
+ # Positions the footer slot over the bottom border row, spanning the full
3439
3614
  # inner width (the only dimension a bottom-row widget needs — the window
3440
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.
3441
3619
  def layout_footer: () -> void
3442
3620
 
3443
3621
  # Read through *this* method, never `@caption` — the ivar stays nil until
@@ -3455,16 +3633,168 @@ module Tuile
3455
3633
 
3456
3634
  def on_focus: () -> void
3457
3635
 
3458
- # _@return_ — optional focusable component occupying the
3459
- # bottom border row, always spanning the full inner width.
3460
- attr_accessor footer: Component?
3461
-
3462
3636
  # _@return_ — optional chrome embedded into the bottom border
3463
3637
  # line, mirroring {#caption} on the top line. Empty by default; hidden
3464
3638
  # whenever a {#footer} component is present.
3465
3639
  attr_accessor footer_text: (StyledString | String)?
3466
3640
  end
3467
3641
 
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
+
3468
3798
  # A boolean input on one row. Space, Enter or a left click toggles it:
3469
3799
  #
3470
3800
  # [x] Enable syslog forwarding
@@ -3547,7 +3877,7 @@ module Tuile
3547
3877
  # The extent ignores {Component#bg_color}: an inherited tint paints the dead
3548
3878
  # tail, but a hit test that silently widened with a background would be a
3549
3879
  # mode switch invisible in the code and untestable by inspection.
3550
- def extent: () -> Rect
3880
+ def extent: () -> Size
3551
3881
 
3552
3882
  # Toggles on Space or Enter. Every other key is left unhandled so it bubbles
3553
3883
  # to an ancestor.
@@ -3589,7 +3919,7 @@ module Tuile
3589
3919
  # a read-only display field could override back to `false`. Only
3590
3920
  # `focusable?` lives here — `tab_stop?` diverges between leaf fields and
3591
3921
  # composing wrappers, so it stays per-class (`DECISIONS.md`
3592
- # `D-integer-field`).
3922
+ # `D_integer_field`).
3593
3923
  def focusable?: () -> bool
3594
3924
  end
3595
3925
 
@@ -3634,7 +3964,7 @@ module Tuile
3634
3964
  # {Item} handles are minted by {#add_item} and nest via the *same* method, so
3635
3965
  # depth is unlimited. There is no removal, no reordering and no dynamic
3636
3966
  # rebuilding: a menu is built once, at construction. See `DECISIONS.md`
3637
- # `D-menu-bar`.
3967
+ # `D_menu_bar`.
3638
3968
  #
3639
3969
  # == Sizing
3640
3970
  # Assign a {#rect} (typically one {Layout::Fixed}`[1]` row at the top of a
@@ -3697,7 +4027,7 @@ module Tuile
3697
4027
  # tail — or on a lower row, when the rect is taller than one — opens
3698
4028
  # nothing. It still *focuses*: {Component#handle_mouse}'s click-to-focus is
3699
4029
  # ungated by geometry.
3700
- def extent: () -> Rect
4030
+ def extent: () -> Size
3701
4031
 
3702
4032
  # _@param_ `new_rect`
3703
4033
  def rect=: (Rect new_rect) -> void
@@ -3970,7 +4300,7 @@ module Tuile
3970
4300
  #
3971
4301
  # Widths are measured here, per level — the panel is as wide as the level's
3972
4302
  # widest label — because {ListDropdown} deliberately measures nothing
3973
- # itself (`DECISIONS.md` `D-select`).
4303
+ # itself (`DECISIONS.md` `D_select`).
3974
4304
  #
3975
4305
  # == Implementation details
3976
4306
  # While open it consumes **everything** except the two keys that mean
@@ -4094,7 +4424,7 @@ module Tuile
4094
4424
  # combo.value = some_user # selects it; field shows its label
4095
4425
  #
4096
4426
  # It's the assembly you'd otherwise wire by hand — a {TextField} plus a
4097
- # non-modal {Popup} over a {List} — promoted to one component. Give it a
4427
+ # an {Overlay} over a {List} — promoted to one component. Give it a
4098
4428
  # single-row {#rect}; it paints the field across that row with a `▾` in the
4099
4429
  # last column and floats the dropdown above or below.
4100
4430
  #
@@ -4149,6 +4479,12 @@ module Tuile
4149
4479
 
4150
4480
  def repaint: () -> void
4151
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
+
4152
4488
  # Field spans the row bar the last column, which the `▾` occupies
4153
4489
  # ({HasContent} layout hook). One row, or none at all when the combo itself
4154
4490
  # was given none — a starved parent must not hand out a rect it doesn't own.
@@ -4231,7 +4567,7 @@ module Tuile
4231
4567
  # a read-only display field could override back to `false`. Only
4232
4568
  # `focusable?` lives here — `tab_stop?` diverges between leaf fields and
4233
4569
  # composing wrappers, so it stays per-class (`DECISIONS.md`
4234
- # `D-integer-field`).
4570
+ # `D_integer_field`).
4235
4571
  def focusable?: () -> bool
4236
4572
 
4237
4573
  def on_focus: () -> void
@@ -4287,7 +4623,7 @@ module Tuile
4287
4623
  # a read-only display field could override back to `false`. Only
4288
4624
  # `focusable?` lives here — `tab_stop?` diverges between leaf fields and
4289
4625
  # composing wrappers, so it stays per-class (`DECISIONS.md`
4290
- # `D-integer-field`).
4626
+ # `D_integer_field`).
4291
4627
  def focusable?: () -> bool
4292
4628
 
4293
4629
  # _@return_ — one-arg callable fired with the new value
@@ -4402,12 +4738,6 @@ module Tuile
4402
4738
  # _@param_ `new_rect`
4403
4739
  def rect=: (Rect new_rect) -> void
4404
4740
 
4405
- # Forwards to whichever child the click landed on — the strip's row, or
4406
- # the pane below it.
4407
- #
4408
- # _@param_ `event`
4409
- def handle_mouse: (MouseEvent event) -> void
4410
-
4411
4741
  # Sends focus to the strip: a sheet is a container, and the strip is where
4412
4742
  # a tab switch is driven from. The pane is a Tab press away.
4413
4743
  def on_focus: () -> void
@@ -5281,42 +5611,23 @@ module Tuile
5281
5611
  end
5282
5612
  end
5283
5613
 
5284
- # Shows a log. Construct your logger pointed at a {LogWindow::IO} to route
5285
- # 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:
5286
5617
  #
5287
5618
  # log_window = Tuile::Component::LogWindow.new
5288
5619
  # logger = Logger.new(Tuile::Component::LogWindow::IO.new(log_window))
5289
- #
5290
- # Any logger that writes formatted lines to an IO works the same way —
5291
- # for example `TTY::Logger` configured with the `:console` handler and
5292
- # `output: LogWindow::IO.new(window)`.
5293
5620
  class LogWindow < Tuile::Component::Window
5621
+ IO: Class
5622
+
5294
5623
  # _@param_ `caption`
5295
5624
  def initialize: (?String caption) -> void
5296
5625
 
5297
- # 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}.
5298
5628
  #
5299
- # _@param_ `string` — the line (or multiple lines) to log.
5629
+ # _@param_ `string` — the line (or multiple lines) to log; `nil` is a no-op.
5300
5630
  def log: (String? string) -> void
5301
-
5302
- # IO-shaped adapter that forwards each log line to the owning {LogWindow}.
5303
- # Implements both {#write} (stdlib `Logger`) and {#puts} (loggers that
5304
- # call `output.puts`, e.g. `TTY::Logger`).
5305
- class IO
5306
- # _@param_ `window`
5307
- def initialize: (LogWindow window) -> void
5308
-
5309
- # _@param_ `string`
5310
- def write: (String string) -> void
5311
-
5312
- # _@param_ `string`
5313
- def puts: (String string) -> void
5314
-
5315
- # Stdlib `Logger` only treats an object as an IO target when it
5316
- # responds to both {#write} and {#close}; otherwise it tries to
5317
- # interpret it as a filename. This is a no-op.
5318
- def close: () -> void
5319
- end
5320
5631
  end
5321
5632
 
5322
5633
  # A single-line text input with a real hardware caret, scrolling
@@ -5588,12 +5899,9 @@ module Tuile
5588
5899
  # a read-only display field could override back to `false`. Only
5589
5900
  # `focusable?` lives here — `tab_stop?` diverges between leaf fields and
5590
5901
  # composing wrappers, so it stays per-class (`DECISIONS.md`
5591
- # `D-integer-field`).
5902
+ # `D_integer_field`).
5592
5903
  def focusable?: () -> bool
5593
5904
 
5594
- # _@param_ `event`
5595
- def handle_mouse: (MouseEvent event) -> void
5596
-
5597
5905
  # _@param_ `rect`
5598
5906
  def rect=: (Rect rect) -> void
5599
5907
 
@@ -5634,13 +5942,29 @@ module Tuile
5634
5942
  def caption=: ((String | StyledString)? new_caption) -> void
5635
5943
  end
5636
5944
 
5637
- # A mixin interface for a component with one child tops. The host must
5638
- # provide a protected `layout(content)` method which repositions the
5639
- # 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.
5640
5967
  module HasContent
5641
- # _@param_ `event`
5642
- def handle_mouse: (MouseEvent event) -> void
5643
-
5644
5968
  # _@param_ `rect`
5645
5969
  def rect=: (Rect rect) -> void
5646
5970
 
@@ -5650,27 +5974,46 @@ module Tuile
5650
5974
  attr_accessor content: Component?
5651
5975
  end
5652
5976
 
5653
- # A {Window} preconfigured with a {List} of static lines. Useful for
5654
- # 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:
5655
5984
  #
5656
- # Usable tiled (just add to a {Layout}) or as a popup via {.open}, which
5657
- # wraps it in a {Popup}.
5985
+ # Component::InfoWindow.open("Help", "A long explanation, wrapped to fit.")
5986
+ # Component::InfoWindow.open("Files", ["drwx src/", "-rw- README.md"])
5987
+ #
5988
+ # Both write the same body slot — the last writer wins.
5658
5989
  class InfoWindow < Tuile::Component::Window
5659
- # _@param_ `caption`
5990
+ # _@param_ `caption` — the border title.
5660
5991
  #
5661
- # _@param_ `lines` — initial content; each entry may contain Rainbow formatting.
5662
- def initialize: (?String caption, ?::Array[String] lines) -> void
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=}.
5998
+ #
5999
+ # _@param_ `lines` — entries are `String`, `StyledString`, or anything that responds to `#to_s`.
6000
+ def lines=: (::Array[untyped] lines) -> void
5663
6001
 
5664
6002
  # Opens the info window as a popup.
5665
6003
  #
5666
- # _@param_ `caption`
6004
+ # _@param_ `caption` — the border title.
5667
6005
  #
5668
- # _@param_ `lines` — the content, may contain formatting.
6006
+ # _@param_ `body` — dispatched by type as in {#initialize}.
5669
6007
  #
5670
- # _@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}.
5671
6009
  #
5672
6010
  # _@return_ — the opened popup.
5673
- 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)?
5674
6017
  end
5675
6018
 
5676
6019
  # Single-select from a set of typed items, one row each. Arrows move a
@@ -5811,12 +6154,9 @@ module Tuile
5811
6154
  # a read-only display field could override back to `false`. Only
5812
6155
  # `focusable?` lives here — `tab_stop?` diverges between leaf fields and
5813
6156
  # composing wrappers, so it stays per-class (`DECISIONS.md`
5814
- # `D-integer-field`).
6157
+ # `D_integer_field`).
5815
6158
  def focusable?: () -> bool
5816
6159
 
5817
- # _@param_ `event`
5818
- def handle_mouse: (MouseEvent event) -> void
5819
-
5820
6160
  # _@param_ `rect`
5821
6161
  def rect=: (Rect rect) -> void
5822
6162
 
@@ -5853,22 +6193,23 @@ module Tuile
5853
6193
  # (floor {MIN_CAP_WIDTH}) and {HEIGHT_FRACTION} tall, and **grows but never
5854
6194
  # shrinks** while it lives; a long message wraps to {MAX_ROWS_PER_MESSAGE}
5855
6195
  # rows and is then ellipsized, and entries past the height cap wait unpainted.
5856
- # `DECISIONS.md` `D-notification` has why each of those is what it is.
6196
+ # `DECISIONS.md` `D_notification` has why each of those is what it is.
5857
6197
  #
5858
6198
  # Three things it deliberately doesn't do:
5859
6199
  #
5860
- # - **Take focus, or receive keys.** A non-modal popup sits off the
5861
- # key-dispatch scope ({ScreenPane#handle_key}), so not even {Popup}'s
5862
- # `q`/ESC arrives here. A left click *on the box* dismisses
5863
- # ({#handle_mouse}); an app wanting a key registers a global shortcut and
5864
- # calls {#close}. A click *elsewhere* does not — this is the one popup
5865
- # with {Popup#close_on_outside_click?} false, since a toast is timed and
5866
- # an unrelated click is not about it.
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.
5867
6207
  # - **Follow a theme flip.** A `Theme::Ref` `color:` is resolved once, when
5868
6208
  # the message is added — a toast lives seconds, so there is no
5869
6209
  # {Component#on_theme_changed} rebuild.
5870
- # - **Take a size.** {#size=} raises; the messages decide.
5871
- 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
5872
6213
  MAX_MESSAGES: Integer
5873
6214
  MAX_ROWS_PER_MESSAGE: Integer
5874
6215
  DISPLAY_SECONDS: Float
@@ -5891,16 +6232,6 @@ module Tuile
5891
6232
 
5892
6233
  def initialize: () -> void
5893
6234
 
5894
- # Load-bearing, not cosmetic: focus landing inside a non-modal popup sits
5895
- # outside the key-dispatch scope, where {ScreenPane#handle_key} delivers to
5896
- # nobody — every keystroke would go dead until the user pressed Tab.
5897
- #
5898
- # _@return_ — false.
5899
- def focusable?: () -> bool
5900
-
5901
- # _@return_ — false — see {#focusable?}.
5902
- def tab_stop?: () -> bool
5903
-
5904
6235
  # Appends a message, dropping it (with a {Tuile.logger} warning) once
5905
6236
  # {MAX_MESSAGES} are held. Public so a caller holding the instance can
5906
6237
  # append without repeating {show}'s lookup.
@@ -5912,7 +6243,7 @@ module Tuile
5912
6243
 
5913
6244
  # Recomputes the box from its messages and re-anchors it to the screen's
5914
6245
  # top-right corner — so a SIGWINCH re-wraps and re-anchors, where
5915
- # {Popup#reposition} would have kept the stale left column of a *derived*
6246
+ # {Overlay#reposition} would have kept the stale left column of a *derived*
5916
6247
  # position (off-screen entirely if the terminal narrowed).
5917
6248
  #
5918
6249
  # Rebuilds the {TextView}'s text too, and every mutation routes through
@@ -5921,19 +6252,13 @@ module Tuile
5921
6252
  # from the width.
5922
6253
  def reposition: () -> void
5923
6254
 
5924
- # A notification is sized by its messages, so this always raises. Failing
5925
- # loudly beats accepting a size the next {#reposition} would discard.
5926
- #
5927
- # _@param_ `_new_size`
5928
- def size=: ((Size | Fraction) _new_size) -> void
5929
-
5930
6255
  # A left click dismisses the whole box, every message with it. Other buttons
5931
6256
  # are consumed and inert — including the scroll wheel, which would otherwise
5932
6257
  # nuke the box on a stray spin.
5933
6258
  #
5934
6259
  # Deliberately *replaces* rather than augments: neither `super` nor
5935
6260
  # {HasContent#handle_mouse} may run, since both end at a
5936
- # `screen.focused = …` inside this subtree (see {#focusable?}).
6261
+ # `screen.focused = …` inside this subtree (see {Overlay#focusable?}).
5937
6262
  #
5938
6263
  # _@param_ `event`
5939
6264
  def handle_mouse: (MouseEvent event) -> void
@@ -6034,7 +6359,7 @@ module Tuile
6034
6359
  # The `█`/`░` pair is the same one {VerticalScrollBar} uses — East-Asian
6035
6360
  # Ambiguous and Neutral respectively, so under an ambiguous-as-wide terminal
6036
6361
  # the rendered length would vary with the fill level. Shipped anyway, per
6037
- # `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
6038
6363
  # beats a third convention, and if that bet is ever reversed both swap
6039
6364
  # together.
6040
6365
  class ProgressBar < Component
@@ -6240,12 +6565,9 @@ module Tuile
6240
6565
  # a read-only display field could override back to `false`. Only
6241
6566
  # `focusable?` lives here — `tab_stop?` diverges between leaf fields and
6242
6567
  # composing wrappers, so it stays per-class (`DECISIONS.md`
6243
- # `D-integer-field`).
6568
+ # `D_integer_field`).
6244
6569
  def focusable?: () -> bool
6245
6570
 
6246
- # _@param_ `event`
6247
- def handle_mouse: (MouseEvent event) -> void
6248
-
6249
6571
  # _@param_ `rect`
6250
6572
  def rect=: (Rect rect) -> void
6251
6573
 
@@ -6254,7 +6576,7 @@ module Tuile
6254
6576
 
6255
6577
  # A borderless, tinted, non-focusable floating selection list — the dropdown
6256
6578
  # a *driver* drops open, drives by forwarding movement keys, and commits a
6257
- # 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
6258
6580
  # focus stays on the driver while the caller refills the rows, moves the
6259
6581
  # highlight, and reads the pick.
6260
6582
  #
@@ -6284,7 +6606,7 @@ module Tuile
6284
6606
  # different tint (a `Theme.ref(:token)` keeps the flip-tracking).
6285
6607
  #
6286
6608
  # UI-thread-confined, like every component (see {Screen}).
6287
- class ListDropdown < Tuile::Component::Popup
6609
+ class ListDropdown < Tuile::Component::Overlay
6288
6610
  MOVE_KEYS: ::Array[String]
6289
6611
  MAX_VISIBLE_ROWS: Integer
6290
6612
 
@@ -6331,7 +6653,15 @@ module Tuile
6331
6653
  # Vertical flips but horizontal slides because covering the driver would
6332
6654
  # hide what is being chosen, while sharing its columns is the point.
6333
6655
  #
6334
- # _@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.
6335
6665
  #
6336
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).
6337
6667
  #
@@ -6366,7 +6696,7 @@ module Tuile
6366
6696
  #
6367
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).
6368
6698
  #
6369
- # _@param_ `width` — the panel's width in columns, clamped to the screen. **Required, with no default:** `anchor.width` is the *parent's* width and would be meaningless here, so the caller measures (see `DECISIONS.md` `D-select` on why the width policy stays with the driver).
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).
6370
6700
  #
6371
6701
  # _@param_ `max_rows` — rows shown before the list scrolls.
6372
6702
  def anchor_beside: (
@@ -6419,6 +6749,54 @@ module Tuile
6419
6749
  end
6420
6750
  end
6421
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
+
6422
6800
  # A {Window} that lists options identified by single keyboard keys, asks
6423
6801
  # the user to pick one, and fires a callback with the picked key.
6424
6802
  #
@@ -6609,12 +6987,9 @@ module Tuile
6609
6987
  # a read-only display field could override back to `false`. Only
6610
6988
  # `focusable?` lives here — `tab_stop?` diverges between leaf fields and
6611
6989
  # composing wrappers, so it stays per-class (`DECISIONS.md`
6612
- # `D-integer-field`).
6990
+ # `D_integer_field`).
6613
6991
  def focusable?: () -> bool
6614
6992
 
6615
- # _@param_ `event`
6616
- def handle_mouse: (MouseEvent event) -> void
6617
-
6618
6993
  # _@param_ `rect`
6619
6994
  def rect=: (Rect rect) -> void
6620
6995
 
@@ -6625,6 +7000,254 @@ module Tuile
6625
7000
  attr_accessor item_label: (Proc | Method)
6626
7001
  end
6627
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
+
6628
7251
  # A {TextField} that paints one mask glyph per character instead of the
6629
7252
  # text. Editing, caret, clicks and horizontal scrolling are the field's,
6630
7253
  # unchanged:
@@ -6799,12 +7422,9 @@ module Tuile
6799
7422
  # a read-only display field could override back to `false`. Only
6800
7423
  # `focusable?` lives here — `tab_stop?` diverges between leaf fields and
6801
7424
  # composing wrappers, so it stays per-class (`DECISIONS.md`
6802
- # `D-integer-field`).
7425
+ # `D_integer_field`).
6803
7426
  def focusable?: () -> bool
6804
7427
 
6805
- # _@param_ `event`
6806
- def handle_mouse: (MouseEvent event) -> void
6807
-
6808
7428
  # _@param_ `rect`
6809
7429
  def rect=: (Rect rect) -> void
6810
7430
 
@@ -7013,7 +7633,7 @@ module Tuile
7013
7633
  # a read-only display field could override back to `false`. Only
7014
7634
  # `focusable?` lives here — `tab_stop?` diverges between leaf fields and
7015
7635
  # composing wrappers, so it stays per-class (`DECISIONS.md`
7016
- # `D-integer-field`).
7636
+ # `D_integer_field`).
7017
7637
  def focusable?: () -> bool
7018
7638
 
7019
7639
  # _@return_ — current text contents.
@@ -7121,6 +7741,48 @@ module Tuile
7121
7741
  attr_reader light: Theme
7122
7742
  end
7123
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
+
7124
7786
  # An event queue. The idea is that all UI-related updates run from the thread
7125
7787
  # which runs the event queue only; this removes any need for locking and/or
7126
7788
  # need for thread-safety mechanisms.
@@ -7298,6 +7960,26 @@ module Tuile
7298
7960
  attr_reader scheme: Symbol
7299
7961
  end
7300
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
+
7301
7983
  # Emitted once when the queue is cleared, all messages are processed and the
7302
7984
  # event loop will block waiting for more messages. Perfect time for
7303
7985
  # repainting windows.
@@ -7397,10 +8079,31 @@ module Tuile
7397
8079
 
7398
8080
  def invalidated_clear: () -> void
7399
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
+
7400
8095
  # No terminal probing in tests: skip {TerminalBackground.detect}
7401
8096
  # (which would write an OSC 11 query to the test runner's TTY and
7402
- # steal its input) and pin the deterministic default.
7403
- 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
7404
8107
 
7405
8108
  # _@return_ — whatever {#print} / {#emit} produced so far.
7406
8109
  # Component painting lands in {#buffer}, not here — assert on
@@ -7474,30 +8177,32 @@ module Tuile
7474
8177
  # The pane owns no chrome of its own — no status bar, no reserved row.
7475
8178
  # {#content} gets the full pane rect, and an app that wants a status line
7476
8179
  # builds one into its own layout and drives it from
7477
- # {Screen#on_focus_changed=} (`D-status-bar`).
8180
+ # {Screen#on_focus_changed=} (`D_status_bar`).
7478
8181
  #
7479
8182
  # The pane is not a {Component::Layout}: popups deliberately overlap content
7480
8183
  # (Z-ordered, full overdraw, no clipping) and key/mouse dispatch follows
7481
8184
  # modal-popup rules rather than active-child dispatch.
7482
8185
  class ScreenPane < Tuile::Component
8186
+ FINAL_METHODS: ::Array[Symbol]
8187
+
7483
8188
  def initialize: () -> void
7484
8189
 
7485
8190
  def focusable?: () -> bool
7486
8191
 
7487
- # Adds a popup and invalidates it for repaint. A modal popup is centered
7488
- # and grabs focus; a non-modal overlay ({Component::Popup#modal?} false) is
7489
- # left wherever the caller positions it and does *not* take focus, so the
7490
- # component that was focused keeps the cursor and keeps receiving keys —
7491
- # 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.
7492
8197
  #
7493
- # The *whole subtree* is invalidated, not just the popup wrapper (which
8198
+ # The *whole subtree* is invalidated, not just the overlay wrapper (which
7494
8199
  # paints nothing on its own): a reopened popup may land on cells that the
7495
8200
  # tiled content has since overpainted, and if its rect is unchanged from
7496
8201
  # last time its content components won't re-invalidate themselves — so
7497
- # without this the popup's contents would stay blank on reopen.
8202
+ # without this the overlay's contents would stay blank on reopen.
7498
8203
  #
7499
- # _@param_ `window`
7500
- def add_popup: (Component::Popup window) -> void
8204
+ # _@param_ `window` — any overlay, modal or not.
8205
+ def add_popup: (Component::Overlay window) -> void
7501
8206
 
7502
8207
  # Removes a popup. If the popup held focus, focus shifts to the now-topmost
7503
8208
  # remaining popup, falling back to the focus snapshotted when the popup
@@ -7522,11 +8227,11 @@ module Tuile
7522
8227
  # _@return_ — true if this pane currently hosts the popup.
7523
8228
  def has_popup?: (Component window) -> bool
7524
8229
 
7525
- # _@return_ — the topmost *modal* popup, or nil when
7526
- # only non-modal overlays (or no popups) are open. This is the "modal
8230
+ # _@return_ — the topmost modal overlay, or nil when
8231
+ # only bare {Component::Overlay}s (or none) are open. This is the "modal
7527
8232
  # owner": the popup that scopes key dispatch, blocks mouse clicks, and
7528
- # confines Tab cycling. Non-modal overlays are excluded — they float above
7529
- # the content without capturing input.
8233
+ # confines Tab cycling. Bare overlays are excluded — they float above the
8234
+ # content without capturing input.
7530
8235
  def modal_popup: () -> Component::Popup?
7531
8236
 
7532
8237
  # Re-lays out children whenever the pane's own rect changes.
@@ -7535,7 +8240,7 @@ module Tuile
7535
8240
  def rect=: (Rect new_rect) -> void
7536
8241
 
7537
8242
  # Gives {#content} the whole pane rect — the pane reserves nothing for
7538
- # itself. Each popup re-resolves its {Component::Popup#size} against the new
8243
+ # itself. Each popup re-resolves its {Component::Popup#declared_size} against the new
7539
8244
  # screen via {Component::Popup#reposition} — so a {Fraction} size tracks
7540
8245
  # resize — repositioning itself (modal popups recenter; non-modal overlays
7541
8246
  # keep the top-left their owner assigned).
@@ -7582,12 +8287,12 @@ module Tuile
7582
8287
  # content beneath.
7583
8288
  #
7584
8289
  # A left click also *dismisses* the open popups it landed outside of that
7585
- # asked for it ({Component::Popup#close_on_outside_click?}). That is a
8290
+ # asked for it ({Component::Overlay#close_on_outside_click?}). That is a
7586
8291
  # second thing happening on a click, but not a second dispatch: the click is
7587
8292
  # still delivered exactly once, down one chain, and a dismissed popup is
7588
8293
  # closed rather than told.
7589
8294
  #
7590
- # "Outside" is measured against the {Component::Popup#owner} chain, not
8295
+ # "Outside" is measured against the {Component::Overlay#owner} chain, not
7591
8296
  # against one rect and not against stacking order: the popup the click hit
7592
8297
  # is kept, and so is every popup that one *belongs to*, transitively. That
7593
8298
  # is what stops a dialog being dismissed by a click on a dropdown its own
@@ -7628,21 +8333,22 @@ module Tuile
7628
8333
  # _@param_ `child`
7629
8334
  def on_child_removed: (Component child) -> void
7630
8335
 
7631
- # The popups a click counts as landing *inside*: the one it hit, plus every
7632
- # popup that one belongs to, up the {Component::Popup#owner} chain. An owner
7633
- # is any component, so it is resolved to the popup enclosing it (a popup
7634
- # resolves to itself) — which keeps the relationship a live tree question
7635
- # rather than one frozen when the overlay opened. The `include?` guard makes
7636
- # a mis-wired cycle terminate instead of hanging the UI thread.
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.
7637
8343
  #
7638
- # _@param_ `hit` — the popup the click landed in, if any.
7639
- def kept_by: (Component::Popup? hit) -> ::Array[Component::Popup]
8344
+ # _@param_ `hit` — the overlay the click landed in, if any.
8345
+ def kept_by: (Component::Overlay? hit) -> ::Array[Component::Overlay]
7640
8346
 
7641
8347
  # _@param_ `component`
7642
8348
  #
7643
- # _@return_ — `component` itself when it is a popup,
7644
- # else the nearest popup above it, else nil.
7645
- def enclosing_popup: (Component? component) -> Component::Popup?
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?
7646
8352
 
7647
8353
  # Delivers `key` to {Screen#focused} and bubbles it up the ancestor chain,
7648
8354
  # stopping at (and including) `scope`. Delivers to no one — returning false
@@ -7675,15 +8381,16 @@ module Tuile
7675
8381
  # _@return_ — the tiled content component.
7676
8382
  attr_accessor content: Component?
7677
8383
 
7678
- # _@return_ — overlay popups in stacking order; last is
7679
- # topmost. Holds both modal popups and non-modal overlays
7680
- # ({Component::Popup#modal?}). The array must not be mutated by callers.
7681
- attr_reader popups: ::Array[Component]
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]
7682
8389
  end
7683
8390
 
7684
8391
  # An immutable string-with-styling, modeled as a sequence of {Span}s where
7685
8392
  # each span carries a complete {Style} (`fg`, `bg`, `bold`, `italic`,
7686
- # `underline`, `strikethrough`). Spans are non-overlapping and fully tile
8393
+ # `underline`, `strikethrough`, `inverse`). Spans are non-overlapping and fully tile
7687
8394
  # the string — every character has exactly one resolved style, no overlay
7688
8395
  # layers to merge, so the style at any column is just its span's `style`
7689
8396
  # rather than a replay of the SGR state machine. The book's chapter 9 is
@@ -7714,7 +8421,7 @@ module Tuile
7714
8421
  # ## Parser
7715
8422
  #
7716
8423
  # {.parse} is strict by default — it recognizes only the SGR codes for
7717
- # {Style}'s attributes (fg/bg/bold/italic/underline/strikethrough) and
8424
+ # {Style}'s attributes (fg/bg/bold/italic/underline/strikethrough/inverse) and
7718
8425
  # raises {ParseError} on anything else, keeping the `parse(to_ansi(x)) == x`
7719
8426
  # round-trip honest. Pass `lenient: true` to instead discard everything it
7720
8427
  # can't model (unmodeled SGR, cursor moves, OSC/DCS, stray escapes) and keep
@@ -7849,6 +8556,10 @@ module Tuile
7849
8556
  # so a log line keeps its red error-level bg while its plain text picks up an
7850
8557
  # inherited panel tint.
7851
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
+ #
7852
8563
  # _@param_ `bg` — background color, coerced via {Color.coerce}. `nil` returns `self` unchanged.
7853
8564
  def under_bg: ((Color | Symbol | Integer | ::Array[Integer])? bg) -> StyledString
7854
8565
 
@@ -7894,6 +8605,20 @@ module Tuile
7894
8605
  # _@param_ `underline` — whether the spans should be underlined.
7895
8606
  def with_underline: (?underline: bool) -> StyledString
7896
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
+
7897
8622
  def inspect: () -> String
7898
8623
 
7899
8624
  def build_ansi: () -> String
@@ -7987,6 +8712,9 @@ module Tuile
7987
8712
  # @return [Boolean]
7988
8713
  # @!attribute [r] strikethrough
7989
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.
7990
8718
  class Style
7991
8719
  DEFAULT: Style
7992
8720
 
@@ -8001,13 +8729,16 @@ module Tuile
8001
8729
  # _@param_ `underline`
8002
8730
  #
8003
8731
  # _@param_ `strikethrough`
8732
+ #
8733
+ # _@param_ `inverse`
8004
8734
  def self.new: (
8005
8735
  ?fg: (Color | Symbol | Integer | ::Array[Integer])?,
8006
8736
  ?bg: (Color | Symbol | Integer | ::Array[Integer])?,
8007
8737
  ?bold: bool,
8008
8738
  ?italic: bool,
8009
8739
  ?underline: bool,
8010
- ?strikethrough: bool
8740
+ ?strikethrough: bool,
8741
+ ?inverse: bool
8011
8742
  ) -> Style
8012
8743
 
8013
8744
  def default?: () -> bool
@@ -8045,6 +8776,10 @@ module Tuile
8045
8776
  attr_reader underline: bool
8046
8777
 
8047
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
8048
8783
  end
8049
8784
 
8050
8785
  # A maximal run of text sharing a single {Style}. `text` is plain — it
@@ -8203,8 +8938,9 @@ module Tuile
8203
8938
  end
8204
8939
  end
8205
8940
 
8206
- # Detects whether the terminal background is light or dark, so {Screen}
8207
- # 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.
8208
8944
  #
8209
8945
  # Two mechanisms, in order of reliability:
8210
8946
  #
@@ -8217,13 +8953,19 @@ module Tuile
8217
8953
  # bounded by a short timeout.
8218
8954
  # 2. **`COLORFGBG` env var** — rxvt/konsole export `"fg;bg"` ANSI palette
8219
8955
  # indices. Less reliable (stale across SSH/tmux, often unset); used
8220
- # 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.
8221
8958
  #
8222
8959
  # **Timing matters**: the OSC 11 reply arrives on stdin, so the query
8223
8960
  # must complete before {EventQueue#start_key_thread} owns stdin —
8224
8961
  # otherwise the reply bytes get consumed as garbage keystrokes. {Screen}
8225
8962
  # calls {.detect} from its constructor, which apps run before
8226
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}.
8227
8969
  module TerminalBackground
8228
8970
  QUERY_TIMEOUT: Float
8229
8971
  QUERY: String
@@ -8234,6 +8976,9 @@ module Tuile
8234
8976
  # Detects the terminal background. Queries OSC 11 when both `input`
8235
8977
  # and `output` are TTYs, falling back to `COLORFGBG`.
8236
8978
  #
8979
+ # TerminalBackground.detect
8980
+ # # => #<data Result scheme=:dark, color=#<Tuile::Color [30, 30, 46]>>
8981
+ #
8237
8982
  # _@param_ `input` — where the OSC 11 reply arrives (the TTY input).
8238
8983
  #
8239
8984
  # _@param_ `output` — where the query is written (the TTY output).
@@ -8242,25 +8987,41 @@ module Tuile
8242
8987
  #
8243
8988
  # _@param_ `timeout` — max seconds to wait for the OSC 11 reply.
8244
8989
  #
8245
- # _@return_ — `:light`, `:dark`, or nil when undetectable.
8990
+ # _@return_ — nil when the background is undetectable —
8991
+ # neither mechanism answered.
8246
8992
  def self.detect: (
8247
8993
  ?input: IO,
8248
8994
  ?output: IO,
8249
8995
  ?env: ::Hash[String, String],
8250
8996
  ?timeout: Numeric
8251
- ) -> Symbol?
8997
+ ) -> Result?
8998
+
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?
8252
9013
 
8253
- # Writes the OSC 11 query and classifies the reply. The whole
8254
- # exchange runs with `input` in raw mode: the reply has no trailing
8255
- # newline, so a canonical-mode read would block past the timeout,
8256
- # and echo would smear the reply bytes onto the screen.
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.
8257
9018
  #
8258
9019
  # _@param_ `input`
8259
9020
  #
8260
9021
  # _@param_ `output`
8261
9022
  #
8262
9023
  # _@param_ `timeout`
8263
- def self.query_osc11: (IO input, IO output, Numeric timeout) -> Symbol?
9024
+ def self.query_osc11: (IO input, IO output, Numeric timeout) -> Result?
8264
9025
 
8265
9026
  # Accumulates reply bytes until a BEL/ST terminator or the deadline.
8266
9027
  # Terminals that don't support OSC 11 never reply — returning
@@ -8271,13 +9032,20 @@ module Tuile
8271
9032
  # _@param_ `timeout`
8272
9033
  def self.read_reply: (IO input, Numeric timeout) -> String
8273
9034
 
8274
- # Relative luminance of the reported background, scaled per
8275
- # component hex width (xterm replies 4 digits per channel, others 2).
9035
+ # Light or dark, by relative luminance against a 0.5 threshold.
8276
9036
  #
8277
- # _@param_ `components` — three hex strings.
9037
+ # _@param_ `components` — red, green and blue, each 0.0..1.0.
8278
9038
  #
8279
9039
  # _@return_ — `:light` or `:dark`.
8280
- 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
8281
9049
 
8282
9050
  # `COLORFGBG` is `"fg;bg"` (rxvt sometimes `"fg;default;bg"`) with
8283
9051
  # ANSI palette indices. White-ish backgrounds — 7 (white) and the
@@ -8286,6 +9054,27 @@ module Tuile
8286
9054
  #
8287
9055
  # _@param_ `value`
8288
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
8289
9078
  end
8290
9079
 
8291
9080
  # A vertical scrollbar that computes which character to draw at each row.