tuile 0.15.0 → 0.17.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 (109) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +229 -80
  3. data/README.md +49 -24
  4. data/book/02-repaint.md +47 -19
  5. data/book/03-layout.md +98 -49
  6. data/book/04-event-loop.md +17 -16
  7. data/book/05-focus.md +106 -34
  8. data/book/06-theming.md +108 -38
  9. data/book/07-components.md +249 -46
  10. data/book/08-testing.md +134 -32
  11. data/book/10-locale.md +3 -3
  12. data/book/README.md +11 -10
  13. data/examples/file_commander.rb +52 -32
  14. data/examples/hello_world.rb +18 -5
  15. data/examples/sampler.rb +576 -146
  16. data/lib/tuile/buffer.rb +12 -1
  17. data/lib/tuile/canvas/backend.rb +46 -0
  18. data/lib/tuile/canvas.rb +212 -0
  19. data/lib/tuile/color.rb +38 -9
  20. data/lib/tuile/component/abstract_string_field.rb +96 -97
  21. data/lib/tuile/component/abstract_wrapping_field.rb +99 -58
  22. data/lib/tuile/component/big_decimal_field.rb +7 -6
  23. data/lib/tuile/component/button.rb +27 -19
  24. data/lib/tuile/component/checkbox.rb +21 -19
  25. data/lib/tuile/component/checkbox_group.rb +17 -18
  26. data/lib/tuile/component/combo_box.rb +69 -64
  27. data/lib/tuile/component/confirm_window.rb +34 -27
  28. data/lib/tuile/component/date_field.rb +50 -18
  29. data/lib/tuile/component/date_time_field.rb +319 -0
  30. data/lib/tuile/component/fill.rb +93 -0
  31. data/lib/tuile/component/float_field.rb +7 -6
  32. data/lib/tuile/component/form_item.rb +250 -0
  33. data/lib/tuile/component/form_layout.rb +206 -0
  34. data/lib/tuile/component/has_bad_input.rb +99 -28
  35. data/lib/tuile/component/has_caption.rb +14 -5
  36. data/lib/tuile/component/has_content.rb +8 -15
  37. data/lib/tuile/component/has_placeholder.rb +1 -1
  38. data/lib/tuile/component/has_validation.rb +40 -14
  39. data/lib/tuile/component/has_value.rb +71 -17
  40. data/lib/tuile/component/integer_field.rb +7 -6
  41. data/lib/tuile/component/label.rb +8 -15
  42. data/lib/tuile/component/layout/absolute.rb +86 -0
  43. data/lib/tuile/component/layout/box.rb +38 -60
  44. data/lib/tuile/component/layout.rb +127 -13
  45. data/lib/tuile/component/list.rb +233 -120
  46. data/lib/tuile/component/list_dropdown.rb +151 -91
  47. data/lib/tuile/component/menu_bar/cascade.rb +102 -32
  48. data/lib/tuile/component/menu_bar.rb +102 -82
  49. data/lib/tuile/component/notification.rb +76 -49
  50. data/lib/tuile/component/overlay.rb +217 -58
  51. data/lib/tuile/component/password_field.rb +1 -8
  52. data/lib/tuile/component/picker_window.rb +41 -17
  53. data/lib/tuile/component/popup.rb +15 -26
  54. data/lib/tuile/component/progress_bar.rb +17 -11
  55. data/lib/tuile/component/radio_group.rb +16 -17
  56. data/lib/tuile/component/scroller.rb +266 -0
  57. data/lib/tuile/component/select.rb +26 -43
  58. data/lib/tuile/component/slot.rb +4 -5
  59. data/lib/tuile/component/tab_sheet.rb +27 -34
  60. data/lib/tuile/component/tabs.rb +49 -34
  61. data/lib/tuile/component/text_area/wrapped_text.rb +1 -1
  62. data/lib/tuile/component/text_area.rb +32 -28
  63. data/lib/tuile/component/text_field.rb +68 -50
  64. data/lib/tuile/component/text_view.rb +157 -89
  65. data/lib/tuile/component/time_field.rb +51 -21
  66. data/lib/tuile/component/vertical_scroll_bar.rb +257 -0
  67. data/lib/tuile/component/window.rb +27 -26
  68. data/lib/tuile/component.rb +653 -323
  69. data/lib/tuile/component_background.rb +177 -0
  70. data/lib/tuile/component_util.rb +43 -0
  71. data/lib/tuile/event.rb +29 -0
  72. data/lib/tuile/event_queue.rb +18 -4
  73. data/lib/tuile/fake_event_queue.rb +1 -1
  74. data/lib/tuile/fake_screen.rb +120 -7
  75. data/lib/tuile/keys.rb +15 -6
  76. data/lib/tuile/layout_pass.rb +180 -0
  77. data/lib/tuile/listeners.rb +219 -0
  78. data/lib/tuile/mouse/router.rb +233 -0
  79. data/lib/tuile/mouse.rb +244 -0
  80. data/lib/tuile/point.rb +6 -0
  81. data/lib/tuile/rect.rb +33 -0
  82. data/lib/tuile/screen.rb +510 -138
  83. data/lib/tuile/screen_pane.rb +185 -67
  84. data/lib/tuile/strict_layout.rb +127 -0
  85. data/lib/tuile/styled_string.rb +144 -14
  86. data/lib/tuile/testing/gestures.rb +35 -0
  87. data/lib/tuile/testing.rb +316 -42
  88. data/lib/tuile/theme.rb +192 -53
  89. data/lib/tuile/theme_def.rb +4 -0
  90. data/lib/tuile/version.rb +1 -1
  91. data/lib/tuile.rb +53 -0
  92. data/sig/tuile.rbs +6084 -1507
  93. metadata +19 -17
  94. data/COMPARISON.md +0 -101
  95. data/DECISIONS.md +0 -8562
  96. data/TERMINOLOGY.md +0 -85
  97. data/ideas/arrow-key-navigation.md +0 -221
  98. data/ideas/binder.md +0 -177
  99. data/ideas/composite-field.md +0 -77
  100. data/ideas/focus-accent.md +0 -116
  101. data/ideas/form-layout.md +0 -151
  102. data/ideas/hover/probe.rb +0 -241
  103. data/ideas/hover/probe_spec.rb +0 -82
  104. data/ideas/hover.md +0 -909
  105. data/ideas/modal-backdrop.md +0 -24
  106. data/ideas/new-components.md +0 -144
  107. data/ideas/per-component-buffers.md +0 -55
  108. data/lib/tuile/mouse_event.rb +0 -68
  109. data/lib/tuile/vertical_scroll_bar.rb +0 -122
data/book/05-focus.md CHANGED
@@ -43,12 +43,12 @@ they're independent on purpose.
43
43
  target *at all*. It's `false` by default — a {Tuile::Component::Label} is
44
44
  decoration; clicking one shouldn't yank focus away from the window around
45
45
  it. Controls that accept input (a text field, a list, a button) override
46
- it to `true`. This gate is what makes click-to-focus sane: clicking lands
47
- focus on the component under the cursor *only if it's focusable*,
48
- otherwise the click is ignored for focus purposes. A click descends the
49
- tree — every component whose rectangle contains the point sees it, outermost
50
- first — so "the component under the cursor" is really all of them, and focus
51
- settles on the deepest focusable one. The same rule governs
46
+ it to `true`. This gate is what makes click-to-focus sane: a press lands
47
+ focus on the component under the pointer *only if it's focusable*,
48
+ otherwise it is ignored for focus purposes. The router resolves a path
49
+ down the tree — every component whose rectangle contains the point, outermost
50
+ first — so "the component under the pointer" is really all of them, and focus
51
+ settles on the deepest focusable one, before any handler runs. The same rule governs
52
52
  the automatic focus-forwarding a container does when it's focused — a
53
53
  window handed focus passes it down to its content, but only if that
54
54
  content is focusable.
@@ -111,7 +111,7 @@ sees the key. These are for app-wide actions — "Ctrl+L opens the log,"
111
111
 
112
112
  ```ruby
113
113
  screen.register_global_shortcut(Tuile::Keys::CTRL_L,
114
- hint: "^L #{screen.theme.hint("log")}") do
114
+ hint: "^L #{screen.theme.fg(:hint, "log")}") do
115
115
  log_popup.open
116
116
  end
117
117
  ```
@@ -128,15 +128,15 @@ left is yours: control keys, ESC, PgUp/PgDn, function keys. A shortcut can
128
128
  opt to fire even while a modal popup is open (`over_popups: true`); by
129
129
  default it's suppressed while a popup is up, so the popup stays modal.
130
130
 
131
- **3. `handle_key`, delivered to focus and bubbling up.** Everything else
132
- goes to the focused component's {Tuile::Component#handle_key}, and if that
131
+ **3. `handle_key?`, delivered to focus and bubbling up.** Everything else
132
+ goes to the focused component's {Tuile::Component#handle_key?}, and if that
133
133
  returns `false` (didn't handle it), the key bubbles up the ancestor chain —
134
134
  the focused component, then its parent, then *its* parent — until someone
135
135
  returns `true` or the scope root is reached. This is how a list handles
136
136
  arrow keys itself but lets an unhandled key rise to the window around it.
137
137
 
138
138
  A component only ever receives a key when it's on the focus chain, so
139
- `handle_key` implementations act on the key alone — they never need to
139
+ `handle_key?` implementations act on the key alone — they never need to
140
140
  check their own `active?` state. And if focus is `nil`, or sits outside
141
141
  the current modal scope, delivery reaches no one: that's precisely what
142
142
  makes an open modal popup modal.
@@ -165,8 +165,8 @@ the tree — and neither needs machinery, because bubbling already has the
165
165
  right shape. **Put the key on the ancestor that owns the region.**
166
166
 
167
167
  ```ruby
168
- class AppLayout < Tuile::Component::Layout::Absolute
169
- def handle_key(key)
168
+ class AppLayout < Tuile::Component::Layout
169
+ def handle_key?(key)
170
170
  case key
171
171
  when "1" then @files.focus; true
172
172
  when "2" then @log.focus; true
@@ -214,7 +214,7 @@ Ask a terminal to paste eight lines and, by default, it types them at your
214
214
  program: one byte at a time, with every line break converted to `\r`. That
215
215
  `\r` is byte-identical to the Enter you press with your finger. So a prompt
216
216
  that rebinds Enter to "submit" submits eight times, and no amount of
217
- cleverness in `handle_key` can tell the two apart — by the time the key
217
+ cleverness in `handle_key?` can tell the two apart — by the time the key
218
218
  arrives, the information is gone.
219
219
 
220
220
  The fix has to happen one layer down, at the code that talks to the
@@ -224,7 +224,7 @@ private mode 2004), which asks the terminal to wrap pasted text in
224
224
  marker, reads the payload raw up to the terminator, and posts it as a
225
225
  single `PasteEvent` — which never enters the ladder at all:
226
226
 
227
- - no Tab traversal, no global shortcuts, no `handle_key`;
227
+ - no Tab traversal, no global shortcuts, no `handle_key?`;
228
228
  - straight to {Tuile::Component#handle_paste} on the focused component —
229
229
  and *only* it: unlike a key, a paste does not bubble to ancestors, because
230
230
  the reasons a key does are all about scope-wide bindings and none of them
@@ -233,7 +233,7 @@ single `PasteEvent` — which never enters the ladder at all:
233
233
 
234
234
  The default `handle_paste` returns `false` and the text is dropped.
235
235
  {Tuile::Component::AbstractStringField} overrides it to insert at the caret
236
- as **one** mutation — so `on_change` fires once for the paste rather than
236
+ as **one** mutation — so `on_value_change` fires once for the paste rather than
237
237
  once per character, and a subclass that claims Enter needs no paste code of
238
238
  its own:
239
239
 
@@ -241,11 +241,11 @@ its own:
241
241
  class PromptTextArea < Tuile::Component::TextArea
242
242
  protected
243
243
 
244
- def handle_text_input_key(key)
244
+ def handle_text_input_key?(key)
245
245
  return super unless key == Tuile::Keys::ENTER
246
246
 
247
247
  submit(text) # a typed Enter, and only ever a typed Enter
248
- self.text = ""
248
+ self.value = ""
249
249
  true
250
250
  end
251
251
  end
@@ -260,7 +260,7 @@ def handle_paste(text)
260
260
  return super if text.lines.size < 20
261
261
 
262
262
  attach_as_file(text)
263
- self.text = "#{text.lines.size} lines attached"
263
+ self.value = "#{text.lines.size} lines attached"
264
264
  true
265
265
  end
266
266
  ```
@@ -273,15 +273,84 @@ disagree about whether a bracketed line break is `\r`, `\r\n` or `\n`, so
273
273
  Tuile settles on `\n` before the text reaches a component.
274
274
 
275
275
  `run_event_loop(bracketed_paste: false)` turns the mode off, the same way
276
- `capture_mouse: false` turns off mouse tracking. Then a paste is keystrokes
276
+ `capture_mouse: false` turns off mouse tracking — the next section has that
277
+ knob's other settings. Then a paste is keystrokes
277
278
  again, with the ambiguity that implies — reach for it only if a terminal
278
279
  mishandles the mode.
279
280
 
281
+ ## The mouse takes a different road
282
+
283
+ A key goes to whoever has focus. A press goes to whoever is *under the
284
+ pointer*, which is a different question with a different answer, so the
285
+ mouse has its own dispatcher — {Tuile::Mouse::Router} — and its own
286
+ handlers. Components no longer walk the tree for it at all:
287
+
288
+ ```ruby
289
+ class Tile < Component
290
+ def handle_mouse_down?(event) # claims the press, and is then grabbed
291
+ return false unless event.button == :left
292
+
293
+ flip
294
+ true
295
+ end
296
+ end
297
+ ```
298
+
299
+ The router resolves a path first: the topmost popup containing the point,
300
+ else the tiled content (a modal popup eats everything outside itself), then
301
+ down through the children whose rects contain it. A left press **focuses the
302
+ innermost focusable on that path before any handler runs** — you never write
303
+ click-to-focus, and you cannot forget it. Then `handle_mouse_down?` is
304
+ offered to the innermost component and **bubbles outward until one answers
305
+ `true`**, exactly as a key bubbles up the focus chain. A wheel notch travels
306
+ the same road through `handle_mouse_scroll?`, which is why a `List` scrolled
307
+ to its top answers `false` and lets the pane behind it scroll instead.
308
+
309
+ Two things follow that are easy to miss. **Geometry is the router's job, not
310
+ yours**: a widget that declared an `extent` (chapter 7) only sees presses
311
+ inside it, so a press on a `Button`'s blank tail focuses the button and fires
312
+ nothing — no hit test in your code. And **whoever claims a press is
313
+ *grabbed***: until the button comes up, `handle_mouse_up` and
314
+ `handle_mouse_drag` go to that component wherever the pointer travels,
315
+ including well outside its own rect — which is what keeps a fast drag from
316
+ escaping the widget that started it. There is no `grab_mouse` and no
317
+ `release_mouse` to call: the claim *is* the request.
318
+
319
+ Because a release can be lost — over ssh, over tmux — a widget **activates on
320
+ the press**, never on a synthesized click, and the grab also ends on the next
321
+ press or on any keystroke. Treat `handle_mouse_up` as press feedback and
322
+ drag-ending, never as the thing that runs the action.
323
+
324
+ How much of this the terminal ever sends is the `capture_mouse:` ladder, each
325
+ rung a superset of the one before:
326
+
327
+ ```ruby
328
+ screen.run_event_loop(capture_mouse: false) # nothing; native select-to-copy
329
+ screen.run_event_loop # == :clicks — presses, releases, the wheel
330
+ screen.run_event_loop(capture_mouse: :drag) # + motion while a button is held
331
+ screen.run_event_loop(capture_mouse: :hover) # + motion with none, and enter/exit
332
+ ```
333
+
334
+ `:hover` adds `handle_mouse_move?` and the argument-less
335
+ `handle_mouse_enter` / `handle_mouse_exit` pair, along with
336
+ {Tuile::Screen#hovered}. Ask for it only if something uses it: a terminal
337
+ reports up to ~84 moves a second. And keep whatever hover does *cosmetic* —
338
+ no terminal reports the pointer leaving the window, so an exit may arrive
339
+ very late, or never.
340
+
341
+ The rung is one app-wide choice, made here and nowhere else — so a component
342
+ that overrides a hover hook under a lower rung simply never hears from it, with
343
+ nothing to warn you. The sampler's *Mouse* pane answers all seven handlers on
344
+ one surface, and is why that app asks for `:hover`.
345
+
280
346
  ## Where the cursor comes in — and where it doesn't
281
347
 
282
348
  A component signals cursor ownership through
283
- {Tuile::Component#cursor_position} — return a `Point` and the terminal
284
- cursor is shown there; return `nil` (the default) and there's no cursor. A
349
+ {Tuile::Component#cursor_position} — return a `Point` in your *own*
350
+ coordinates (the ones you paint in) and the terminal cursor is shown there;
351
+ return `nil` (the default) and there's no cursor. {Tuile::Screen} is what
352
+ converts the point to a screen position, so a caret is a column and a row and
353
+ nothing more. A
285
354
  {Tuile::Component::TextField} being edited returns its caret position, so
286
355
  the caret you see blinking is the focused component's answer to that one
287
356
  question.
@@ -296,23 +365,23 @@ declaration in the system.
296
365
  ## A component that reacts to its own focus
297
366
 
298
367
  Two hooks tell a component about itself, and a component overrides them —
299
- nothing else calls them. {Tuile::Component#on_focus} fires when it gains
300
- focus; {Tuile::Component#on_blur} fires when it loses it. Both fire on that
368
+ nothing else calls them. {Tuile::Component#handle_focus} fires when it gains
369
+ focus; {Tuile::Component#handle_blur} fires when it loses it. Both fire on that
301
370
  one component, never on the ancestors that light up and go dark with it.
302
371
 
303
- `on_focus` is the forwarding hook. It's how a {Tuile::Component::Window}
372
+ `handle_focus` is the forwarding hook. It's how a {Tuile::Component::Window}
304
373
  handed focus passes it to its content, and how a {Tuile::Component::Layout}
305
374
  skips ahead to the first tab stop underneath it — which is also why it fires
306
375
  on *every* assignment, even one that re-focuses what's already focused.
307
376
 
308
- `on_blur` is the commit point. Nothing else in Tuile is one: Tab is
377
+ `handle_blur` is the commit point. Nothing else in Tuile is one: Tab is
309
378
  unconditional, so a user leaving a half-finished field usually leaves by a
310
379
  key no component ever sees, and `on_enter` never fires. If your field wants
311
380
  to tidy up what was typed, this is where:
312
381
 
313
382
  ```ruby
314
383
  class TrimmedField < Tuile::Component::TextField
315
- protected def on_blur = (self.text = text.strip)
384
+ protected def handle_blur = (self.value = text.strip)
316
385
  end
317
386
  ```
318
387
 
@@ -331,7 +400,7 @@ nothing; and `screen.close` blurs the focused component on its way out. Keep
331
400
  the handler cheap and it won't matter.
332
401
 
333
402
  The framework sends both hooks with `__send__`, so declare them public,
334
- protected or private as you like — the example above groups `on_blur` under
403
+ protected or private as you like — the example above groups `handle_blur` under
335
404
  `protected`, which is where framework-invoked plumbing belongs.
336
405
 
337
406
  ## Writing a status line
@@ -344,7 +413,7 @@ A status line is a `Label` in your layout. That's the whole idea:
344
413
 
345
414
  ```ruby
346
415
  status = Tuile::Component::Label.new
347
- status.text = "q #{screen.theme.hint("quit")} Tab #{screen.theme.hint("Switch")}"
416
+ status.text = "q #{screen.theme.fg(:hint, "quit")} Tab #{screen.theme.fg(:hint, "Switch")}"
348
417
 
349
418
  root = Tuile::Component::Layout::Vertical.new
350
419
  root.add(main_ui, Tuile::Component::Layout::Expand[1])
@@ -357,10 +426,13 @@ is exactly this: Tab, Enter and Backspace work in both panes, so its row is
357
426
  a constant. Reaching for a focus callback there would be machinery computing
358
427
  a value that never changes.
359
428
 
360
- Two details about the text itself. `theme.hint(...)` styles the descriptive
361
- half of a `key what` pair so hints look consistent (chapter 6), and it
362
- **bakes the color in** — so a label built from it rebuilds itself from
363
- `on_theme_changed` to follow a light/dark flip. And keys registered with
429
+ Two details about the text itself. The `:hint` above is one of *your*
430
+ `custom` theme tokens, not one of Tuile's — the framework colors only the
431
+ chrome it paints, and this row isn't its. Chapter 6 shows the four lines
432
+ that define the pair; the shade to reach for is a grey dimmer than the
433
+ terminal's own foreground, so the *key* is what pulls the eye. `theme.fg`
434
+ also **bakes the color in**, so a label built from it rebuilds itself from
435
+ its `on_theme_changed` slot to follow a light/dark flip. And keys registered with
364
436
  {Tuile::Screen#register_global_shortcut} don't advertise themselves: the
365
437
  registry runs actions, it doesn't describe them, so a `^K menu` in your row
366
438
  is text you write next to the registration.
@@ -369,10 +441,10 @@ is text you write next to the registration.
369
441
 
370
442
  Some apps genuinely show different keys in different places — a window with
371
443
  a search mode, or a pane whose commands only apply to it. For those,
372
- {Tuile::Screen#on_focus_changed=} is the notification:
444
+ {Tuile::Screen#on_focus_changed} is the notification:
373
445
 
374
446
  ```ruby
375
- screen.on_focus_changed = -> { status.text = hint_for(screen.focused) }
447
+ screen.on_focus_changed { status.text = hint_for(screen.focused) }
376
448
  ```
377
449
 
378
450
  It fires after every focus *change* — to and from `nil` included, and after
data/book/06-theming.md CHANGED
@@ -24,10 +24,9 @@ defaults for free.
24
24
  What Tuile *does* color is the small set of cues that signal
25
25
  interaction: the highlight behind the focused list row, the border of the
26
26
  active window, the resting "well" of a text field, the scrollbar down a
27
- scrollable pane's edge, the shortcut captions
28
- in a status line you write. Those are the accents, and they are exactly the tokens
27
+ scrollable pane's edge. Those are the accents, and they are exactly the tokens
29
28
  a {Tuile::Theme} carries — `active_bg_color`, `active_border_color`,
30
- `input_bg_color`, `scrollbar_color`, `hint_color`, the three that mark a field
29
+ `input_bg_color`, `scrollbar_color`, the three that mark a field
31
30
  invalid (`error_color` and the two error wells), and `placeholder_color` for
32
31
  the hint an empty field paints into itself. There is no global `bg` or `fg` token,
33
32
  and that absence is intentional: adding one would mean painting over the
@@ -36,12 +35,16 @@ TUI look wrong on someone else's color scheme. The theme touches only
36
35
  what the framework must color to be legible, and leaves the rest to the
37
36
  terminal.
38
37
 
39
- Two of them are worth a second look, because they pull in opposite
40
- directions. `hint_color` is an *accent* — a blue that draws the eye to a
41
- shortcut caption you want noticed. `placeholder_color` is its temperamental
42
- opposite: a grey tuned to sit just above invisible, because a placeholder is
43
- a hint the reader is welcome to miss. Reaching for the wrong one of the two
44
- makes an empty field louder than a filled one.
38
+ Note what that list does *not* include: a color for secondary text. The
39
+ shortcut captions in a status line are a good example — they want to be
40
+ dimmer than the keys beside them, and Tuile has no opinion about how dim,
41
+ because Tuile does not paint them. A status line is yours (the framework
42
+ reserves no row for one), so its shades are yours too, and they go in
43
+ `custom` — which the next section covers. The one token that *looks* like
44
+ a secondary-text color is `placeholder_color`, and it isn't one: it is
45
+ tuned to sit just above invisible, for the specific case of a hint the
46
+ reader is welcome to miss, on the specific background of a field's own
47
+ well.
45
48
 
46
49
  So a {Tuile::Theme} is a frozen value type — a `Data.define` of colors
47
50
  plus an app-extensible `custom` hash — and that's all. Two are
@@ -87,17 +90,17 @@ prompt. You *can* name the panel's colour again on the field — but there is a
87
90
  shorter way to say "I have no background of my own, use whatever is behind me":
88
91
 
89
92
  ```ruby
90
- field.bg_color = Component::BG_INHERIT
93
+ field.bg_color = ComponentBackground::INHERIT
91
94
  ```
92
95
 
93
96
  That is CSS's `background: inherit`, and it is different from leaving
94
97
  `bg_color` unset: unset means "ask *my* default first", which for a field is
95
- its well. `BG_INHERIT` skips the well and goes straight to what surrounds it.
98
+ its well. `ComponentBackground::INHERIT` skips the well and goes straight to what surrounds it.
96
99
 
97
100
  It is the same mechanism Tuile uses internally. A
98
101
  {Tuile::Component::ComboBox} is one widget with one surface, built out of a
99
102
  {Tuile::Component::TextField} plus a `▾` — so the ComboBox paints the well and
100
- marks its inner field `BG_INHERIT`. Exactly one well per widget, which is what
103
+ marks its inner field `ComponentBackground::INHERIT`. Exactly one well per widget, which is what
101
104
  lets you tint the ComboBox and have the tint reach the cells the field draws.
102
105
 
103
106
  Backgrounds can differ by state. An input is brighter while it holds focus,
@@ -122,7 +125,7 @@ above mean what it reads as.
122
125
  reference* — `Theme.ref(:panel_bg)` — that names one of your app's custom
123
126
  tokens and re-resolves it against the current theme on every paint. The
124
127
  reference is the ergonomic path: assign it once and the panel follows
125
- light and dark on its own, with no `on_theme_changed` handler. That works
128
+ light and dark on its own, with no `handle_theme_changed` handler. That works
126
129
  precisely because a background — unlike the baked-in colors of your
127
130
  *content* (below) — is resolved *live* at paint, exactly like the
128
131
  framework's own accents; `bg_color` is a single value read late, so
@@ -163,14 +166,15 @@ colors. Done.
163
166
  When a component has a themed color in hand, it applies it in one of two
164
167
  ways, and which one depends on the text.
165
168
 
166
- For plain chrome — a border string, a status-bar hint — the theme's
169
+ For plain chrome — a border string, a button caption — the theme's
167
170
  **rendering helpers** wrap the text in the token's SGR color and a reset:
168
- `theme.active_bg("[ Ok ]")`, `theme.hint("quit")`. The helper picks the
169
- right channel for the token's role (a `*_bg` token wraps as a background,
170
- a hint as a foreground) and passes the content through verbatim, so the
171
- string may already contain other escape sequences — which is how
172
- {Tuile::Component::Window} feeds its whole border row, cursor moves and
173
- all, through `active_border`.
171
+ `theme.active_bg("[ Ok ]")`, `theme.active_border("┌──┐")`, and
172
+ `theme.fg(:hint, "quit")` for one of your own `custom` tokens. The helper
173
+ picks the right channel for the token's role (a `*_bg` token wraps as a
174
+ background, a border as a foreground) and passes the content through
175
+ verbatim, so the string may already contain other escape sequences — which
176
+ is how {Tuile::Component::Window} feeds its whole border row, cursor moves
177
+ and all, through `active_border`.
174
178
 
175
179
  But chrome text is flat. Content is not. A list row or a label may be a
176
180
  {Tuile::StyledString} with its own per-span colors, and wrapping that in
@@ -234,12 +238,30 @@ background, and Tuile has it: the OSC 11 reply carries the RGB, and
234
238
  {Tuile::Screen}`#background_color` hands it to you as a
235
239
  {Tuile::Color}.
236
240
 
241
+ The place to use it is the theme itself. A token may be a Proc of the
242
+ background instead of a fixed {Tuile::Color}, and the screen calls it for
243
+ you:
244
+
237
245
  ```ruby
238
- bg = Tuile::Screen.instance.background_color
239
- sidebar.bg_color =
240
- bg ? Tuile::Color.rgb(*bg.value.map { (_1 + 10).clamp(0, 255) }) : FALLBACK_TINT
246
+ LIFT = ->(color, by) { Tuile::Color.rgb(*color.rgb.map { (_1 + by).clamp(0, 255) }) }
247
+
248
+ APP_THEME = Tuile::ThemeDef.new(
249
+ dark: Tuile::Theme::DARK.with(custom: {
250
+ pane_bg: ->(bg) { bg ? LIFT.call(bg, 10) : FALLBACK_TINT },
251
+ pane_frame: ->(_bg, t) { LIFT.call(t[:pane_bg], 20) }
252
+ }),
253
+ light: …
254
+ )
255
+ screen.theme_def = APP_THEME
256
+ sidebar.bg_color = Tuile::Theme.ref(:pane_bg)
241
257
  ```
242
258
 
259
+ The arithmetic is yours — Tuile ships no `lighten`, because how far to
260
+ step, in which direction, and whether to step at all is a design choice,
261
+ not a fact about the terminal. The second parameter, when a Proc asks for
262
+ it, reads the other tokens, so a hairline can be derived from the pane it
263
+ sits on, whatever order you declared them in.
264
+
243
265
  That `FALLBACK_TINT` is not defensive padding — it's the branch you
244
266
  should expect to hit. Plenty of terminals answer neither probe, and the
245
267
  `COLORFGBG` fallback reports a palette *index* with no RGB behind it, so
@@ -252,12 +274,21 @@ more round trip than you might expect. The mode-2031 report says only
252
274
  "the OS is light now" — it carries no RGB — so when the screen sees one,
253
275
  it writes the OSC 11 query again, and the reply comes back through the
254
276
  key thread as another event. The new color therefore lands a frame after
255
- the new theme. When it does, Tuile fires
256
- {Tuile::Component}`#on_theme_changed` across the tree exactly as a theme
257
- swap does, on the reasoning that a tint derived from the background *is*
258
- a theme-derived color, and that hook is already where you rebuild those.
259
- So the same override handles both halves of a flip, and you don't need to
260
- know which one woke you.
277
+ the new theme. When it does, the screen calls every derived token again,
278
+ producing a fresh, fully concrete {Tuile::Screen}`#theme`, and fires
279
+ {Tuile::Component}`#handle_theme_changed` across the tree once, exactly as a
280
+ theme swap does. A `Theme.ref` slot follows with no code of yours; content
281
+ you baked from the theme rebuilds in the same hook it always did, and you
282
+ don't need to know which half of the flip woke you.
283
+
284
+ Why a Proc in the theme, rather than a color that knows how to recompute
285
+ itself? Because {Tuile::Color} is a value: the back buffer decides whether a
286
+ cell changed by comparing colors, and a color that answered differently
287
+ depending on the terminal would compare equal to itself while painting
288
+ something new. Deriving once, when the inputs change, keeps every color
289
+ Tuile paints with a plain value — and the tree walks once per change,
290
+ instead of an app re-assigning its theme from inside the very hook the
291
+ walk is calling.
261
292
 
262
293
  ## Not every terminal can show what you computed
263
294
 
@@ -353,7 +384,21 @@ one with `theme[:accent]`, which **fail-fasts**: a typo'd token raises
353
384
  `KeyError` rather than silently painting a default, so a missing color is
354
385
  a loud bug and not a mystery. And you render with the generic `fg` / `bg`
355
386
  helpers — `theme.fg(:accent, "NEW")` — the custom-token counterparts of
356
- the built-in `hint` / `active_bg` helpers.
387
+ the built-in `active_border` / `active_bg` helpers.
388
+
389
+ This is where a status line's shades belong, and it is worth doing even
390
+ for a single token. The examples that ship with Tuile all carry one
391
+ `hint` grey for the descriptive half of their `"q quit"` rows, paired
392
+ dark and light so the row follows an appearance flip:
393
+
394
+ ```ruby
395
+ APP_THEME = Tuile::ThemeDef.new(
396
+ dark: Tuile::Theme::DARK.with(custom: { hint: Tuile::Color::GREY54 }),
397
+ light: Tuile::Theme::LIGHT.with(custom: { hint: Tuile::Color::GREY62 })
398
+ )
399
+ screen.theme_def = APP_THEME
400
+ status.text = "q #{screen.theme.fg(:hint, "quit")}"
401
+ ```
357
402
 
358
403
  For an app with more than a couple of custom tokens, the tidier move is
359
404
  to **subclass** {Tuile::Theme} and give each token a named coloring
@@ -422,21 +467,46 @@ because only *you* know which of the string's colors came from the theme
422
467
  versus which are inherent to the data (a log line's level color, say,
423
468
  should *not* follow the theme).
424
469
 
425
- The hook for this is {Tuile::Component#on_theme_changed}, fired on every
470
+ The hook for this is {Tuile::Component#handle_theme_changed}, fired on every
426
471
  attached component whenever the theme changes. Your handler does exactly
427
472
  one thing: **re-run the code that rendered the content**, so it rebuilds
428
473
  the StyledString against the now-current theme.
429
474
 
475
+ There are two ways to consume it, matching how you built the component, and
476
+ they are two different names: `on_` for the slot, `handle_` for the override. If
477
+ you assembled stock components, register on the `on_theme_changed` **listener
478
+ slot** — the reader *is* the registrar, and a slot holds as many listeners as
479
+ you give it:
480
+
481
+ ```ruby
482
+ label.on_theme_changed { label.text = render_status_line }
483
+ ```
484
+
485
+ There is no `on_theme_changed=`, deliberately: nothing you register can displace
486
+ what the widget — or another part of your app — already wired there. Hold what
487
+ `on_theme_changed` returns you if you mean to take it back off later:
488
+
489
+ ```ruby
490
+ cb = label.on_theme_changed { … }
491
+ label.on_theme_changed.remove(cb)
492
+ ```
493
+
494
+ If you subclassed, override `handle_theme_changed`, the **override point**
495
+ — and call `super`, so registered listeners still fire:
496
+
430
497
  ```ruby
431
- label.on_theme_changed = -> { label.text = render_status_line }
498
+ class StatusLabel < Tuile::Component::Label
499
+ protected def handle_theme_changed
500
+ super
501
+ self.text = render_status_line
502
+ end
503
+ end
432
504
  ```
433
505
 
434
- There are two ways to consume it, matching how you built the component.
435
- If you assembled stock components, assign the `on_theme_changed=` proc as
436
- above. If you subclassed, override the method — and call `super`, so an
437
- assigned listener still fires. Either way the rule is the same: the hook
438
- is where theme-derived content gets rebuilt, and the framework handles
439
- everything else.
506
+ That pair is the house rule across the whole widget set, not a special
507
+ case for theming. Either way the rule here is the same — the hook is where
508
+ theme-derived content gets rebuilt, and the framework handles everything
509
+ else.
440
510
 
441
511
  ---
442
512