tuile 0.15.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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +121 -80
  3. data/README.md +28 -12
  4. data/book/04-event-loop.md +12 -12
  5. data/book/05-focus.md +95 -26
  6. data/book/06-theming.md +58 -26
  7. data/book/07-components.md +61 -6
  8. data/book/08-testing.md +24 -22
  9. data/book/10-locale.md +2 -2
  10. data/book/README.md +6 -5
  11. data/examples/file_commander.rb +14 -5
  12. data/examples/hello_world.rb +17 -4
  13. data/examples/sampler.rb +392 -18
  14. data/lib/tuile/component/abstract_string_field.rb +16 -18
  15. data/lib/tuile/component/abstract_wrapping_field.rb +47 -11
  16. data/lib/tuile/component/button.rb +8 -8
  17. data/lib/tuile/component/checkbox.rb +9 -9
  18. data/lib/tuile/component/checkbox_group.rb +6 -5
  19. data/lib/tuile/component/combo_box.rb +50 -35
  20. data/lib/tuile/component/confirm_window.rb +7 -5
  21. data/lib/tuile/component/date_field.rb +28 -3
  22. data/lib/tuile/component/date_time_field.rb +275 -0
  23. data/lib/tuile/component/has_bad_input.rb +2 -2
  24. data/lib/tuile/component/has_content.rb +3 -3
  25. data/lib/tuile/component/has_placeholder.rb +1 -1
  26. data/lib/tuile/component/has_validation.rb +2 -2
  27. data/lib/tuile/component/has_value.rb +1 -1
  28. data/lib/tuile/component/label.rb +1 -1
  29. data/lib/tuile/component/layout/box.rb +4 -1
  30. data/lib/tuile/component/layout.rb +3 -3
  31. data/lib/tuile/component/list.rb +42 -32
  32. data/lib/tuile/component/list_dropdown.rb +3 -3
  33. data/lib/tuile/component/menu_bar/cascade.rb +5 -5
  34. data/lib/tuile/component/menu_bar.rb +18 -18
  35. data/lib/tuile/component/notification.rb +32 -18
  36. data/lib/tuile/component/overlay.rb +9 -8
  37. data/lib/tuile/component/picker_window.rb +27 -8
  38. data/lib/tuile/component/popup.rb +2 -2
  39. data/lib/tuile/component/progress_bar.rb +10 -4
  40. data/lib/tuile/component/radio_group.rb +6 -5
  41. data/lib/tuile/component/select.rb +11 -12
  42. data/lib/tuile/component/slot.rb +3 -3
  43. data/lib/tuile/component/tab_sheet.rb +6 -6
  44. data/lib/tuile/component/tabs.rb +11 -11
  45. data/lib/tuile/component/text_area.rb +12 -10
  46. data/lib/tuile/component/text_field.rb +14 -12
  47. data/lib/tuile/component/text_view.rb +15 -11
  48. data/lib/tuile/component/time_field.rb +29 -4
  49. data/lib/tuile/component.rb +201 -93
  50. data/lib/tuile/event_queue.rb +4 -4
  51. data/lib/tuile/fake_event_queue.rb +1 -1
  52. data/lib/tuile/fake_screen.rb +84 -2
  53. data/lib/tuile/mouse/router.rb +217 -0
  54. data/lib/tuile/mouse.rb +177 -0
  55. data/lib/tuile/screen.rb +98 -61
  56. data/lib/tuile/screen_pane.rb +41 -36
  57. data/lib/tuile/styled_string.rb +5 -5
  58. data/lib/tuile/testing.rb +8 -8
  59. data/lib/tuile/theme.rb +22 -34
  60. data/lib/tuile/version.rb +1 -1
  61. data/lib/tuile/vertical_scroll_bar.rb +1 -1
  62. data/sig/tuile.rbs +1211 -427
  63. metadata +4 -16
  64. data/COMPARISON.md +0 -101
  65. data/DECISIONS.md +0 -8562
  66. data/TERMINOLOGY.md +0 -85
  67. data/ideas/arrow-key-navigation.md +0 -221
  68. data/ideas/binder.md +0 -177
  69. data/ideas/composite-field.md +0 -77
  70. data/ideas/focus-accent.md +0 -116
  71. data/ideas/form-layout.md +0 -151
  72. data/ideas/hover/probe.rb +0 -241
  73. data/ideas/hover/probe_spec.rb +0 -82
  74. data/ideas/hover.md +0 -909
  75. data/ideas/modal-backdrop.md +0 -24
  76. data/ideas/new-components.md +0 -144
  77. data/ideas/per-component-buffers.md +0 -55
  78. 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.
@@ -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.
@@ -166,7 +166,7 @@ right shape. **Put the key on the ancestor that owns the region.**
166
166
 
167
167
  ```ruby
168
168
  class AppLayout < Tuile::Component::Layout::Absolute
169
- def handle_key(key)
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
@@ -241,7 +241,7 @@ 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
@@ -273,10 +273,76 @@ 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
@@ -296,23 +362,23 @@ declaration in the system.
296
362
  ## A component that reacts to its own focus
297
363
 
298
364
  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
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
301
367
  one component, never on the ancestors that light up and go dark with it.
302
368
 
303
- `on_focus` is the forwarding hook. It's how a {Tuile::Component::Window}
369
+ `handle_focus` is the forwarding hook. It's how a {Tuile::Component::Window}
304
370
  handed focus passes it to its content, and how a {Tuile::Component::Layout}
305
371
  skips ahead to the first tab stop underneath it — which is also why it fires
306
372
  on *every* assignment, even one that re-focuses what's already focused.
307
373
 
308
- `on_blur` is the commit point. Nothing else in Tuile is one: Tab is
374
+ `handle_blur` is the commit point. Nothing else in Tuile is one: Tab is
309
375
  unconditional, so a user leaving a half-finished field usually leaves by a
310
376
  key no component ever sees, and `on_enter` never fires. If your field wants
311
377
  to tidy up what was typed, this is where:
312
378
 
313
379
  ```ruby
314
380
  class TrimmedField < Tuile::Component::TextField
315
- protected def on_blur = (self.text = text.strip)
381
+ protected def handle_blur = (self.text = text.strip)
316
382
  end
317
383
  ```
318
384
 
@@ -331,7 +397,7 @@ nothing; and `screen.close` blurs the focused component on its way out. Keep
331
397
  the handler cheap and it won't matter.
332
398
 
333
399
  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
400
+ protected or private as you like — the example above groups `handle_blur` under
335
401
  `protected`, which is where framework-invoked plumbing belongs.
336
402
 
337
403
  ## Writing a status line
@@ -344,7 +410,7 @@ A status line is a `Label` in your layout. That's the whole idea:
344
410
 
345
411
  ```ruby
346
412
  status = Tuile::Component::Label.new
347
- 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")}"
348
414
 
349
415
  root = Tuile::Component::Layout::Vertical.new
350
416
  root.add(main_ui, Tuile::Component::Layout::Expand[1])
@@ -357,10 +423,13 @@ is exactly this: Tab, Enter and Backspace work in both panes, so its row is
357
423
  a constant. Reaching for a focus callback there would be machinery computing
358
424
  a value that never changes.
359
425
 
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
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
364
433
  {Tuile::Screen#register_global_shortcut} don't advertise themselves: the
365
434
  registry runs actions, it doesn't describe them, so a `^K menu` in your row
366
435
  is text you write next to the registration.
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
@@ -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
@@ -253,7 +257,7 @@ more round trip than you might expect. The mode-2031 report says only
253
257
  it writes the OSC 11 query again, and the reply comes back through the
254
258
  key thread as another event. The new color therefore lands a frame after
255
259
  the new theme. When it does, Tuile fires
256
- {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
257
261
  swap does, on the reasoning that a tint derived from the background *is*
258
262
  a theme-derived color, and that hook is already where you rebuild those.
259
263
  So the same override handles both halves of a flip, and you don't need to
@@ -353,7 +357,21 @@ one with `theme[:accent]`, which **fail-fasts**: a typo'd token raises
353
357
  `KeyError` rather than silently painting a default, so a missing color is
354
358
  a loud bug and not a mystery. And you render with the generic `fg` / `bg`
355
359
  helpers — `theme.fg(:accent, "NEW")` — the custom-token counterparts of
356
- 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
+ ```
357
375
 
358
376
  For an app with more than a couple of custom tokens, the tidier move is
359
377
  to **subclass** {Tuile::Theme} and give each token a named coloring
@@ -422,21 +440,35 @@ because only *you* know which of the string's colors came from the theme
422
440
  versus which are inherent to the data (a log line's level color, say,
423
441
  should *not* follow the theme).
424
442
 
425
- 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
426
444
  attached component whenever the theme changes. Your handler does exactly
427
445
  one thing: **re-run the code that rendered the content**, so it rebuilds
428
446
  the StyledString against the now-current theme.
429
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
+
430
452
  ```ruby
431
453
  label.on_theme_changed = -> { label.text = render_status_line }
432
454
  ```
433
455
 
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.
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.
440
472
 
441
473
  ---
442
474
 
@@ -370,7 +370,7 @@ calendar is different: it answers what weekday the 17th is), and with your hands
370
370
  already on the keys, typing `1345` beats scrolling to it. Tuile is
371
371
  keyboard-first: the mouse gets what falls out of click routing for free and never
372
372
  motivates a widget on its own. The ranking behind that is `D_mouse` in
373
- `DECISIONS.md`.
373
+ `design/decisions.md`.
374
374
 
375
375
  What you get for it is that the two questions stay independent. Switching
376
376
  precision never touches the spelling, so a Finnish user sees `13.45` and
@@ -540,6 +540,24 @@ not parse, and goes quiet again on your next edit. Only the *ink* waits.
540
540
  `bad_input?` is still answered from the current buffer the instant you ask it,
541
541
  which is what keeps the Save handler above correct with no change at all.
542
542
 
543
+ There is a second half to this, and it bites harder, because some prefixes of a
544
+ date do not merely fail to parse — they parse *cleanly*. In a `dd.mm.yyyy`
545
+ field, `1.1.2` on the way to `1.1.2024` is the first of January in the year 2:
546
+ a perfectly good `Date`, one `bad_input?` will never flag, and one your
547
+ listener would be handed while the user is still typing, along with whatever
548
+ recalculation hangs off it. So the date and time fields settle their *notice*
549
+ on those same two gestures: `on_value_change` fires when you leave the field or
550
+ press Enter, not as you type. Reading `value` is again unaffected, so a Save on
551
+ a keyboard shortcut that never moves focus still sees the date on screen — and
552
+ a value nobody had to type, a `value=` or an Up/Down step or a `clear`, is
553
+ announced the moment it happens.
554
+
555
+ The ink and the notice settle together because one question decides both, and
556
+ it is the prefix-closed question from a few pages back. Every buffer an
557
+ `IntegerField` passes through really is the number it shows, so `4` on the way
558
+ to `42` is worth announcing and worth reddening. A date's are neither. That one
559
+ property of the grammar settles the filter, the ink and the notice alike.
560
+
543
561
  The one discipline the writer owes is visible in those `: nil` branches: **set
544
562
  or clear on every pass.** Only assign the message where you validate, and a
545
563
  field that has been fixed goes back to normal on its own. Forget the clear and
@@ -570,14 +588,14 @@ label without displacing the value — so a field carries no caption at all. The
570
588
  form in this book builds its own captions.
571
589
 
572
590
  The sibling seam, one level up, is **which keys the field acts on at all**:
573
- override `handle_text_input_key` and call `super` for everything you don't
591
+ override `handle_text_input_key?` and call `super` for everything you don't
574
592
  claim.
575
593
 
576
594
  ```ruby
577
595
  class SubmitField < Tuile::Component::TextArea
578
596
  protected
579
597
 
580
- def handle_text_input_key(key)
598
+ def handle_text_input_key?(key)
581
599
  return super unless key == Tuile::Keys::ENTER
582
600
 
583
601
  submit(text) # Enter submits instead of inserting a newline
@@ -616,6 +634,41 @@ that will one day sit above these components. So the seam is kept thin on
616
634
  purpose: `on_value_change` carries just the new value, and there's no
617
635
  read-only or required flag yet. Room left for that layer to grow into.
618
636
 
637
+ ### Two fields, one value
638
+
639
+ {Tuile::Component::DateTimeField} is the first field made of *fields*: the date
640
+ field and the time field from earlier in this chapter, side by side on one row,
641
+ behind a single `DateTime`.
642
+
643
+ ```ruby
644
+ starts = Component::DateTimeField.new
645
+ starts.value = DateTime.new(2026, 9, 14, 13, 45) # [2026-09-14] [13:45]
646
+ starts.date_field.formats = "%d.%m.%Y" # tune a half in place…
647
+ starts.time_field.step = 900 # …rather than through a forwarder
648
+ ```
649
+
650
+ The halves are exposed read-only: a child you *tune* but never *supply* is
651
+ reached directly, so there is no second set of names to keep in step — and no
652
+ argument about whether `formats=` on the composite would mean the date's or the
653
+ time's.
654
+
655
+ The value is non-nil only when both halves parse, and a half going bad nils the
656
+ whole thing rather than holding the last good one: a field holds bad input **or**
657
+ a value, never both. What is genuinely new is the question of who goes red, and
658
+ the answer is one sentence — **the composite paints only the fault no half can
659
+ wear**. Garbage in the date half is attributable, so that half reddens itself on
660
+ the latch you saw a moment ago, and the composite paints nothing. A date with no
661
+ time is nobody else's fault, so the composite reddens *whole* — but only once you
662
+ leave it, so it judges you when you are done rather than while you are filling it
663
+ in. A rule's verdict is not attributable either, and reddens whole with no latch
664
+ at all.
665
+
666
+ One wrinkle follows from that. Pressing Enter over a half-filled field reports
667
+ `bad_input?` and its message but does not redden it; the ink waits for you to
668
+ leave. Latching on Enter would reopen exactly the window the rule closes — the
669
+ one where the field tells you that you are wrong when the truth is that you are
670
+ not finished.
671
+
619
672
  ## Choosing from a set
620
673
 
621
674
  {Tuile::Component::List} is the workhorse: a scrollable column of *items*
@@ -967,7 +1020,7 @@ looks like in practice.)
967
1020
  **A focused button consumes Enter**, and that matters the moment you have
968
1021
  more than one. Enter on a focused `Save` activates *that* button — not some
969
1022
  form-wide default, because Tuile has no notion of a default button at all.
970
- The form's Enter-to-submit is a `handle_key` on the ancestor that owns the
1023
+ The form's Enter-to-submit is a `handle_key?` on the ancestor that owns the
971
1024
  form (chapter 5), and it only ever sees Enter when the focused widget
972
1025
  declined it. So a dialog's two buttons are just two widgets, and which one
973
1026
  Enter hits is simply which one has focus.
@@ -1234,7 +1287,7 @@ what a `TabSheet` does: only the selected tab's pane is a child of the
1234
1287
  sheet, the rest are detached. That is a deliberate choice rather than
1235
1288
  history, and the reason is the sentence above about hooks — inverted:
1236
1289
 
1237
- - **`on_detached` fires when a pane goes away, `on_attached` when it
1290
+ - **`handle_detached` fires when a pane goes away, `handle_attached` when it
1238
1291
  returns.** A {Tuile::Component::ProgressBar} in a background tab stops its
1239
1292
  ticker and restarts it on return, with no bookkeeping from you. Hiding
1240
1293
  would keep it ticking, unseen.
@@ -1684,7 +1737,9 @@ layout) *or* as a popup (via a class-level `open`).
1684
1737
  presentation from the body's type (an Array is rows, text is prose).
1685
1738
  - {Tuile::Component::PickerWindow} — a menu of options each bound to a
1686
1739
  single key, firing your block with the picked key. Popped up via `open`,
1687
- it closes itself after a pick; ESC/`q` cancels without firing.
1740
+ it closes itself after a pick; ESC/`q` cancels without firing. Captions
1741
+ paint in the terminal's own foreground; hand in a {Tuile::StyledString}
1742
+ (or the ANSI string `theme.fg` returns) to color one, per option.
1688
1743
  - {Tuile::Component::LogWindow} — a Window framing a
1689
1744
  {Tuile::Component::LogTextView}: an auto-scrolling, scrollbar-equipped
1690
1745
  TextView purpose-built for log output. The view is where the behavior
data/book/08-testing.md CHANGED
@@ -107,7 +107,7 @@ method* assembled, and the test never held a reference to it.
107
107
  one component matching a spec:
108
108
 
109
109
  ```ruby
110
- Testing.get(Component::Button, caption: "Save").handle_key(Keys::ENTER)
110
+ Testing.get(Component::Button, caption: "Save").handle_key?(Keys::ENTER)
111
111
  Testing.get(id: :amount).value = 42
112
112
  ```
113
113
 
@@ -132,7 +132,7 @@ walk that takes the first match —
132
132
 
133
133
  ```ruby
134
134
  combo = nil
135
- window.on_tree { |c| combo ||= c if c.is_a?(Component::ComboBox) }
135
+ window.walk_tree { |c| combo ||= c if c.is_a?(Component::ComboBox) }
136
136
  ```
137
137
 
138
138
  — and the day the pane grows a second `ComboBox`, that silently re-points
@@ -178,34 +178,36 @@ terser, and changes nothing about the assertion channel. What a component
178
178
  There are two altitudes at which you feed input, and picking the right one
179
179
  is most of writing a good Tuile test.
180
180
 
181
- **Low: call the component directly.** {Tuile::Component#handle_key} and
182
- `handle_mouse` are public, and calling them straight tests a component's
183
- own logic in isolation — no focus, no dispatch, just "given this key, does
184
- the list move its cursor?" `handle_key` returns whether it consumed the
185
- key, so you assert on that too:
181
+ **Low: call the component directly.** {Tuile::Component#handle_key?} and the
182
+ `handle_mouse_*` handlers are public, and calling one straight tests a
183
+ component's own logic in isolation — no focus, no dispatch, just "given this
184
+ key, does the list move its cursor?" Both answer whether they consumed the
185
+ event, so you assert on that too:
186
186
 
187
187
  ```ruby
188
- list.handle_key(Keys::DOWN_ARROW) # exercises the cursor directly
188
+ list.handle_key?(Keys::DOWN_ARROW) # exercises the cursor directly
189
+ list.handle_mouse_scroll?(Mouse::ScrollEvent.new(:up, 5, 2)) # false at the top: an ancestor gets it
189
190
  ```
190
191
 
191
- **A mouse test needs the component mounted, where a key test doesn't.** A
192
- click doesn't only *do* something, it also *focuses* — and
193
- {Tuile::Screen#focused=} refuses a component that isn't on the pane, so
194
- `handle_mouse` on a component you never attached raises "is not attached to
195
- this screen". Give it a tree first:
192
+ **A press, though, wants the high altitude.** It does not only *do* something:
193
+ it focuses, it dismisses popups, and which component it even reaches is the
194
+ router's answer rather than the component's — so calling `handle_mouse_down?`
195
+ by hand tests a third of what a click is. Drive it through
196
+ {Tuile::FakeScreen}, which posts the gesture the terminal would:
196
197
 
197
198
  ```ruby
198
- screen.content = list # a click focuses; focus needs a tree
199
+ screen.content = list # a press focuses; focus needs a tree
199
200
  list.rect = Rect.new(0, 0, 10, 5)
200
- list.handle_mouse(MouseEvent.new(:left, 5, 2))
201
+ screen.click(5, 2) # press then release, at that cell
201
202
  ```
202
203
 
203
- That applies to containers too, and to more of them than you might expect:
204
- a click descends to every child whose rect contains the point, so testing a
205
- window's footer by clicking it exercises the window, the footer's slot and
206
- the footer, all of which want to be attached.
204
+ `click` is the whole gesture; `press` / `release` are its halves, for a test
205
+ about what the grab does in between, and `scroll` / `move` post the other two
206
+ events. They all take screen-absolute, 0-based coordinates, so a test asserts
207
+ against the rect it assigned — and a press on a cell no component covers
208
+ simply does nothing.
207
209
 
208
- **High: go through the pane.** {Tuile::ScreenPane#handle_key} runs the
210
+ **High: go through the pane.** {Tuile::ScreenPane#handle_key?} runs the
209
211
  dispatch rung from chapter 5 that routing is actually about: delivery to
210
212
  {Tuile::Screen#focused}, then the bubble up its ancestor chain to the scope
211
213
  root. So when your test is about routing — that a layout's one-key pane jump
@@ -215,11 +217,11 @@ drive the pane and let the real machinery run:
215
217
 
216
218
  ```ruby
217
219
  screen.focused = list # focus as production does — or list.focus
218
- assert screen.pane.handle_key("1") # the layout's ancestor binding fires
220
+ assert screen.pane.handle_key?("1") # the layout's ancestor binding fires
219
221
  ```
220
222
 
221
223
  The two rungs *above* the pane have their own doors, because `Screen`'s own
222
- `handle_key` — the top of the ladder — is private: it belongs to the key
224
+ `handle_key?` — the top of the ladder — is private: it belongs to the key
223
225
  thread, not to app code. Tab cycling is {Tuile::Screen#focus_next} /
224
226
  `focus_previous`, both already scoped to the topmost modal popup, which is
225
227
  what "a popup traps Tab" means. A global shortcut is a block you registered,