tuile 0.12.0 → 0.13.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/DECISIONS.md +1297 -13
  4. data/README.md +136 -490
  5. data/TERMINOLOGY.md +11 -2
  6. data/book/01-first-app.md +22 -17
  7. data/book/02-repaint.md +18 -5
  8. data/book/03-layout.md +11 -10
  9. data/book/05-focus.md +133 -18
  10. data/book/06-theming.md +5 -2
  11. data/book/07-components.md +402 -12
  12. data/book/08-testing.md +18 -4
  13. data/book/README.md +7 -5
  14. data/examples/file_commander.rb +22 -16
  15. data/examples/hello_world.rb +17 -5
  16. data/examples/sampler.rb +385 -66
  17. data/ideas/arrow-key-navigation.md +16 -0
  18. data/ideas/new-components.md +7 -6
  19. data/lib/tuile/ansi.rb +10 -0
  20. data/lib/tuile/component/abstract_string_field.rb +36 -0
  21. data/lib/tuile/component/combo_box.rb +3 -1
  22. data/lib/tuile/component/list.rb +22 -0
  23. data/lib/tuile/component/list_dropdown.rb +86 -3
  24. data/lib/tuile/component/menu_bar/cascade.rb +255 -0
  25. data/lib/tuile/component/menu_bar.rb +582 -0
  26. data/lib/tuile/component/notification.rb +14 -11
  27. data/lib/tuile/component/picker_window.rb +0 -5
  28. data/lib/tuile/component/popup.rb +75 -9
  29. data/lib/tuile/component/select.rb +3 -1
  30. data/lib/tuile/component/tab_sheet.rb +242 -0
  31. data/lib/tuile/component/tabs.rb +528 -0
  32. data/lib/tuile/component/text_area.rb +5 -4
  33. data/lib/tuile/component/text_field.rb +23 -6
  34. data/lib/tuile/component/text_view.rb +8 -5
  35. data/lib/tuile/component.rb +38 -13
  36. data/lib/tuile/event_queue.rb +25 -1
  37. data/lib/tuile/fake_screen.rb +14 -0
  38. data/lib/tuile/keys.rb +65 -0
  39. data/lib/tuile/screen.rb +94 -77
  40. data/lib/tuile/screen_pane.rb +109 -27
  41. data/lib/tuile/styled_string.rb +40 -0
  42. data/lib/tuile/version.rb +1 -1
  43. data/sig/tuile.rbs +1473 -93
  44. metadata +6 -3
  45. data/mise.toml +0 -2
data/TERMINOLOGY.md CHANGED
@@ -19,10 +19,12 @@ needs a paragraph of justification, that paragraph belongs in one of those three
19
19
  | **cluster** | a grapheme cluster — the unit measurement, slicing, caret motion and deletion all work in. Never `each_char`. |
20
20
  | **row_in_viewport** | a row measured `0...rect.height`, i.e. relative to a component's own rect. |
21
21
  | **scroll_top_row** | the content row currently sitting at the top of the viewport. |
22
+ | **left_column** | the content column currently painted in a widget's leftmost cell — the horizontal counterpart of `scroll_top_row`. Private wherever it exists (`TextField`, `Tabs`, `MenuBar`): what a caller relies on is the invariant it maintains — the caret, or the selected segment, is in view — not the number. |
22
23
  | **viewport_rows** | how many rows of content are visible — always `rect.height`; kept private, since `rect.height` is the public form. |
23
24
  | **row_count** | how many rows the wrapped content occupies. Public on `TextArea` (with `caret_row`, its companion); also on the private `WrappedText` and as `VerticalScrollBar.new(row_count:)`. Not on `TextView` / `List`, which have no caller for it. |
24
25
  | **caret_row** | the row a text input's caret sits in, counted from the content's first row. `TextArea` only. |
25
- | **extent** | the sub-rect a one-row caption widget actually occupies (`min(caption width + 4, rect.width)`), used for its highlight and hit test — narrower than its `rect`. |
26
+ | **extent** | the sub-rect a one-row widget actually paints, used for its highlight and hit test — narrower than the `rect` it was given. The arithmetic is each widget's own (a `Checkbox`'s glyph plus caption; a `Tabs` strip's segments and separators), never a `Component` method. |
27
+ | **segment** | one tab's span on a {Tuile::Component::Tabs} strip: its caption plus a padding column either side. The unit a click resolves to; the separator column between two segments belongs to neither. |
26
28
 
27
29
  **Space rule 1.** An object with only one row space leaves `row` unqualified:
28
30
  {Tuile::Buffer} *is* the grid, so its rows are screen rows;
@@ -40,10 +42,11 @@ content-space.
40
42
  | **line_count** | a count of `\n` units (`TextView::Region#line_count`). Never a row count. |
41
43
  | **item** | a domain object a widget holds and renders — `List#items`, and the enum widgets above it. |
42
44
  | **renderer** | the `item -> row` proc a generic component uses to render an item it knows nothing about. |
45
+ | **selection** | which item or tab a selector currently points at. *View state* when nothing would save it ({Tuile::Component::Tabs}`#selected`), a *value* when a form would (`RadioGroup#value`) — the split `D-tabs` calls the "would a form save it?" test. |
43
46
  | **item_count** / **item_index** | how a `List::Cursor` counts and addresses; equal to a row count in a `List`, but the cursor indexes *items*. |
44
47
  | **text** | the user-editable **value** of an input ({Tuile::Component::HasValue}, aliased as `text` on `AbstractStringField`). |
45
48
  | **caption** | app-authored **chrome** text ({Tuile::Component::HasCaption}) — a `Window` title, a `Button` label. Never a value. |
46
- | **chrome** | framework- or app-authored decoration around content: captions, borders, footers, the status bar. |
49
+ | **chrome** | framework- or app-authored decoration around content: captions, borders, footers, an app's status line. |
47
50
  | **caret** | the index into an input's `text` where editing happens; always on a cluster boundary. Distinct from the *cursor*. |
48
51
 
49
52
  ## Tree, paint and theme
@@ -54,6 +57,12 @@ content-space.
54
57
  | **tile** / **tiled** | the non-popup part of the tree — `ScreenPane#content` and its descendants. Also *to tile*: to cover a rect completely. |
55
58
  | **attached** | reachable from a {Tuile::ScreenPane} via the parent chain — the one axis `attached?` consults. |
56
59
  | **slot** | a named child a container holds by identity (`content`, `footer`) as well as in `children`. |
60
+ | **cascade** | the stack of open {Tuile::Component::ListDropdown} panels a {Tuile::Component::MenuBar} drives, one per level, the last deepest. Each is an overlay on the pane, not a child of the bar. |
61
+ | **submenu** | a menu item that opens a further panel instead of doing something — `MenuBar::Item#submenu?`, true iff the item has children. Painted with a trailing `▸`. |
62
+ | **mnemonic** | a letter that activates one {Tuile::Component::MenuBar} item, underlined in its caption. Always *level-scoped*: matched against the top-level items while the cascade is closed and the deepest open panel while it is open, never across the two. |
63
+ | **strip** | the one-row {Tuile::Component::Tabs} component: captions, one selected, no content of its own. A {Tuile::Component::MenuBar} has one too — same word, and the same extent-based hit testing, deliberately not the same look. |
64
+ | **tab** | a {Tuile::Component::Tabs::Tab} — a caption plus an identity, minted and owned by the strip. Not a component (it never paints itself) and not an *item* (it holds per-element state, and the set is never assigned whole). Say "a tab" and "the Tab key"; never let the two words touch. |
65
+ | **pane** | the component a {Tuile::Component::TabSheet} shows for the selected tab. The unselected ones are *detached*, which is how Tuile hides a component. |
57
66
  | **invalidate** | record a component as needing repaint; the loop coalesces and repaints once per tick. |
58
67
  | **cursor** | *(two senses, both live)* the hardware terminal cursor (`Screen#cursor_position`), and a `List::Cursor` — the selection position within a list. |
59
68
  | **well** | the explicit background an input paints over its whole rect (`Theme#input_bg_color` / `#active_bg_color`), which opts it out of `bg_color` inheritance. |
data/book/01-first-app.md CHANGED
@@ -104,11 +104,10 @@ window.focus
104
104
 
105
105
  `screen.content = window` makes the window the screen's **tiled content**
106
106
  — the component that fills the terminal. Setting it triggers a layout
107
- pass: the window is handed a rectangle spanning the whole screen (minus
108
- one bottom row, which we'll get to), and it in turn sizes the label
109
- inside its border. This is the top-down cascade in miniature — the screen
110
- sizes the window, the window sizes its content — and it re-runs, top
111
- down, every time the terminal is resized.
107
+ pass: the window is handed a rectangle spanning the whole screen, and it
108
+ in turn sizes the label inside its border. This is the top-down cascade in
109
+ miniature — the screen sizes the window, the window sizes its content —
110
+ and it re-runs, top down, every time the terminal is resized.
112
111
 
113
112
  `window.focus` marks the window as the **focused** component: the one
114
113
  that receives keystrokes. Focus flows down toward interactive content
@@ -117,30 +116,36 @@ the active thing." In a one-window app it's mostly cosmetic (it draws the
117
116
  border in the active color); in a real app, focus is what routes the
118
117
  keyboard, and it gets its own chapter (chapter 5).
119
118
 
120
- You may have noticed you never created a status bar, yet the app has one
121
- — the bottom row showing `q quit`. That's because your window isn't the
122
- whole story of what's on screen.
119
+ Run it and you'll notice what *isn't* there: no status bar, no menu, no
120
+ title chrome beyond the border you asked for. **Tuile paints nothing you
121
+ didn't build.** Other TUI toolkits hand you a status row and a hint line
122
+ for free; Tuile gives your content the whole terminal and lets you decide
123
+ whether a bottom row is worth one of your rows. A status line is three
124
+ lines of layout when you want one — `examples/hello_world.rb` adds one,
125
+ and chapter 3 shows the mechanism.
126
+
127
+ That is the same instinct as top-down layout: the framework declines to
128
+ make sizing decisions on your behalf, here by declining to spend a row.
123
129
 
124
130
  ## The tree you didn't build
125
131
 
126
132
  Your `window` isn't actually the root of the tree. The real root is a
127
133
  structural node called the {Tuile::ScreenPane}, owned by the screen, and
128
- it holds three things:
134
+ it holds two things:
129
135
 
130
136
  ```
131
137
  ScreenPane (structural root — paints nothing itself)
132
138
  ├── content your window (the tiled UI)
133
- ├── popups modal overlays, when you open them (none yet)
134
- └── status_bar the bottom row (that "q quit" hint)
139
+ └── popups modal overlays, when you open them (none yet)
135
140
  ```
136
141
 
137
142
  You only ever manage the `content` slot directly (via `screen.content=`);
138
- the pane, the popup stack, and the status bar are the framework's. The
139
- reason everything — including popups — lives under one parent is
140
- uniformity: focus traversal, "is this component still on screen?", and
141
- cleanup when a component is removed all work the same way for every node,
142
- with no special cases. You'll meet popups in chapter 7; for now it's
143
- enough to know the pane is up there, quietly being the root.
143
+ the pane and the popup stack are the framework's. The reason everything —
144
+ including popups — lives under one parent is uniformity: focus traversal,
145
+ "is this component still on screen?", and cleanup when a component is
146
+ removed all work the same way for every node, with no special cases.
147
+ You'll meet popups in chapter 7; for now it's enough to know the pane is
148
+ up there, quietly being the root.
144
149
 
145
150
  ## Run the loop, and always close
146
151
 
data/book/02-repaint.md CHANGED
@@ -136,14 +136,27 @@ Meeting that second half is easy, because the default
136
136
  - A **leaf** component (no children) gets its background cleared
137
137
  automatically, so you can paint your content and trust the rest is
138
138
  blanked.
139
- - A **container whose children exactly tile its rect** skips the clear —
140
- the children will cover everything anyway.
141
139
  - A **container with gaps** between its children (a form with
142
- mixed-width fields, say) gets the background cleared *and* its children
143
- re-invalidated, so they repaint cleanly on top. This is what makes
144
- gappy layouts safe without every container writing its own
140
+ mixed-width fields, say) gets the background cleared, because those gap
141
+ cells are ones no child will paint over.
142
+ - A **container whose children exactly tile its rect** skips the clear —
143
+ the children cover every cell anyway, and blanking a cell you are about
144
+ to repaint would only mark it dirty for the flush.
145
+ - **Either way, a container re-invalidates its children.** That is what
146
+ makes gappy layouts safe without every container writing its own
145
147
  damage-tracking pass.
146
148
 
149
+ That last point is worth a moment, because the obvious optimization is
150
+ wrong. A clear wipes the container's *whole* rect — every descendant's
151
+ cells, not just the gaps — but a container only ever notifies its own
152
+ direct children, so the notice has to keep travelling down on its own. A
153
+ tiling container that stayed quiet ("my children cover everything, nothing
154
+ to do") would be a dead end: its grandchildren would never learn their
155
+ cells had been blanked by an ancestor, and their content would vanish until
156
+ some unrelated event happened to invalidate them. Repainting more than
157
+ strictly necessary costs nothing here — identical glyphs leave a cell
158
+ unchanged, so the diff is still empty and nothing reaches the wire.
159
+
147
160
  The practical rule for writing a component: **call `super` in your
148
161
  `repaint`** to inherit that clearing, then paint your content. The only
149
162
  components that skip `super` are the few that paint every cell of their
data/book/03-layout.md CHANGED
@@ -40,9 +40,9 @@ rectangles they are given. If new content arrives that is too tall for
40
40
  its pane, the pane scrolls or clips — it does not push back on the
41
41
  parent to grow.
42
42
 
43
- This is already how Tuile's tiled UI works today: `ScreenPane` sizes
44
- your content and the status bar; every real layout you write positions
45
- its children the same way. The chapter's job is to convince you that
43
+ This is already how Tuile's tiled UI works today: `ScreenPane` hands your
44
+ content the whole terminal; every real layout you write positions its
45
+ children the same way. The chapter's job is to convince you that
46
46
  this is a feature, then show you the few pieces of vocabulary that make
47
47
  it comfortable.
48
48
 
@@ -342,16 +342,17 @@ The boxes are sugar, not a replacement, and they can't say everything. A **cap
342
342
  on a proportion** is the case to recognise:
343
343
 
344
344
  ```ruby
345
- list_width = (rect.width / 3).clamp(20, 40) # a third, but never <20 or >40
346
345
  group_width = [16, rect.width / 3].min # a third, but never more than 16
346
+ list_width = (rect.width / 3).clamp(20, 40) # a third, but never <20 or >40
347
347
  ```
348
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.
349
+ The first is in `examples/sampler.rb` twice — the sidebar in its CheckboxGroup
350
+ pane and the one in its List pane — and both keep a `rect=` override. That's
351
+ the intended division of labour rather than a gap to work around: use a box for
352
+ the stack, drop to `Absolute` for the region that genuinely needs arithmetic —
353
+ usually nesting one inside the other, so only the awkward part carries any. The
354
+ sampler does exactly that, and porting it to these layouts took it from 59
355
+ hand-written rectangles down to a handful (5 today).
355
356
 
356
357
  ## Geometry: `Point`, `Size`, `Rect`
357
358
 
data/book/05-focus.md CHANGED
@@ -125,6 +125,17 @@ scan of the scope for a component carrying a matching "shortcut key,"
125
125
  which would jump focus to it. It's gone; the next section explains why the
126
126
  bubble does that job better.
127
127
 
128
+ One thing does happen *after* the ladder, and it lives in the loop rather
129
+ than in dispatch: if nothing handled the key and it was `q` or ESC, the
130
+ loop stops and your program exits. That is what a `q quit` hint in an app's
131
+ status line is describing — not a binding anyone registered, but the fate
132
+ of an unclaimed quit key. It also explains why ESC means different
133
+ things in different places: an open {Tuile::Component::Popup} handles ESC
134
+ itself (dismissing is its job), so the loop never sees it and the popup
135
+ closes instead of the app. And a widget keeps a stray `q` from quitting
136
+ simply by consuming it, which a focused {Tuile::Component::TextField} was
137
+ doing anyway — it's a printable character.
138
+
128
139
  ## Scope-wide keys live on an ancestor
129
140
 
130
141
  Two things every app wants: `1`/`2`/`3` to jump between panes, and Enter to
@@ -161,6 +172,7 @@ The same mechanism gives you a form's default button, one form per popup:
161
172
  | `Button` | consumes it | activates *itself*, not the default |
162
173
  | `Checkbox` | consumes it (toggles) | the form never sees it |
163
174
  | `Select` | consumes it (opens, then commits) | the form never sees it |
175
+ | `Tabs` | declines | bubbles up → submit |
164
176
 
165
177
  Because bubbling stops at the scope root, two forms in two popups each get
166
178
  their own Enter — something a global registry structurally cannot do. This
@@ -172,6 +184,76 @@ child table, rather than each widget declaring its own mnemonic. That's a
172
184
  fair trade: which key jumps where is a decision about the assembly, and it
173
185
  reads well in one place.
174
186
 
187
+ ## Paste is not a keystroke
188
+
189
+ Everything above is about keys. A paste looks like keys — and that
190
+ resemblance is a genuine problem, not a convenience.
191
+
192
+ Ask a terminal to paste eight lines and, by default, it types them at your
193
+ program: one byte at a time, with every line break converted to `\r`. That
194
+ `\r` is byte-identical to the Enter you press with your finger. So a prompt
195
+ that rebinds Enter to "submit" submits eight times, and no amount of
196
+ cleverness in `handle_key` can tell the two apart — by the time the key
197
+ arrives, the information is gone.
198
+
199
+ The fix has to happen one layer down, at the code that talks to the
200
+ terminal. {Tuile::Screen#run_event_loop} enables **bracketed paste** (DEC
201
+ private mode 2004), which asks the terminal to wrap pasted text in
202
+ `\e[200~` … `\e[201~` markers. Tuile's key thread recognizes the opening
203
+ marker, reads the payload raw up to the terminator, and posts it as a
204
+ single `PasteEvent` — which never enters the ladder at all:
205
+
206
+ - no Tab traversal, no global shortcuts, no `handle_key`;
207
+ - straight to {Tuile::Component#handle_paste}, delivered down the focus
208
+ chain and bubbling exactly like a key;
209
+ - the whole clipboard as one `String`, `\n`-normalized.
210
+
211
+ The default `handle_paste` returns `false` and the text is dropped.
212
+ {Tuile::Component::AbstractStringField} overrides it to insert at the caret
213
+ as **one** mutation — so `on_change` fires once for the paste rather than
214
+ once per character, and a subclass that claims Enter needs no paste code of
215
+ its own:
216
+
217
+ ```ruby
218
+ class PromptTextArea < Tuile::Component::TextArea
219
+ protected
220
+
221
+ def handle_text_input_key(key)
222
+ return super unless key == Tuile::Keys::ENTER
223
+
224
+ submit(text) # a typed Enter, and only ever a typed Enter
225
+ self.text = ""
226
+ true
227
+ end
228
+ end
229
+ ```
230
+
231
+ Override `handle_paste` yourself when a paste should mean something other
232
+ than "insert this": collapsing a huge clipboard to a `[Pasted 230 lines]`
233
+ placeholder, say, or pulling a file path out of it.
234
+
235
+ ```ruby
236
+ def handle_paste(text)
237
+ return super if text.lines.size < 20
238
+
239
+ attach_as_file(text)
240
+ self.text = "#{text.lines.size} lines attached"
241
+ true
242
+ end
243
+ ```
244
+
245
+ Two smaller consequences worth knowing. Because the payload is read raw
246
+ rather than through {Tuile::Keys.getkey}, a pasted ESC or Tab stays payload
247
+ — unbracketed, a pasted Tab moves focus and a pasted ESC swallows the five
248
+ bytes behind it. And the line endings are normalized for you: terminals
249
+ disagree about whether a bracketed line break is `\r`, `\r\n` or `\n`, so
250
+ Tuile settles on `\n` before the text reaches a component.
251
+
252
+ `run_event_loop(bracketed_paste: false)` turns the mode off, the same way
253
+ `capture_mouse: false` turns off mouse tracking. Then a paste is keystrokes
254
+ again, with the ambiguity that implies — reach for it only if a terminal
255
+ mishandles the mode.
256
+
175
257
  ## Where the cursor comes in — and where it doesn't
176
258
 
177
259
  A component signals cursor ownership through
@@ -188,28 +270,61 @@ needed. With dispatch resting on nothing but "did you return `true`," the
188
270
  proxy is gone, and a component's decision to consume a key is the only
189
271
  declaration in the system.
190
272
 
191
- ## The status bar writes itself
273
+ ## Writing a status line
274
+
275
+ Chapter 1 pointed out that Tuile draws no status bar. This is the chapter
276
+ where you find out that's a decision about *ownership*, not an omission —
277
+ and that most status lines don't need this chapter's machinery at all.
278
+
279
+ A status line is a `Label` in your layout. That's the whole idea:
192
280
 
193
- You've seen the bottom row showing hints like `q quit` since chapter 1.
194
- It's driven by focus. Whenever focus changes, the screen rebuilds the
195
- status bar from two sources: the currently-relevant shortcuts, and the
196
- focused context's own advertised hint.
281
+ ```ruby
282
+ status = Tuile::Component::Label.new
283
+ status.text = "q #{screen.theme.hint("quit")} Tab #{screen.theme.hint("Switch")}"
284
+
285
+ root = Tuile::Component::Layout::Vertical.new
286
+ root.add(main_ui, Tuile::Component::Layout::Expand[1])
287
+ root.add(status, Tuile::Component::Layout::Fixed[1])
288
+ ```
289
+
290
+ If the keys your app offers are the same wherever the user is, you are
291
+ done — set the text once and never touch it again. `examples/file_commander.rb`
292
+ is exactly this: Tab, Enter and Backspace work in both panes, so its row is
293
+ a constant. Reaching for a focus callback there would be machinery computing
294
+ a value that never changes.
197
295
 
198
- A component advertises its hint by overriding
199
- {Tuile::Component#keyboard_hint} to return a preformatted string
200
- (components build these with `theme.hint(...)` so the styling matches).
201
- The screen composes the bar differently depending on what's in front:
296
+ Two details about the text itself. `theme.hint(...)` styles the descriptive
297
+ half of a `key what` pair so hints look consistent (chapter 6), and it
298
+ **bakes the color in** — so a label built from it rebuilds itself from
299
+ `on_theme_changed` to follow a light/dark flip. And keys registered with
300
+ {Tuile::Screen#register_global_shortcut} don't advertise themselves: the
301
+ registry runs actions, it doesn't describe them, so a `^K menu` in your row
302
+ is text you write next to the registration.
202
303
 
203
- - **Tiled (no popup):** `q quit`, then any global-shortcut hints, then the
204
- active window's `keyboard_hint`.
205
- - **Popup open:** the over-popups global hints, then the popup's own hint
206
- (a popup owns its `q Close` prefix).
304
+ ### When the row does depend on focus
305
+
306
+ Some apps genuinely show different keys in different places — a window with
307
+ a search mode, or a pane whose commands only apply to it. For those,
308
+ {Tuile::Screen#on_focus_changed=} is the notification:
309
+
310
+ ```ruby
311
+ screen.on_focus_changed = -> { status.text = hint_for(screen.focused) }
312
+ ```
207
313
 
208
- You don't assemble the bar yourself; you override `keyboard_hint` on the
209
- components that have shortcuts worth advertising, register global
210
- shortcuts with a `hint:`, and the composition happens on every focus
211
- change. The status bar is a *view* of the focus state, not a thing you
212
- maintain.
314
+ It fires after every focus *change* — to and from `nil` included, and after
315
+ the repair that runs when a popup closes. It's edge-triggered, so
316
+ re-focusing what already has focus fires nothing and your callback can
317
+ rebuild the string unconditionally. Two things it must tolerate: `focused`
318
+ being `nil`, and firing during `screen.close`, which clears focus as it
319
+ unmounts.
320
+
321
+ What `hint_for` does is entirely yours — Tuile has no notion of a hint and
322
+ no method for one, so there is no interface here to conform to.
323
+ `examples/sampler.rb` names the focused component's class, which makes Tab
324
+ traversal visible as you walk a pane. An app with per-window keys usually
325
+ walks up the focus chain from `screen.focused` and takes the first answer,
326
+ because that mirrors the direction a key bubbles — but that's an app's
327
+ design decision, not a framework pattern.
213
328
 
214
329
  ---
215
330
 
data/book/06-theming.md CHANGED
@@ -24,7 +24,7 @@ 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 shortcut captions
27
- in the status bar. Those are the accents, and they are exactly the tokens
27
+ in a status line you write. Those are the accents, and they are exactly the tokens
28
28
  a {Tuile::Theme} carries — `active_bg_color`, `active_border_color`,
29
29
  `input_bg_color`, `hint_color`. There is no global `bg` or `fg` token,
30
30
  and that absence is intentional: adding one would mean painting over the
@@ -250,7 +250,10 @@ integer) that {Tuile::Color}.coerce accepts elsewhere. A theme is
250
250
  declared once per app, so the extra verbosity buys self-documentation —
251
251
  `Color.palette(130)` says "palette index," and the named constant
252
252
  `Color::DARK_ORANGE3` says even more, where a bare `130` at the
253
- declaration site says nothing.
253
+ declaration site says nothing. All 256 xterm palette names are there as
254
+ constants — `Color::DODGER_BLUE1`, `Color::GREY37` — and
255
+ `Color::PALETTE_NAMES` is the enumerable map behind them if you'd rather
256
+ browse than guess.
254
257
 
255
258
  ## When the theme changes under your content
256
259