tuile 0.9.0 → 0.11.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +81 -36
  3. data/DECISIONS.md +2566 -0
  4. data/README.md +37 -24
  5. data/book/03-layout.md +153 -8
  6. data/book/04-event-loop.md +86 -0
  7. data/book/05-focus.md +84 -51
  8. data/book/06-theming.md +53 -1
  9. data/book/07-components.md +458 -9
  10. data/book/08-testing.md +21 -8
  11. data/book/09-styled-text.md +132 -0
  12. data/book/README.md +22 -14
  13. data/examples/sampler.rb +632 -67
  14. data/ideas/arrow-key-navigation.md +205 -0
  15. data/ideas/new-components.md +118 -0
  16. data/ideas/per-component-buffers.md +55 -0
  17. data/lib/tuile/buffer.rb +52 -51
  18. data/lib/tuile/color.rb +4 -10
  19. data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
  20. data/lib/tuile/component/big_decimal_field.rb +199 -0
  21. data/lib/tuile/component/button.rb +25 -21
  22. data/lib/tuile/component/checkbox.rb +134 -0
  23. data/lib/tuile/component/checkbox_group.rb +188 -0
  24. data/lib/tuile/component/combo_box.rb +263 -0
  25. data/lib/tuile/component/float_field.rb +161 -0
  26. data/lib/tuile/component/has_caption.rb +44 -0
  27. data/lib/tuile/component/has_content.rb +5 -5
  28. data/lib/tuile/component/has_value.rb +64 -0
  29. data/lib/tuile/component/integer_field.rb +135 -0
  30. data/lib/tuile/component/label.rb +13 -10
  31. data/lib/tuile/component/layout/box.rb +316 -0
  32. data/lib/tuile/component/layout/horizontal.rb +40 -0
  33. data/lib/tuile/component/layout/vertical.rb +41 -0
  34. data/lib/tuile/component/layout.rb +149 -15
  35. data/lib/tuile/component/list.rb +8 -7
  36. data/lib/tuile/component/list_dropdown.rb +157 -0
  37. data/lib/tuile/component/password_field.rb +105 -0
  38. data/lib/tuile/component/popup.rb +10 -14
  39. data/lib/tuile/component/progress_bar.rb +278 -0
  40. data/lib/tuile/component/radio_group.rb +188 -0
  41. data/lib/tuile/component/select.rb +251 -0
  42. data/lib/tuile/component/text_area.rb +189 -65
  43. data/lib/tuile/component/text_field.rb +170 -32
  44. data/lib/tuile/component/text_view.rb +57 -114
  45. data/lib/tuile/component/window.rb +32 -47
  46. data/lib/tuile/component.rb +250 -87
  47. data/lib/tuile/event_queue.rb +14 -17
  48. data/lib/tuile/fake_event_queue.rb +11 -2
  49. data/lib/tuile/fake_screen.rb +4 -5
  50. data/lib/tuile/fraction.rb +6 -10
  51. data/lib/tuile/screen.rb +202 -104
  52. data/lib/tuile/screen_pane.rb +51 -41
  53. data/lib/tuile/styled_string.rb +125 -86
  54. data/lib/tuile/theme.rb +78 -41
  55. data/lib/tuile/version.rb +1 -1
  56. data/lib/tuile.rb +4 -0
  57. data/sig/tuile.rbs +2962 -680
  58. metadata +25 -7
data/README.md CHANGED
@@ -49,6 +49,12 @@ gem "tuile", git: "https://github.com/mvysny/tuile.git"
49
49
 
50
50
  Tuile requires Ruby 3.3+.
51
51
 
52
+ One component — `Component::BigDecimalField` — additionally needs the
53
+ `bigdecimal` gem, which Tuile deliberately does *not* depend on (it has been a
54
+ bundled gem since Ruby 3.4, so Bundler no longer puts it on the load path for
55
+ free). Add `gem "bigdecimal"` to your Gemfile if you use that field; nothing
56
+ else in Tuile loads it.
57
+
52
58
  ## Documentation
53
59
 
54
60
  - **[The Tuile guide](book/README.md)** teaches Tuile cover to cover — the
@@ -98,9 +104,8 @@ Shift+Tab move focus between the list and the demo's widgets.
98
104
  ### Component tree
99
105
 
100
106
  Everything on screen is a `Tuile::Component`. Components have a `parent`,
101
- `children`, a `rect` (absolute position), an `active?` flag (true for every
102
- component on the focus chain root → focused), and an optional `key_shortcut`
103
- that the framework will route keys to from anywhere in the tree.
107
+ `children`, a `rect` (absolute position), and an `active?` flag (true for
108
+ every component on the focus chain root → focused).
104
109
 
105
110
  A single `Tuile::Screen` (process singleton) owns the tree. Under it sits a
106
111
  structural `ScreenPane` with three slots: tiled `content` (your app's main
@@ -194,11 +199,13 @@ mechanism that handles it wins:
194
199
  screen.unregister_global_shortcut(Tuile::Keys::CTRL_L)
195
200
  ```
196
201
 
197
- Only unprintable keys are accepted (control characters, ESC, BACKSPACE,
198
- arrows, F-keys); printable keys raise so they can't hijack typing into
199
- a `TextField`. By default, the shortcut is suppressed while any popup
200
- is open and the popup receives the key; pass `over_popups: true` to
201
- pre-empt the popup.
202
+ This registry sits above the component tree and nothing suppresses it,
203
+ so it only accepts keys no widget can need: printable keys raise (they'd
204
+ hijack typing), as do Tab/Shift+Tab and `Screen::EDITING_KEYS` (Enter,
205
+ Backspace, Delete, the arrows). Control characters, ESC, `PgUp`/`PgDn`
206
+ and F-keys are yours. By default, the shortcut is suppressed while any
207
+ popup is open and the popup receives the key; pass `over_popups: true`
208
+ to pre-empt the popup.
202
209
 
203
210
  Pass `hint:` to surface the shortcut in the status bar. It's a
204
211
  preformatted string the caller fully owns (color it however the rest
@@ -207,23 +214,10 @@ mechanism that handles it wins:
207
214
  `over_popups: true` hints show up, prepended before the popup's
208
215
  `q Close`. Omit `hint:` to leave the shortcut silent in the status bar.
209
216
 
210
- 3. **`Component#key_shortcut`** — a declarative hotkey attached to a
211
- component. The framework walks the focused component's subtree for a
212
- match and focuses the winner. Good fit for "press F to focus the filter
213
- field" or one-key tab pickers. The lookup is suppressed while the
214
- focused component owns the hardware cursor (e.g. a `TextField` the user
215
- is typing into) so editing isn't interrupted:
216
-
217
- ```ruby
218
- filter_field.key_shortcut = "f"
219
- ```
220
-
221
- 4. **`Component#handle_key`** — override this on your own component when
217
+ 3. **`Component#handle_key`** — override this on your own component when
222
218
  it needs to react to keys directly (a list reacting to arrows, a custom
223
219
  widget handling Enter, …). Return `true` to mark the key handled,
224
- `false` to let the dispatcher keep walking. Call `super` to keep the
225
- default `key_shortcut` subtree lookup; suppress it only when you
226
- deliberately want this component to swallow everything:
220
+ `false` to let the key keep travelling:
227
221
 
228
222
  ```ruby
229
223
  class Toggle < Tuile::Component
@@ -233,7 +227,26 @@ mechanism that handles it wins:
233
227
  invalidate
234
228
  true
235
229
  else
236
- super
230
+ false
231
+ end
232
+ end
233
+ end
234
+ ```
235
+
236
+ The key goes to the focused component first, then **bubbles up its
237
+ ancestors** to the scope root (the topmost popup, or the tiled content).
238
+ That makes an ancestor the right home for scope-wide keys — a form's
239
+ default button, or one-key jumps between panes — and it needs no special
240
+ protection, because a focused `TextField` consumes the key before the
241
+ ancestor ever sees it:
242
+
243
+ ```ruby
244
+ class AppLayout < Tuile::Component::Layout::Absolute
245
+ def handle_key(key)
246
+ case key
247
+ when "1" then @files.focus; true
248
+ when "2" then @log.focus; true
249
+ else false
237
250
  end
238
251
  end
239
252
  end
data/book/03-layout.md CHANGED
@@ -125,6 +125,15 @@ complex TUIs — tmux, neovim's splits, k9s, lazygit, htop — are all
125
125
  them needs flex grow/shrink/wrap/basis or a constraint solve. The
126
126
  hardest real terminal UIs already live comfortably inside "simple."
127
127
 
128
+ Be precise about what that validates, though: it's TUI *app architecture*,
129
+ not TUI *framework feature lists*. Several terminal frameworks do ship a
130
+ full engine — Textual has CSS, Ink embeds Yoga (the flexbox engine React
131
+ Native uses), ratatui runs a real Cassowary solver. The reason isn't that
132
+ terminals need one; it's that those frameworks never hand you a rectangle,
133
+ so an engine is the only way their users can lay anything out. Tuile hands
134
+ you coordinates, which is what makes richer layout *optional* here —
135
+ available where it helps, declinable everywhere else.
136
+
128
137
  It's stronger than "simple happens to work," though. Importing a CSS-like
129
138
  system would be *actively worse* on a terminal, for three concrete
130
139
  reasons:
@@ -204,6 +213,146 @@ That's the whole "responsive" story: plain Ruby, recomputed on a
204
213
  discrete resize event. No breakpoint DSL, no media queries — just the
205
214
  arithmetic you'd write anyway.
206
215
 
216
+ ## Stacks without the arithmetic: `Vertical` and `Horizontal`
217
+
218
+ `Absolute` is the right tool for genuinely two-dimensional geometry, and
219
+ tedious for the most common shape in any app: a stack. So Tuile ships two
220
+ *box* layouts that do that arithmetic for you. You declare what extent each
221
+ child should get, and the box hands down rectangles through the very same
222
+ `rect=`:
223
+
224
+ ```ruby
225
+ form = Tuile::Component::Layout::Vertical.new(spacing: 1)
226
+ form.add(prompt, Tuile::Component::Layout::Fixed[3]) # 3 rows
227
+ form.add(field, Tuile::Component::Layout::Fixed[1]) # 1 row
228
+ form.add(log, Tuile::Component::Layout::Expand[1]) # …all that's left
229
+ ```
230
+
231
+ `Horizontal` is the same with the axes swapped — the constraint is a width,
232
+ and `Expand` claims the rest of the row:
233
+
234
+ ```ruby
235
+ split = Tuile::Component::Layout::Horizontal.new
236
+ split.add(sidebar, Tuile::Component::Layout::Fixed[30])
237
+ split.add(main, Tuile::Component::Layout::Expand[1])
238
+ ```
239
+
240
+ Inside a subclass the constraint names need no prefix, since they live on
241
+ `Layout`, an ancestor:
242
+
243
+ ```ruby
244
+ class LoginForm < Tuile::Component::Layout::Vertical
245
+ def initialize
246
+ super(spacing: 1, padding: Insets[top: 1])
247
+ add(@user = Tuile::Component::TextField.new, Fixed[1], cross: Fixed[30])
248
+ add(@log = Tuile::Component::TextView.new, Expand[1])
249
+ end
250
+ end
251
+ ```
252
+
253
+ ### The three constraints
254
+
255
+ - **`Fixed[n]`** — exactly `n` cells, clamped to what's still unassigned.
256
+ - **`Percent[n]`** — `n`% of the space *available*, measured after padding
257
+ and the gaps between children come off. So two `Percent[50]` children fit
258
+ exactly instead of overflowing by the gap between them.
259
+ - **`Expand[weight]`** — a share of whatever is left once the `Fixed` and
260
+ `Percent` children have taken theirs, split in proportion to the weights.
261
+
262
+ That's the entire vocabulary, and the omission is the point: **there is no
263
+ `Auto`.** Nothing asks a child how big it would like to be. This is the same
264
+ rule as the rest of the chapter, wearing a friendlier face.
265
+
266
+ Two more knobs, both on the box rather than on each child: `spacing:` (blank
267
+ cells between adjacent children) and `padding:` (an inset from the box's own
268
+ rect — `Insets[top: 1, left: 2]`, or a plain integer for all four edges).
269
+
270
+ ### The cross axis, and alignment
271
+
272
+ Each child also gets a `cross:` constraint — its width in a `Vertical`, its
273
+ height in a `Horizontal`. It defaults to `Percent[100]`, so children fill the
274
+ box across the axis, which is usually what you want. Narrow one when it isn't:
275
+
276
+ ```ruby
277
+ form.add(field, Fixed[1], cross: Fixed[30]) # 30 columns
278
+ form.add(title, Fixed[1], cross: Percent[50], align: :center)
279
+ ```
280
+
281
+ `align:` is `:start`, `:center` or `:end` — axis-agnostic on purpose, since
282
+ `:start` means the left edge in a `Vertical` and the top edge in a
283
+ `Horizontal`. It does something only when the child is narrower than the
284
+ space available.
285
+
286
+ Alignment might look like it contradicts the top-down rule — surely centering
287
+ needs to know how wide the child is? It doesn't. It needs *a* width, and the
288
+ `cross:` constraint is where that width came from. Nothing gets measured.
289
+ (`Expand` is main-axis only for a related reason: across the axis a child has
290
+ no siblings to compete with, so a weight would have nothing to mean. Passing
291
+ one as `cross:` raises.)
292
+
293
+ ### Packing, starving, and remainders
294
+
295
+ Three behaviours worth knowing, because they are what you get *instead of* a
296
+ solver:
297
+
298
+ **Children pack from the start edge.** With no `Expand` among them the slack
299
+ is simply left at the end — there's no invisible filler to add, the way
300
+ Swing's `BoxLayout` needs glue.
301
+
302
+ **Over-subscription starves rather than raising.** If the children ask for
303
+ more than there is, they're satisfied in declaration order and whoever is
304
+ left over gets an empty rect — which, as chapter 2 established, paints
305
+ nothing. A pane too short for its content degrades quietly instead of
306
+ throwing or spilling outside its rect.
307
+
308
+ **A remainder goes to the earliest `Expand` children, one cell each.** Five
309
+ equal `Expand`s in 12 rows get `3, 3, 2, 2, 2` — never `2, 2, 2, 2, 4`, which
310
+ is what "give the leftover to the last one" produces. On a character grid a
311
+ doubled pane is plainly visible, so spare cells are spread rather than dumped.
312
+ One wrinkle, since this chapter showed you the hand-written version first: the
313
+ two-pane `Absolute` example above gives the odd column to the *right* pane,
314
+ while two `Expand[1]` children give it to the *left*. Both are deterministic;
315
+ they're just different code.
316
+
317
+ ### Varying the gap: nest, don't configure
318
+
319
+ `spacing` belongs to the box rather than to individual children, deliberately.
320
+ A gap sits *between* two children, so "whose gap is it?" has no good answer —
321
+ and both possible conventions confuse readers.
322
+
323
+ When you want tighter grouping, nest a box. A `spacing: 0` stack inside a
324
+ `spacing: 1` stack keeps two rows flush while the rest of the form breathes:
325
+
326
+ ```ruby
327
+ pair = Tuile::Component::Layout::Vertical.new # spacing: 0
328
+ pair.add(bar, Fixed[1])
329
+ pair.add(caption, Fixed[1]) # flush under the bar
330
+
331
+ form = Tuile::Component::Layout::Vertical.new(spacing: 1)
332
+ form.add(prompt, Fixed[4])
333
+ form.add(pair, Fixed[2]) # blank row around the pair
334
+ ```
335
+
336
+ That *states* the grouping instead of faking it with a per-child gap — boxes
337
+ within boxes, which is how the rest of Tuile composes anyway.
338
+
339
+ ### When to stay with `Absolute`
340
+
341
+ The boxes are sugar, not a replacement, and they can't say everything. A **cap
342
+ on a proportion** is the case to recognise:
343
+
344
+ ```ruby
345
+ list_width = (rect.width / 3).clamp(20, 40) # a third, but never <20 or >40
346
+ group_width = [16, rect.width / 3].min # a third, but never more than 16
347
+ ```
348
+
349
+ Both of these are in `examples/sampler.rb`, and both keep a `rect=`
350
+ override. That's the intended division of labour rather than a gap to work
351
+ around: use a box for the stack, drop to `Absolute` for the region that
352
+ genuinely needs arithmetic — usually nesting one inside the other, so only the
353
+ awkward part carries any. The sampler does exactly that, and porting it to
354
+ these layouts took it from 59 hand-written rectangles down to 7.
355
+
207
356
  ## Geometry: `Point`, `Size`, `Rect`
208
357
 
209
358
  The values you compute with are three small frozen types
@@ -369,11 +518,7 @@ own code and set the size top-down. Keep measurement opt-in and
369
518
  caller-side; the moment the framework starts consulting children for
370
519
  sizes automatically, it's on the road back to the constraint solver.
371
520
 
372
- And if you find yourself building many dynamic, user-draggable splits by
373
- hand and wishing for a `Layout.vertical([Length(3), Fill(1), …])`
374
- convenience — that's a known, *deliberately deferred* addition. It would
375
- be pure sugar: a rect producer running a small greedy 1-D pass and
376
- feeding results to the very same `rect=` setter you already use, with no
377
- change to the foundation. Absolute-first is the base; a descriptive
378
- split layer is an optional convenience on top, added if and when the
379
- convenience pays for itself.
521
+ Note that the box layouts above are not an exception to any of this. They
522
+ compute rectangles *for* you, but they compute them from constraints you
523
+ supplied, and they hand them down through the same `rect=`. No child is ever
524
+ consulted.
@@ -172,6 +172,92 @@ Reach for `tick` when you're pacing work ("check every two seconds"),
172
172
  `tick_fps` when you're driving an animation ("spin at 8 fps"). Same
173
173
  machinery underneath; pick the unit that matches how you're thinking.
174
174
 
175
+ ## Owning a resource for as long as you're on screen
176
+
177
+ Both examples above end with a loose thread: *who calls `cancel`, and
178
+ when?* If the spinner lives in a popup the user can close, then someone
179
+ has to remember to stop the ticker at exactly the moment the popup goes
180
+ away — and "someone remembers" is how you end up with a timer firing
181
+ against a component nobody can see, forever.
182
+
183
+ The component itself is the only thing that knows. So it gets told:
184
+
185
+ ```ruby
186
+ class Spinner < Tuile::Component::Label
187
+ FRAMES = %w[/ - \\ |]
188
+
189
+ protected
190
+
191
+ def on_attached
192
+ @ticker = screen.event_queue.tick_fps(8) { |n| self.text = FRAMES[n % FRAMES.size] }
193
+ end
194
+
195
+ def on_detached
196
+ @ticker&.cancel
197
+ @ticker = nil
198
+ end
199
+ end
200
+ ```
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
204
+ popup and it starts; close the popup and it stops. Nothing at the call site
205
+ remembers anything — `popup.close` is the whole teardown.
206
+
207
+ The contract is a mirror: **`on_attached` starts what `on_detached` stops.**
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
210
+ calls it genuinely is off the screen, possibly for a long time, so stopping
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.
213
+
214
+ This generalizes well beyond tickers, and the interesting case is
215
+ subscriptions. A component may depend on a service, but a service must never
216
+ reach back up into the UI — so when data has to flow *upward*, the component
217
+ subscribes and the service emits blind. That subscription is a resource with
218
+ exactly the lifetime the hooks describe:
219
+
220
+ ```ruby
221
+ class BuildStatus < Tuile::Component::Label
222
+ def initialize(service)
223
+ super()
224
+ @service = service
225
+ end
226
+
227
+ protected
228
+
229
+ def on_attached
230
+ @subscription = @service.on_change { |s| screen.event_queue.submit { self.text = s } }
231
+ end
232
+
233
+ def on_detached
234
+ @subscription&.unsubscribe
235
+ @subscription = nil
236
+ end
237
+ end
238
+ ```
239
+
240
+ Note the `submit` inside the listener — the service emits from whatever
241
+ thread it likes, and marshalling onto the UI thread is the listener's job,
242
+ exactly as earlier in this chapter. What the hooks add is the other half:
243
+ the subscription exists for precisely as long as the component is on
244
+ screen, and no view-closing code path has to know that the subscription
245
+ exists at all.
246
+
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
249
+ tickers stop, the subscriptions come off, and you didn't write any of that
250
+ teardown. What *doesn't* fire is a process that exits without closing the
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
253
+ beyond the process — flushing a file, say — close the screen deliberately
254
+ rather than relying on exit.
255
+
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
258
+ to paint, invalidate here and do the work in `repaint`, which is what
259
+ chapter 2 was about anyway.
260
+
175
261
  ## Resize is just another event
176
262
 
177
263
  A terminal resize could have been handled off the `SIGWINCH` signal
data/book/05-focus.md CHANGED
@@ -55,8 +55,8 @@ Shift+Tab land on this component while cycling. It's also `false` by
55
55
  default, and it *implies* focusable — a tab stop is always a valid focus
56
56
  target, but not every focus target is a tab stop. The distinction matters
57
57
  for containers: a {Tuile::Component::Window} is focusable (so a click on
58
- its chrome, or a `key_shortcut`, can focus it) but is *not* a tab stop
59
- (Tab should skip the frame and stop on the actual inputs inside it). So:
58
+ its chrome can focus it) but is *not* a tab stop (Tab should skip the frame
59
+ and stop on the actual inputs inside it). So:
60
60
 
61
61
  - **Label** — neither. Decoration.
62
62
  - **Window, Popup** — focusable, not a tab stop. Clickable chrome,
@@ -80,8 +80,8 @@ earlier.
80
80
  **1. Tab and Shift+Tab — focus navigation, first, always.** These are
81
81
  intercepted before anything else and drive the cycling described above.
82
82
  They're taken off the top deliberately: a focused text field swallows
83
- almost every printable key (step 3 explains how), and if Tab weren't
84
- reserved here, a field would trap it too and you could never Tab out.
83
+ almost every printable key, and if Tab weren't reserved here, a field would
84
+ trap it too and you could never Tab out.
85
85
 
86
86
  **2. Global shortcuts.** App-level shortcuts registered with
87
87
  {Tuile::Screen#register_global_shortcut} fire next, before any component
@@ -95,32 +95,24 @@ screen.register_global_shortcut(Tuile::Keys::CTRL_L,
95
95
  end
96
96
  ```
97
97
 
98
- Only *unprintable* keys are allowed here (control keys, function keys,
99
- escape sequences). Printable keys are rejected at registration time,
100
- because a global binding on `a` would hijack someone typing `a` into a
101
- text field — that's what the per-component `key_shortcut` in step 3 is
102
- for. A shortcut can opt to fire even while a modal popup is open
103
- (`over_popups: true`); by default it's suppressed while a popup is up, so
104
- the popup stays modal.
105
-
106
- **3. A `key_shortcut` anywhere in the focused scope.** Every component can
107
- carry a {Tuile::Component#key_shortcut} — a single key that, when pressed,
108
- *jumps focus to that component*. The screen searches the current modal
109
- scope's subtree for a component whose shortcut matches; if it finds one,
110
- it focuses it and consumes the key. A window advertises its shortcut in
111
- its caption (`[f]-Files`), so a whole pane can be reachable with one key.
112
-
113
- But this search is **suppressed while a text widget is mid-edit** — and
114
- that suppression is the subtle, important part, so it gets its own
115
- section below.
116
-
117
- **4. `handle_key` on the focus chain.** Finally, if nothing above claimed
118
- the key, it's delivered to the focused component's
119
- {Tuile::Component#handle_key}, and if that returns `false` (didn't handle
120
- it), it bubbles up the ancestor chain — the focused component, then its
121
- parent, then *its* parent — until someone returns `true` or the scope
122
- root is reached. This is how a list handles arrow keys itself but lets an
123
- unhandled key rise to the window around it.
98
+ This registry is the only keyboard mechanism that sits *above* the
99
+ component tree, and nothing suppresses it — which is exactly why it's
100
+ picky about what it accepts. Printable keys are rejected at registration
101
+ time, because a global binding on `a` would hijack someone typing `a` into
102
+ a text field. So are Tab and Shift+Tab (step 1 already took them), and so
103
+ are `Screen::EDITING_KEYS` — Enter, Backspace, Delete and the arrows —
104
+ because every editable widget needs those, and a global binding would
105
+ break text entry app-wide with no way for a field to defend itself. What's
106
+ left is yours: control keys, ESC, PgUp/PgDn, function keys. A shortcut can
107
+ opt to fire even while a modal popup is open (`over_popups: true`); by
108
+ default it's suppressed while a popup is up, so the popup stays modal.
109
+
110
+ **3. `handle_key`, delivered to focus and bubbling up.** Everything else
111
+ goes to the focused component's {Tuile::Component#handle_key}, and if that
112
+ returns `false` (didn't handle it), the key bubbles up the ancestor chain —
113
+ the focused component, then its parent, then *its* parent — until someone
114
+ returns `true` or the scope root is reached. This is how a list handles
115
+ arrow keys itself but lets an unhandled key rise to the window around it.
124
116
 
125
117
  A component only ever receives a key when it's on the focus chain, so
126
118
  `handle_key` implementations act on the key alone — they never need to
@@ -128,32 +120,73 @@ check their own `active?` state. And if focus is `nil`, or sits outside
128
120
  the current modal scope, delivery reaches no one: that's precisely what
129
121
  makes an open modal popup modal.
130
122
 
131
- ## Why a text field can just type
123
+ Three rungs, and that's the whole ladder. Tuile used to have a fourth — a
124
+ scan of the scope for a component carrying a matching "shortcut key,"
125
+ which would jump focus to it. It's gone; the next section explains why the
126
+ bubble does that job better.
132
127
 
133
- Here's the problem the cursor-ownership rule solves. Suppose a window has
134
- a search field, and elsewhere in the same window a list has `key_shortcut
135
- = "d"` for "delete." You click into the field and type "add item." Should
136
- the "d" in "add" trigger delete?
128
+ ## Scope-wide keys live on an ancestor
137
129
 
138
- Obviously not — and step 3 is where it would go wrong, because "d" *is* a
139
- registered `key_shortcut` in the scope. The rule that saves you: the
140
- `key_shortcut` search in step 3 is skipped whenever a component **owns
141
- the hardware cursor**.
130
+ Two things every app wants: `1`/`2`/`3` to jump between panes, and Enter to
131
+ submit a form. Neither is a *global* action — each belongs to one region of
132
+ the tree — and neither needs machinery, because bubbling already has the
133
+ right shape. **Put the key on the ancestor that owns the region.**
134
+
135
+ ```ruby
136
+ class AppLayout < Tuile::Component::Layout::Absolute
137
+ def handle_key(key)
138
+ case key
139
+ when "1" then @files.focus; true
140
+ when "2" then @log.focus; true
141
+ else false
142
+ end
143
+ end
144
+ end
145
+ ```
146
+
147
+ Now think about what happens when the user clicks into a search field
148
+ inside `@files` and types "add item". Should the `1` in a typed "item 1"
149
+ jump panes? Obviously not — and it doesn't, for a reason that requires no
150
+ special case at all: **the focused field consumes the key at step 3 and
151
+ returns `true`, so the layout never sees it.** An ancestor only hears the
152
+ keys its descendants declined. That's the whole protection.
153
+
154
+ The same mechanism gives you a form's default button, one form per popup:
155
+
156
+ | focused widget | Enter | outcome |
157
+ |---|---|---|
158
+ | `TextArea` | consumes it (newline) | the form never sees it |
159
+ | `TextField` with an `on_enter` | consumes it | no double-submit |
160
+ | `TextField` without one | declines | bubbles up → submit |
161
+ | `Button` | consumes it | activates *itself*, not the default |
162
+ | `Checkbox` | consumes it (toggles) | the form never sees it |
163
+ | `Select` | consumes it (opens, then commits) | the form never sees it |
164
+
165
+ Because bubbling stops at the scope root, two forms in two popups each get
166
+ their own Enter — something a global registry structurally cannot do. This
167
+ is also why registering Enter globally is refused in step 2: the registry
168
+ would take it away from all of them at once.
169
+
170
+ The one thing this shape asks of you is that the *parent* holds the key →
171
+ child table, rather than each widget declaring its own mnemonic. That's a
172
+ fair trade: which key jumps where is a decision about the assembly, and it
173
+ reads well in one place.
174
+
175
+ ## Where the cursor comes in — and where it doesn't
142
176
 
143
177
  A component signals cursor ownership through
144
178
  {Tuile::Component#cursor_position} — return a `Point` and the terminal
145
- cursor is shown there; return `nil` (the default) and there's no cursor.
146
- A {Tuile::Component::TextField} being edited returns its caret position,
147
- which is non-`nil`, so the screen knows a text widget is mid-edit and
148
- suppresses shortcut capture. The "d" flows straight through step 3 to
149
- step 4, where the focused field's `handle_key` inserts it. Tab (step 1)
150
- still works, because it's reserved above all this — so you can always Tab
151
- out of the field, at which point the cursor goes away and shortcuts light
152
- back up.
153
-
154
- That's the entire mechanism: printable keys belong to whoever owns the
155
- cursor, and owning the cursor mutes the sibling shortcuts that would
156
- otherwise pick them off.
179
+ cursor is shown there; return `nil` (the default) and there's no cursor. A
180
+ {Tuile::Component::TextField} being edited returns its caret position, so
181
+ the caret you see blinking is the focused component's answer to that one
182
+ question.
183
+
184
+ That's all it does. It positions the hardware cursor; it does not route
185
+ keys. Tuile briefly used it as a proxy for "this widget is in text-entry
186
+ mode, don't steal its printable keys" — a signal the deleted fourth rung
187
+ needed. With dispatch resting on nothing but "did you return `true`," the
188
+ proxy is gone, and a component's decision to consume a key is the only
189
+ declaration in the system.
157
190
 
158
191
  ## The status bar writes itself
159
192
 
data/book/06-theming.md CHANGED
@@ -38,6 +38,55 @@ colors plus an app-extensible `custom` hash — and that's all. Two are
38
38
  built in: {Tuile::Theme::DARK}, the colors Tuile has always used, and
39
39
  {Tuile::Theme::LIGHT}, counterparts legible on a pale background.
40
40
 
41
+ ## Backgrounds are opt-in
42
+
43
+ The stance above — paint accents, leave the rest to the terminal — is how
44
+ the *framework* paints. But sometimes your *app* wants a real background:
45
+ an overlay panel, a slash-command menu, a dropdown that has to read as one
46
+ solid tinted block floating over the content beneath it — filler rows
47
+ included, not a ragged half-shaded box. That is {Tuile::Component#bg_color}.
48
+
49
+ Set it on a component and that component paints a background behind
50
+ everything it draws; leave it unset (the default) and you get the
51
+ terminal default, exactly as before. The useful part is that it
52
+ **inherits**: set `bg_color` once on a container — a
53
+ {Tuile::Component::Popup}, a layout, a window — and every descendant that
54
+ hasn't set its own picks it up. You tint the panel, and the labels and
55
+ lists inside come out on the same tint without being told individually.
56
+
57
+ That inheritance has to be *manufactured*, because a terminal has no
58
+ transparency: every cell holds exactly one background, and painting a
59
+ glyph replaces the whole cell (chapter 2's opaque grid). So Tuile resolves
60
+ the effective background at paint time by walking up to the nearest
61
+ ancestor that set a `bg_color` — the same read-at-paint discipline the
62
+ theme uses, and for the same reason: change a container's background and
63
+ its subtree is invalidated and simply repaints in the new color. The
64
+ terminal default is just the root of that chain, which is why an unset
65
+ `bg_color` everywhere behaves exactly like it always has.
66
+
67
+ A widget with a background of its *own* keeps it. A text field paints its
68
+ 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`.
72
+
73
+ `bg_color` takes either a concrete {Tuile::Color} or a *live theme
74
+ reference* — `Theme.ref(:panel_bg)` — that names one of your app's custom
75
+ tokens and re-resolves it against the current theme on every paint. The
76
+ 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
78
+ precisely because a background — unlike the baked-in colors of your
79
+ *content* (below) — is resolved *live* at paint, exactly like the
80
+ framework's own accents; `bg_color` is a single value read late, so
81
+ late-binding it to a token costs nothing. It is the one place an app color
82
+ tracks the theme without the hook.
83
+
84
+ A reference reaches your *custom* tokens only, never a framework-imposed
85
+ global, so the "no global background token" line holds either way: the
86
+ framework still paints no background of its own. You opt in per
87
+ component — a concrete `Color` when you want it fixed, a `Theme.ref` when
88
+ you want it to follow the scheme.
89
+
41
90
  ## Read at paint time, never cached
42
91
 
43
92
  There is one rule about *using* the theme that everything else depends
@@ -207,7 +256,10 @@ declaration site says nothing.
207
256
 
208
257
  The built-in components restyle for free because they read the theme at
209
258
  paint time. Your *content* can't always do that — and this is the one
210
- theming responsibility that lands on the app.
259
+ theming responsibility that lands on the app. (A *background* is the
260
+ exception: `bg_color = Theme.ref(:token)` tracks the theme live with no
261
+ handler, because it's resolved at paint — see "Backgrounds are opt-in"
262
+ above. The hook below is for baked-in *content* colors.)
211
263
 
212
264
  The problem: when you build a {Tuile::StyledString} for a
213
265
  {Tuile::Component::Label} or a {Tuile::Component::List} row, its colors