tuile 0.14.0 → 0.16.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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +159 -49
  3. data/README.md +53 -17
  4. data/book/04-event-loop.md +12 -12
  5. data/book/05-focus.md +152 -22
  6. data/book/06-theming.md +105 -25
  7. data/book/07-components.md +531 -50
  8. data/book/08-testing.md +100 -20
  9. data/book/10-locale.md +216 -0
  10. data/book/README.md +19 -9
  11. data/examples/file_commander.rb +14 -5
  12. data/examples/hello_world.rb +17 -4
  13. data/examples/sampler.rb +654 -40
  14. data/lib/tuile/component/abstract_string_field.rb +114 -68
  15. data/lib/tuile/component/abstract_wrapping_field.rb +279 -0
  16. data/lib/tuile/component/big_decimal_field.rb +52 -79
  17. data/lib/tuile/component/button.rb +8 -8
  18. data/lib/tuile/component/checkbox.rb +9 -9
  19. data/lib/tuile/component/checkbox_group.rb +38 -21
  20. data/lib/tuile/component/combo_box.rb +102 -59
  21. data/lib/tuile/component/confirm_window.rb +7 -5
  22. data/lib/tuile/component/date_field.rb +347 -0
  23. data/lib/tuile/component/date_time_field.rb +275 -0
  24. data/lib/tuile/component/float_field.rb +57 -82
  25. data/lib/tuile/component/has_bad_input.rb +88 -0
  26. data/lib/tuile/component/has_caption.rb +8 -0
  27. data/lib/tuile/component/has_content.rb +32 -13
  28. data/lib/tuile/component/has_placeholder.rb +62 -0
  29. data/lib/tuile/component/has_validation.rb +115 -0
  30. data/lib/tuile/component/has_value.rb +28 -1
  31. data/lib/tuile/component/integer_field.rb +51 -78
  32. data/lib/tuile/component/label.rb +7 -39
  33. data/lib/tuile/component/layout/box.rb +90 -19
  34. data/lib/tuile/component/layout.rb +15 -5
  35. data/lib/tuile/component/list.rb +53 -38
  36. data/lib/tuile/component/list_dropdown.rb +7 -3
  37. data/lib/tuile/component/menu_bar/cascade.rb +5 -5
  38. data/lib/tuile/component/menu_bar.rb +18 -18
  39. data/lib/tuile/component/notification.rb +32 -18
  40. data/lib/tuile/component/overlay.rb +26 -8
  41. data/lib/tuile/component/picker_window.rb +27 -8
  42. data/lib/tuile/component/popup.rb +2 -2
  43. data/lib/tuile/component/progress_bar.rb +10 -4
  44. data/lib/tuile/component/radio_group.rb +41 -23
  45. data/lib/tuile/component/select.rb +23 -16
  46. data/lib/tuile/component/slot.rb +3 -3
  47. data/lib/tuile/component/tab_sheet.rb +6 -6
  48. data/lib/tuile/component/tabs.rb +11 -11
  49. data/lib/tuile/component/text_area.rb +26 -18
  50. data/lib/tuile/component/text_field.rb +55 -26
  51. data/lib/tuile/component/text_view.rb +40 -19
  52. data/lib/tuile/component/time_field.rb +479 -0
  53. data/lib/tuile/component/window.rb +26 -13
  54. data/lib/tuile/component.rb +635 -131
  55. data/lib/tuile/event_queue.rb +4 -4
  56. data/lib/tuile/fake_event_queue.rb +1 -1
  57. data/lib/tuile/fake_screen.rb +95 -3
  58. data/lib/tuile/final.rb +75 -0
  59. data/lib/tuile/locale.rb +851 -0
  60. data/lib/tuile/mouse/router.rb +217 -0
  61. data/lib/tuile/mouse.rb +177 -0
  62. data/lib/tuile/screen.rb +219 -68
  63. data/lib/tuile/screen_pane.rb +51 -42
  64. data/lib/tuile/styled_string.rb +5 -5
  65. data/lib/tuile/testing.rb +198 -0
  66. data/lib/tuile/theme.rb +110 -32
  67. data/lib/tuile/version.rb +1 -1
  68. data/lib/tuile/vertical_scroll_bar.rb +88 -12
  69. data/lib/tuile.rb +1 -0
  70. data/sig/tuile.rbs +4595 -825
  71. metadata +14 -9
  72. data/COMPARISON.md +0 -101
  73. data/DECISIONS.md +0 -5422
  74. data/TERMINOLOGY.md +0 -71
  75. data/ideas/arrow-key-navigation.md +0 -221
  76. data/ideas/modal-backdrop.md +0 -24
  77. data/ideas/new-components.md +0 -124
  78. data/ideas/per-component-buffers.md +0 -55
  79. data/lib/tuile/mouse_event.rb +0 -68
@@ -188,28 +188,28 @@ class Spinner < Tuile::Component::Label
188
188
 
189
189
  protected
190
190
 
191
- def on_attached
191
+ def handle_attached
192
192
  @ticker = screen.event_queue.tick_fps(8) { |n| self.text = FRAMES[n % FRAMES.size] }
193
193
  end
194
194
 
195
- def on_detached
195
+ def handle_detached
196
196
  @ticker&.cancel
197
197
  @ticker = nil
198
198
  end
199
199
  end
200
200
  ```
201
201
 
202
- `on_attached` fires the moment this component's tree is mounted on the
203
- screen; `on_detached` fires the moment it's unmounted. Add the spinner to a
202
+ `handle_attached` fires the moment this component's tree is mounted on the
203
+ screen; `handle_detached` fires the moment it's unmounted. Add the spinner to a
204
204
  popup and it starts; close the popup and it stops. Nothing at the call site
205
205
  remembers anything — `popup.close` is the whole teardown.
206
206
 
207
- The contract is a mirror: **`on_attached` starts what `on_detached` stops.**
207
+ The contract is a mirror: **`handle_attached` starts what `handle_detached` stops.**
208
208
  Keep both cheap and idempotent, because a component *moved* from one parent
209
- to another gets `on_detached` and then `on_attached` — between those two
209
+ to another gets `handle_detached` and then `handle_attached` — between those two
210
210
  calls it genuinely is off the screen, possibly for a long time, so stopping
211
211
  and restarting is the honest thing to do. And whatever you acquire in
212
- `on_attached` you must release in `on_detached`, because nothing else will.
212
+ `handle_attached` you must release in `handle_detached`, because nothing else will.
213
213
 
214
214
  This generalizes well beyond tickers, and the interesting case is
215
215
  subscriptions. A component may depend on a service, but a service must never
@@ -226,11 +226,11 @@ class BuildStatus < Tuile::Component::Label
226
226
 
227
227
  protected
228
228
 
229
- def on_attached
229
+ def handle_attached
230
230
  @subscription = @service.on_change { |s| screen.event_queue.submit { self.text = s } }
231
231
  end
232
232
 
233
- def on_detached
233
+ def handle_detached
234
234
  @subscription&.unsubscribe
235
235
  @subscription = nil
236
236
  end
@@ -245,16 +245,16 @@ screen, and no view-closing code path has to know that the subscription
245
245
  exists at all.
246
246
 
247
247
  `screen.close` counts as unmounting, so the `screen.close` at the end of
248
- your `main` gives every component still on screen its `on_detached` — the
248
+ your `main` gives every component still on screen its `handle_detached` — the
249
249
  tickers stop, the subscriptions come off, and you didn't write any of that
250
250
  teardown. What *doesn't* fire is a process that exits without closing the
251
251
  screen at all: these are lifecycle hooks, not destructors, and Tuile
252
- installs no `at_exit`. If your `on_detached` does something that matters
252
+ installs no `at_exit`. If your `handle_detached` does something that matters
253
253
  beyond the process — flushing a file, say — close the screen deliberately
254
254
  rather than relying on exit.
255
255
 
256
256
  The other thing the hooks are not is a place to do layout. When
257
- `on_attached` runs, your parent hasn't assigned your `rect` yet. If you need
257
+ `handle_attached` runs, your parent hasn't assigned your `rect` yet. If you need
258
258
  to paint, invalidate here and do the work in `repaint`, which is what
259
259
  chapter 2 was about anyway.
260
260
 
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.
@@ -73,6 +73,24 @@ that scope in tree order and advances by one, wrapping around; Shift+Tab
73
73
  walks backward. This is what keeps Tab from escaping an open dialog — the
74
74
  scope is the dialog, so cycling stays inside it.
75
75
 
76
+ There is a third gate, and it isn't a predicate you override:
77
+ {Tuile::Component#visible?}. A hidden component — or any component under a
78
+ hidden one — is skipped by Tab, by click-to-focus, and by the forwarding a
79
+ container does, because it is skipped by the walk that collects candidates
80
+ at all. You never write a `visible?` check yourself; chapter 7 covers what
81
+ the flag is for.
82
+
83
+ The rule to know here is what happens when you hide the thing that
84
+ currently *has* focus. Focus does not stay there, and it does not become
85
+ nothing either: it moves to the hidden component's parent, which forwards
86
+ it on to the first field it can reach — exactly what happens when a
87
+ component is removed from the tree. Nothing is stashed, so bringing the
88
+ component back does not bring focus back with it. That is the same
89
+ behaviour a browser and every desktop toolkit settle on, and the reason
90
+ Tuile can't simply drop focus is worth knowing: with focus at nothing, keys
91
+ reach nobody at all, so an unhandled `q` would fall through to the event
92
+ loop and quit your app.
93
+
76
94
  ## The dispatch order
77
95
 
78
96
  When a key arrives, it's offered to the tree in a fixed order, and the
@@ -93,7 +111,7 @@ sees the key. These are for app-wide actions — "Ctrl+L opens the log,"
93
111
 
94
112
  ```ruby
95
113
  screen.register_global_shortcut(Tuile::Keys::CTRL_L,
96
- hint: "^L #{screen.theme.hint("log")}") do
114
+ hint: "^L #{screen.theme.fg(:hint, "log")}") do
97
115
  log_popup.open
98
116
  end
99
117
  ```
@@ -110,15 +128,15 @@ left is yours: control keys, ESC, PgUp/PgDn, function keys. A shortcut can
110
128
  opt to fire even while a modal popup is open (`over_popups: true`); by
111
129
  default it's suppressed while a popup is up, so the popup stays modal.
112
130
 
113
- **3. `handle_key`, delivered to focus and bubbling up.** Everything else
114
- 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
115
133
  returns `false` (didn't handle it), the key bubbles up the ancestor chain —
116
134
  the focused component, then its parent, then *its* parent — until someone
117
135
  returns `true` or the scope root is reached. This is how a list handles
118
136
  arrow keys itself but lets an unhandled key rise to the window around it.
119
137
 
120
138
  A component only ever receives a key when it's on the focus chain, so
121
- `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
122
140
  check their own `active?` state. And if focus is `nil`, or sits outside
123
141
  the current modal scope, delivery reaches no one: that's precisely what
124
142
  makes an open modal popup modal.
@@ -148,7 +166,7 @@ right shape. **Put the key on the ancestor that owns the region.**
148
166
 
149
167
  ```ruby
150
168
  class AppLayout < Tuile::Component::Layout::Absolute
151
- def handle_key(key)
169
+ def handle_key?(key)
152
170
  case key
153
171
  when "1" then @files.focus; true
154
172
  when "2" then @log.focus; true
@@ -196,7 +214,7 @@ Ask a terminal to paste eight lines and, by default, it types them at your
196
214
  program: one byte at a time, with every line break converted to `\r`. That
197
215
  `\r` is byte-identical to the Enter you press with your finger. So a prompt
198
216
  that rebinds Enter to "submit" submits eight times, and no amount of
199
- 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
200
218
  arrives, the information is gone.
201
219
 
202
220
  The fix has to happen one layer down, at the code that talks to the
@@ -206,9 +224,11 @@ private mode 2004), which asks the terminal to wrap pasted text in
206
224
  marker, reads the payload raw up to the terminator, and posts it as a
207
225
  single `PasteEvent` — which never enters the ladder at all:
208
226
 
209
- - no Tab traversal, no global shortcuts, no `handle_key`;
210
- - straight to {Tuile::Component#handle_paste}, delivered down the focus
211
- chain and bubbling exactly like a key;
227
+ - no Tab traversal, no global shortcuts, no `handle_key?`;
228
+ - straight to {Tuile::Component#handle_paste} on the focused component —
229
+ and *only* it: unlike a key, a paste does not bubble to ancestors, because
230
+ the reasons a key does are all about scope-wide bindings and none of them
231
+ wants a clipboard;
212
232
  - the whole clipboard as one `String`, `\n`-normalized.
213
233
 
214
234
  The default `handle_paste` returns `false` and the text is dropped.
@@ -221,7 +241,7 @@ its own:
221
241
  class PromptTextArea < Tuile::Component::TextArea
222
242
  protected
223
243
 
224
- def handle_text_input_key(key)
244
+ def handle_text_input_key?(key)
225
245
  return super unless key == Tuile::Keys::ENTER
226
246
 
227
247
  submit(text) # a typed Enter, and only ever a typed Enter
@@ -253,10 +273,76 @@ disagree about whether a bracketed line break is `\r`, `\r\n` or `\n`, so
253
273
  Tuile settles on `\n` before the text reaches a component.
254
274
 
255
275
  `run_event_loop(bracketed_paste: false)` turns the mode off, the same way
256
- `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
257
278
  again, with the ambiguity that implies — reach for it only if a terminal
258
279
  mishandles the mode.
259
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
+
260
346
  ## Where the cursor comes in — and where it doesn't
261
347
 
262
348
  A component signals cursor ownership through
@@ -273,6 +359,47 @@ needed. With dispatch resting on nothing but "did you return `true`," the
273
359
  proxy is gone, and a component's decision to consume a key is the only
274
360
  declaration in the system.
275
361
 
362
+ ## A component that reacts to its own focus
363
+
364
+ Two hooks tell a component about itself, and a component overrides them —
365
+ nothing else calls them. {Tuile::Component#handle_focus} fires when it gains
366
+ focus; {Tuile::Component#handle_blur} fires when it loses it. Both fire on that
367
+ one component, never on the ancestors that light up and go dark with it.
368
+
369
+ `handle_focus` is the forwarding hook. It's how a {Tuile::Component::Window}
370
+ handed focus passes it to its content, and how a {Tuile::Component::Layout}
371
+ skips ahead to the first tab stop underneath it — which is also why it fires
372
+ on *every* assignment, even one that re-focuses what's already focused.
373
+
374
+ `handle_blur` is the commit point. Nothing else in Tuile is one: Tab is
375
+ unconditional, so a user leaving a half-finished field usually leaves by a
376
+ key no component ever sees, and `on_enter` never fires. If your field wants
377
+ to tidy up what was typed, this is where:
378
+
379
+ ```ruby
380
+ class TrimmedField < Tuile::Component::TextField
381
+ protected def handle_blur = (self.text = text.strip)
382
+ end
383
+ ```
384
+
385
+ Both hooks fire on one component, so a *composed* widget — a
386
+ {Tuile::Component::ComboBox}, whose inner field is what actually holds focus —
387
+ gets the blur on the child, not on itself. When the question is "has focus left
388
+ this whole widget", override `active=` instead and compare before and after;
389
+ that's exactly what the combo box does to close its dropdown when you tab away.
390
+
391
+ It is a notification, not a veto — focus has already moved by the time you
392
+ hear about it, and a handler that tries to hold focus is picking a fight with
393
+ the one key nothing can suppress. It also fires wherever focus is *dropped*,
394
+ not only where a user moved it: when a popup closes, the component being
395
+ blurred has usually been detached already, so an `invalidate` there does
396
+ nothing; and `screen.close` blurs the focused component on its way out. Keep
397
+ the handler cheap and it won't matter.
398
+
399
+ The framework sends both hooks with `__send__`, so declare them public,
400
+ protected or private as you like — the example above groups `handle_blur` under
401
+ `protected`, which is where framework-invoked plumbing belongs.
402
+
276
403
  ## Writing a status line
277
404
 
278
405
  Chapter 1 pointed out that Tuile draws no status bar. This is the chapter
@@ -283,7 +410,7 @@ A status line is a `Label` in your layout. That's the whole idea:
283
410
 
284
411
  ```ruby
285
412
  status = Tuile::Component::Label.new
286
- status.text = "q #{screen.theme.hint("quit")} Tab #{screen.theme.hint("Switch")}"
413
+ status.text = "q #{screen.theme.fg(:hint, "quit")} Tab #{screen.theme.fg(:hint, "Switch")}"
287
414
 
288
415
  root = Tuile::Component::Layout::Vertical.new
289
416
  root.add(main_ui, Tuile::Component::Layout::Expand[1])
@@ -296,10 +423,13 @@ is exactly this: Tab, Enter and Backspace work in both panes, so its row is
296
423
  a constant. Reaching for a focus callback there would be machinery computing
297
424
  a value that never changes.
298
425
 
299
- Two details about the text itself. `theme.hint(...)` styles the descriptive
300
- half of a `key what` pair so hints look consistent (chapter 6), and it
301
- **bakes the color in** so a label built from it rebuilds itself from
302
- `on_theme_changed` to follow a light/dark flip. And keys registered with
426
+ Two details about the text itself. The `:hint` above is one of *your*
427
+ `custom` theme tokens, not one of Tuile's the framework colors only the
428
+ chrome it paints, and this row isn't its. Chapter 6 shows the four lines
429
+ that define the pair; the shade to reach for is a grey dimmer than the
430
+ terminal's own foreground, so the *key* is what pulls the eye. `theme.fg`
431
+ also **bakes the color in**, so a label built from it rebuilds itself from
432
+ its `on_theme_changed` slot to follow a light/dark flip. And keys registered with
303
433
  {Tuile::Screen#register_global_shortcut} don't advertise themselves: the
304
434
  registry runs actions, it doesn't describe them, so a `^K menu` in your row
305
435
  is text you write next to the registration.
data/book/06-theming.md CHANGED
@@ -23,18 +23,31 @@ defaults for free.
23
23
 
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
- active window, the resting "well" of a text field, the shortcut captions
27
- in a status line you write. Those are the accents, and they are exactly the tokens
26
+ active window, the resting "well" of a text field, the scrollbar down a
27
+ scrollable pane's edge. Those are the accents, and they are exactly the tokens
28
28
  a {Tuile::Theme} carries — `active_bg_color`, `active_border_color`,
29
- `input_bg_color`, `hint_color`. There is no global `bg` or `fg` token,
29
+ `input_bg_color`, `scrollbar_color`, the three that mark a field
30
+ invalid (`error_color` and the two error wells), and `placeholder_color` for
31
+ the hint an empty field paints into itself. There is no global `bg` or `fg` token,
30
32
  and that absence is intentional: adding one would mean painting over the
31
33
  terminal's defaults everywhere, which is precisely the thing that makes a
32
34
  TUI look wrong on someone else's color scheme. The theme touches only
33
35
  what the framework must color to be legible, and leaves the rest to the
34
36
  terminal.
35
37
 
36
- So a {Tuile::Theme} is a frozen value type a `Data.define` of four
37
- colors plus an app-extensible `custom` hashand that's all. Two are
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.
48
+
49
+ So a {Tuile::Theme} is a frozen value type — a `Data.define` of colors
50
+ plus an app-extensible `custom` hash — and that's all. Two are
38
51
  built in: {Tuile::Theme::DARK}, the colors Tuile has always used, and
39
52
  {Tuile::Theme::LIGHT}, counterparts legible on a pale background.
40
53
 
@@ -66,15 +79,53 @@ terminal default is just the root of that chain, which is why an unset
66
79
 
67
80
  A widget with a background of its *own* keeps it. A text field paints its
68
81
  well across its whole rect, so dropping one into a tinted panel shows the
69
- field in its own well, not the panel tint — the explicit background wins
70
- over the inherited one, the terminal equivalent of a CSS element that sets
71
- its own `background`.
82
+ field in its own well, not the panel tint — the terminal equivalent of a CSS
83
+ element that sets its own `background`. That is a *default*, though, not a
84
+ refusal: set `bg_color` on the field itself and it wins, because the chain
85
+ asks three questions in order — what did the app set on this component, what
86
+ background does this widget claim of its own, and what surrounds it.
87
+
88
+ Which is also how you get a field that reads as plain text inside a tinted
89
+ prompt. You *can* name the panel's colour again on the field — but there is a
90
+ shorter way to say "I have no background of my own, use whatever is behind me":
91
+
92
+ ```ruby
93
+ field.bg_color = Component::BG_INHERIT
94
+ ```
95
+
96
+ That is CSS's `background: inherit`, and it is different from leaving
97
+ `bg_color` unset: unset means "ask *my* default first", which for a field is
98
+ its well. `BG_INHERIT` skips the well and goes straight to what surrounds it.
99
+
100
+ It is the same mechanism Tuile uses internally. A
101
+ {Tuile::Component::ComboBox} is one widget with one surface, built out of a
102
+ {Tuile::Component::TextField} plus a `▾` — so the ComboBox paints the well and
103
+ marks its inner field `BG_INHERIT`. Exactly one well per widget, which is what
104
+ lets you tint the ComboBox and have the tint reach the cells the field draws.
105
+
106
+ Backgrounds can differ by state. An input is brighter while it holds focus,
107
+ and a flat `bg_color` replaces *both* shades — fine for a field, which shows a
108
+ caret when focused, and a deliberate choice for something like a
109
+ {Tuile::Component::Select}, which has no caret and nothing else to indicate
110
+ focus with. Name the states when you want to keep the distinction:
111
+
112
+ ```ruby
113
+ field.bg_color = grey # flat: focused or not
114
+ field.bg_color = { normal: grey, active: blue } # your own pair
115
+ field.bg_color = { active: blue } # keep the widget's own well,
116
+ # override only the focus shade
117
+ ```
118
+
119
+ The keys are a small closed set (`:normal`, `:active`) that Tuile defines —
120
+ a state with no key simply isn't answered there, and the question falls
121
+ through to the next level of the chain, which is what makes the third line
122
+ above mean what it reads as.
72
123
 
73
124
  `bg_color` takes either a concrete {Tuile::Color} or a *live theme
74
125
  reference* — `Theme.ref(:panel_bg)` — that names one of your app's custom
75
126
  tokens and re-resolves it against the current theme on every paint. The
76
127
  reference is the ergonomic path: assign it once and the panel follows
77
- 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
78
129
  precisely because a background — unlike the baked-in colors of your
79
130
  *content* (below) — is resolved *live* at paint, exactly like the
80
131
  framework's own accents; `bg_color` is a single value read late, so
@@ -115,14 +166,15 @@ colors. Done.
115
166
  When a component has a themed color in hand, it applies it in one of two
116
167
  ways, and which one depends on the text.
117
168
 
118
- 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
119
170
  **rendering helpers** wrap the text in the token's SGR color and a reset:
120
- `theme.active_bg("[ Ok ]")`, `theme.hint("quit")`. The helper picks the
121
- right channel for the token's role (a `*_bg` token wraps as a background,
122
- a hint as a foreground) and passes the content through verbatim, so the
123
- string may already contain other escape sequences which is how
124
- {Tuile::Component::Window} feeds its whole border row, cursor moves and
125
- 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`.
126
178
 
127
179
  But chrome text is flat. Content is not. A list row or a label may be a
128
180
  {Tuile::StyledString} with its own per-span colors, and wrapping that in
@@ -205,7 +257,7 @@ more round trip than you might expect. The mode-2031 report says only
205
257
  it writes the OSC 11 query again, and the reply comes back through the
206
258
  key thread as another event. The new color therefore lands a frame after
207
259
  the new theme. When it does, Tuile fires
208
- {Tuile::Component}`#on_theme_changed` across the tree exactly as a theme
260
+ {Tuile::Component}`#handle_theme_changed` across the tree exactly as a theme
209
261
  swap does, on the reasoning that a tint derived from the background *is*
210
262
  a theme-derived color, and that hook is already where you rebuild those.
211
263
  So the same override handles both halves of a flip, and you don't need to
@@ -305,7 +357,21 @@ one with `theme[:accent]`, which **fail-fasts**: a typo'd token raises
305
357
  `KeyError` rather than silently painting a default, so a missing color is
306
358
  a loud bug and not a mystery. And you render with the generic `fg` / `bg`
307
359
  helpers — `theme.fg(:accent, "NEW")` — the custom-token counterparts of
308
- the built-in `hint` / `active_bg` helpers.
360
+ the built-in `active_border` / `active_bg` helpers.
361
+
362
+ This is where a status line's shades belong, and it is worth doing even
363
+ for a single token. The examples that ship with Tuile all carry one
364
+ `hint` grey for the descriptive half of their `"q quit"` rows, paired
365
+ dark and light so the row follows an appearance flip:
366
+
367
+ ```ruby
368
+ APP_THEME = Tuile::ThemeDef.new(
369
+ dark: Tuile::Theme::DARK.with(custom: { hint: Tuile::Color::GREY54 }),
370
+ light: Tuile::Theme::LIGHT.with(custom: { hint: Tuile::Color::GREY62 })
371
+ )
372
+ screen.theme_def = APP_THEME
373
+ status.text = "q #{screen.theme.fg(:hint, "quit")}"
374
+ ```
309
375
 
310
376
  For an app with more than a couple of custom tokens, the tidier move is
311
377
  to **subclass** {Tuile::Theme} and give each token a named coloring
@@ -374,21 +440,35 @@ because only *you* know which of the string's colors came from the theme
374
440
  versus which are inherent to the data (a log line's level color, say,
375
441
  should *not* follow the theme).
376
442
 
377
- The hook for this is {Tuile::Component#on_theme_changed}, fired on every
443
+ The hook for this is {Tuile::Component#handle_theme_changed}, fired on every
378
444
  attached component whenever the theme changes. Your handler does exactly
379
445
  one thing: **re-run the code that rendered the content**, so it rebuilds
380
446
  the StyledString against the now-current theme.
381
447
 
448
+ There are two ways to consume it, matching how you built the component,
449
+ and they are two different method names — the `=` tells them apart. If you
450
+ assembled stock components, assign the `on_theme_changed=` **listener slot**:
451
+
382
452
  ```ruby
383
453
  label.on_theme_changed = -> { label.text = render_status_line }
384
454
  ```
385
455
 
386
- There are two ways to consume it, matching how you built the component.
387
- If you assembled stock components, assign the `on_theme_changed=` proc as
388
- above. If you subclassed, override the method — and call `super`, so an
389
- assigned listener still fires. Either way the rule is the same: the hook
390
- is where theme-derived content gets rebuilt, and the framework handles
391
- everything else.
456
+ If you subclassed, override `handle_theme_changed`, the **override point**
457
+ and call `super`, so an assigned listener still fires:
458
+
459
+ ```ruby
460
+ class StatusLabel < Tuile::Component::Label
461
+ protected def handle_theme_changed
462
+ super
463
+ self.text = render_status_line
464
+ end
465
+ end
466
+ ```
467
+
468
+ That pair is the house rule across the whole widget set, not a special
469
+ case for theming. Either way the rule here is the same — the hook is where
470
+ theme-derived content gets rebuilt, and the framework handles everything
471
+ else.
392
472
 
393
473
  ---
394
474