tuile 0.8.0 → 0.10.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -0
  3. data/DECISIONS.md +1961 -0
  4. data/README.md +82 -48
  5. data/book/01-first-app.md +186 -0
  6. data/book/02-repaint.md +177 -0
  7. data/book/03-layout.md +379 -0
  8. data/book/04-event-loop.md +295 -0
  9. data/book/05-focus.md +219 -0
  10. data/book/06-theming.md +302 -0
  11. data/book/07-components.md +585 -0
  12. data/book/08-testing.md +199 -0
  13. data/book/09-styled-text.md +132 -0
  14. data/book/README.md +85 -0
  15. data/examples/hello_world.rb +1 -2
  16. data/examples/sampler.rb +435 -20
  17. data/ideas/new-components.md +109 -0
  18. data/ideas/per-component-buffers.md +55 -0
  19. data/lib/tuile/buffer.rb +113 -43
  20. data/lib/tuile/color.rb +4 -10
  21. data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
  22. data/lib/tuile/component/button.rb +25 -29
  23. data/lib/tuile/component/checkbox.rb +133 -0
  24. data/lib/tuile/component/checkbox_group.rb +188 -0
  25. data/lib/tuile/component/combo_box.rb +281 -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/info_window.rb +4 -2
  30. data/lib/tuile/component/integer_field.rb +135 -0
  31. data/lib/tuile/component/label.rb +20 -25
  32. data/lib/tuile/component/layout.rb +3 -26
  33. data/lib/tuile/component/list.rb +8 -33
  34. data/lib/tuile/component/list_dropdown.rb +106 -0
  35. data/lib/tuile/component/log_window.rb +0 -14
  36. data/lib/tuile/component/password_field.rb +105 -0
  37. data/lib/tuile/component/popup.rb +70 -79
  38. data/lib/tuile/component/progress_bar.rb +278 -0
  39. data/lib/tuile/component/radio_group.rb +188 -0
  40. data/lib/tuile/component/text_area.rb +189 -65
  41. data/lib/tuile/component/text_field.rb +170 -32
  42. data/lib/tuile/component/text_view.rb +57 -137
  43. data/lib/tuile/component/window.rb +88 -121
  44. data/lib/tuile/component.rb +246 -142
  45. data/lib/tuile/event_queue.rb +39 -21
  46. data/lib/tuile/fake_event_queue.rb +32 -7
  47. data/lib/tuile/fake_screen.rb +4 -5
  48. data/lib/tuile/fraction.rb +42 -0
  49. data/lib/tuile/screen.rb +210 -109
  50. data/lib/tuile/screen_pane.rb +56 -44
  51. data/lib/tuile/styled_string.rb +112 -83
  52. data/lib/tuile/theme.rb +78 -41
  53. data/lib/tuile/version.rb +1 -1
  54. data/sig/tuile.rbs +2291 -890
  55. metadata +28 -9
  56. data/ideas/back-buffer.md +0 -217
  57. data/lib/tuile/sizing.rb +0 -59
data/book/05-focus.md ADDED
@@ -0,0 +1,219 @@
1
+ # 5. Focus and the keyboard
2
+
3
+ Chapter 4 ended on a promise: because everything runs on one thread, this
4
+ chapter can describe keyboard handling without a single caveat about
5
+ concurrency. When a key is dispatched, nothing else is happening — no
6
+ repaint mid-flight, no background thread mutating the tree. So the only
7
+ question left is the interesting one: given a keystroke and a tree of
8
+ components, *who gets it?*
9
+
10
+ The answer has two halves. **Focus** decides which component is the
11
+ current target. **Dispatch** decides the order in which components are
12
+ offered a key — because focus is the common case, not the only case.
13
+
14
+ ## The focus chain
15
+
16
+ At any moment, at most one component is **focused**. You set it directly:
17
+
18
+ ```ruby
19
+ screen.focused = some_component # or: some_component.focus
20
+ screen.focused = nil # nothing focused
21
+ ```
22
+
23
+ Focus is not just a flag on one component, though — it's a *chain*.
24
+ Setting focus walks from the target up through its parents to the root,
25
+ and marks every component on that path **active**. Everything not on the
26
+ path is deactivated. So if you focus a text field inside a window inside
27
+ the content area, the field, the window, and the content are all active;
28
+ their siblings are not.
29
+
30
+ That active chain is what components paint against. A {Tuile::Component::Window}
31
+ draws its border in the accent color when it's active and a plain color
32
+ when it isn't — which is why, in a multi-window layout, exactly the
33
+ window containing the focused widget lights up. "Active" means "on the
34
+ path to what has focus," and it falls out of one assignment.
35
+
36
+ ## Two gates: `focusable?` and `tab_stop?`
37
+
38
+ Not everything can be focused, and not everything focusable participates
39
+ in every way of *getting* focused. Two predicates draw those lines, and
40
+ they're independent on purpose.
41
+
42
+ {Tuile::Component#focusable?} gates whether a component can become a focus
43
+ target *at all*. It's `false` by default — a {Tuile::Component::Label} is
44
+ decoration; clicking one shouldn't yank focus away from the window around
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. The same rule governs
49
+ the automatic focus-forwarding a container does when it's focused — a
50
+ window handed focus passes it down to its content, but only if that
51
+ content is focusable.
52
+
53
+ {Tuile::Component#tab_stop?} gates something narrower: whether Tab and
54
+ Shift+Tab land on this component while cycling. It's also `false` by
55
+ default, and it *implies* focusable — a tab stop is always a valid focus
56
+ target, but not every focus target is a tab stop. The distinction matters
57
+ for containers: a {Tuile::Component::Window} is focusable (so a click on
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
+
61
+ - **Label** — neither. Decoration.
62
+ - **Window, Popup** — focusable, not a tab stop. Clickable chrome,
63
+ skipped by Tab.
64
+ - **TextField, List, Button** — both. Real inputs you can click *and*
65
+ Tab to.
66
+
67
+ Tab cycling is confined to a **modal scope**: the topmost modal popup if
68
+ one is open, otherwise the tiled content. Tab collects the tab stops in
69
+ that scope in tree order and advances by one, wrapping around; Shift+Tab
70
+ walks backward. This is what keeps Tab from escaping an open dialog — the
71
+ scope is the dialog, so cycling stays inside it.
72
+
73
+ ## The dispatch order
74
+
75
+ When a key arrives, it's offered to the tree in a fixed order, and the
76
+ first handler to claim it wins. Understanding this order is the whole
77
+ game, because a key you expected one component to get can be intercepted
78
+ earlier.
79
+
80
+ **1. Tab and Shift+Tab — focus navigation, first, always.** These are
81
+ intercepted before anything else and drive the cycling described above.
82
+ They're taken off the top deliberately: a focused text field swallows
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
+
86
+ **2. Global shortcuts.** App-level shortcuts registered with
87
+ {Tuile::Screen#register_global_shortcut} fire next, before any component
88
+ sees the key. These are for app-wide actions — "Ctrl+L opens the log,"
89
+ "F1 shows help" — that should work regardless of what's focused:
90
+
91
+ ```ruby
92
+ screen.register_global_shortcut(Tuile::Keys::CTRL_L,
93
+ hint: "^L #{screen.theme.hint("log")}") do
94
+ log_popup.open
95
+ end
96
+ ```
97
+
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.
116
+
117
+ A component only ever receives a key when it's on the focus chain, so
118
+ `handle_key` implementations act on the key alone — they never need to
119
+ check their own `active?` state. And if focus is `nil`, or sits outside
120
+ the current modal scope, delivery reaches no one: that's precisely what
121
+ makes an open modal popup modal.
122
+
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.
127
+
128
+ ## Scope-wide keys live on an ancestor
129
+
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
+
163
+ Because bubbling stops at the scope root, two forms in two popups each get
164
+ their own Enter — something a global registry structurally cannot do. This
165
+ is also why registering Enter globally is refused in step 2: the registry
166
+ would take it away from all of them at once.
167
+
168
+ The one thing this shape asks of you is that the *parent* holds the key →
169
+ child table, rather than each widget declaring its own mnemonic. That's a
170
+ fair trade: which key jumps where is a decision about the assembly, and it
171
+ reads well in one place.
172
+
173
+ ## Where the cursor comes in — and where it doesn't
174
+
175
+ A component signals cursor ownership through
176
+ {Tuile::Component#cursor_position} — return a `Point` and the terminal
177
+ cursor is shown there; return `nil` (the default) and there's no cursor. A
178
+ {Tuile::Component::TextField} being edited returns its caret position, so
179
+ the caret you see blinking is the focused component's answer to that one
180
+ question.
181
+
182
+ That's all it does. It positions the hardware cursor; it does not route
183
+ keys. Tuile briefly used it as a proxy for "this widget is in text-entry
184
+ mode, don't steal its printable keys" — a signal the deleted fourth rung
185
+ needed. With dispatch resting on nothing but "did you return `true`," the
186
+ proxy is gone, and a component's decision to consume a key is the only
187
+ declaration in the system.
188
+
189
+ ## The status bar writes itself
190
+
191
+ You've seen the bottom row showing hints like `q quit` since chapter 1.
192
+ It's driven by focus. Whenever focus changes, the screen rebuilds the
193
+ status bar from two sources: the currently-relevant shortcuts, and the
194
+ focused context's own advertised hint.
195
+
196
+ A component advertises its hint by overriding
197
+ {Tuile::Component#keyboard_hint} to return a preformatted string
198
+ (components build these with `theme.hint(...)` so the styling matches).
199
+ The screen composes the bar differently depending on what's in front:
200
+
201
+ - **Tiled (no popup):** `q quit`, then any global-shortcut hints, then the
202
+ active window's `keyboard_hint`.
203
+ - **Popup open:** the over-popups global hints, then the popup's own hint
204
+ (a popup owns its `q Close` prefix).
205
+
206
+ You don't assemble the bar yourself; you override `keyboard_hint` on the
207
+ components that have shortcuts worth advertising, register global
208
+ shortcuts with a `hint:`, and the composition happens on every focus
209
+ change. The status bar is a *view* of the focus state, not a thing you
210
+ maintain.
211
+
212
+ ---
213
+
214
+ Focus and dispatch are the last piece of the runtime. You now have the
215
+ whole loop: build a tree (chapter 1), it repaints without flicker
216
+ (chapter 2), sized top-down (chapter 3), driven by a single-threaded
217
+ event loop (chapter 4), with keys routed through focus (this chapter).
218
+ Chapter 6 adds the last cross-cutting concern — color — and then chapters
219
+ 7 and 8 turn from *how the framework works* to *what you build with it*.
@@ -0,0 +1,302 @@
1
+ # 6. Theming
2
+
3
+ Every chapter so far has been about *structure* — the tree, the repaint,
4
+ the loop, focus. This one is about a single presentational concern that
5
+ cuts across all of them: color. It's the last cross-cutting piece of the
6
+ runtime, and it's small, because Tuile takes a deliberately narrow view
7
+ of what a "theme" is. A theme in Tuile is not a stylesheet. It does not
8
+ describe how your app looks. It describes the handful of *accents* the
9
+ framework itself paints — and nothing else.
10
+
11
+ That restraint is the whole design. Understand why the theme is small
12
+ and you understand theming.
13
+
14
+ ## The theme colors only the accents
15
+
16
+ Look at any Tuile screen and most of what you see is the terminal's own
17
+ colors: the default foreground text on the default background. A label's
18
+ text, a window's interior, the body of a list — none of that is themed.
19
+ It inherits whatever the user's terminal is set to, which already matches
20
+ their preferences perfectly. Tuile writes no background fill and no
21
+ foreground color for those cells, so they come out in the terminal's
22
+ defaults for free.
23
+
24
+ What Tuile *does* color is the small set of cues that signal
25
+ interaction: the highlight behind the focused list row, the border of the
26
+ active window, the resting "well" of a text field, the shortcut captions
27
+ in the status bar. Those are the accents, and they are exactly the tokens
28
+ a {Tuile::Theme} carries — `active_bg_color`, `active_border_color`,
29
+ `input_bg_color`, `hint_color`. There is no global `bg` or `fg` token,
30
+ and that absence is intentional: adding one would mean painting over the
31
+ terminal's defaults everywhere, which is precisely the thing that makes a
32
+ TUI look wrong on someone else's color scheme. The theme touches only
33
+ what the framework must color to be legible, and leaves the rest to the
34
+ terminal.
35
+
36
+ So a {Tuile::Theme} is a frozen value type — a `Data.define` of four
37
+ colors plus an app-extensible `custom` hash — and that's all. Two are
38
+ built in: {Tuile::Theme::DARK}, the colors Tuile has always used, and
39
+ {Tuile::Theme::LIGHT}, counterparts legible on a pale background.
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
+
90
+ ## Read at paint time, never cached
91
+
92
+ There is one rule about *using* the theme that everything else depends
93
+ on, and it's stated as an invariant in AGENTS.md because breaking it
94
+ breaks live theme switching: **a component reads `screen.theme` at paint
95
+ time, inside `repaint`, and never stores a theme color in an ivar.**
96
+
97
+ The reason is the repaint model from chapter 2. When the theme changes,
98
+ Tuile does not hunt down every component and tell it which colors to
99
+ update. It does the crude, correct thing: it invalidates the entire tree
100
+ and lets the normal repaint redraw everything. On that repaint each
101
+ component asks `screen.theme` for its accents afresh — so it simply comes
102
+ out in the new colors, with no per-component update logic anywhere. A
103
+ component that cached `theme.active_bg_color` in its constructor would
104
+ keep painting the old color after a switch, an island of stale palette in
105
+ an otherwise-restyled screen. Read it every time; the lookup is a hash
106
+ access, and the repaint that follows a theme change was going to redraw
107
+ you regardless.
108
+
109
+ This is why the built-in components need no theme-change handling at all.
110
+ They read at paint time, the tree is invalidated, they repaint in the new
111
+ colors. Done.
112
+
113
+ ## Two ways to apply a token
114
+
115
+ When a component has a themed color in hand, it applies it in one of two
116
+ ways, and which one depends on the text.
117
+
118
+ For plain chrome — a border string, a status-bar hint — the theme's
119
+ **rendering helpers** wrap the text in the token's SGR color and a reset:
120
+ `theme.active_bg("[ Ok ]")`, `theme.hint("quit")`. The helper picks the
121
+ right channel for the token's role (a `*_bg` token wraps as a background,
122
+ a hint as a foreground) and passes the content through verbatim, so the
123
+ string may already contain other escape sequences — which is how
124
+ {Tuile::Component::Window} feeds its whole border line, cursor moves and
125
+ all, through `active_border`.
126
+
127
+ But chrome text is flat. Content is not. A list row or a label may be a
128
+ {Tuile::StyledString} with its own per-span colors, and wrapping that in
129
+ one blunt SGR color would flatten every span to a single hue. For those,
130
+ the theme exposes the raw color as a `*_color` reader
131
+ (`theme.active_bg_color`) and you hand it to the StyledString, which
132
+ composites it *underneath* the existing spans — {Tuile::Component::List}
133
+ highlights its cursor row with `base.with_bg(theme.active_bg_color)`,
134
+ preserving whatever foreground colors the row already carried. The rule
135
+ of thumb: **plain chrome text → helper; structured text → `*_color`
136
+ reader plus StyledString.**
137
+
138
+ ## Following the terminal, automatically
139
+
140
+ An app never has to ask which theme to use. Tuile picks one by detecting
141
+ whether the terminal background is light or dark, through
142
+ {Tuile::TerminalBackground}.detect — two mechanisms in order of
143
+ reliability. First an **OSC 11 query**: Tuile writes an escape sequence
144
+ asking the terminal for its background color, and a modern terminal
145
+ replies with the RGB, whose luminance decides light versus dark.
146
+ Terminals that don't understand the query simply never answer, so the
147
+ read is bounded by a short timeout and falls through to the second
148
+ mechanism, the `COLORFGBG` environment variable that a few terminals
149
+ export. If both are inconclusive, Tuile assumes dark.
150
+
151
+ The timing of that detection is subtle enough to be a design constraint.
152
+ The OSC 11 *reply arrives on stdin* — the same stream the key thread will
153
+ own once the event loop is running. If detection ran after the loop
154
+ started, those reply bytes would land in the key thread and be consumed
155
+ as a garbage keystroke. So detection must happen *before* stdin is
156
+ claimed, which is why {Tuile::Screen} runs it in its constructor, seeding
157
+ `theme` before your app has built a single component. This is not an
158
+ implementation detail you can relocate — it's why the constructor, not
159
+ some later `setup` call, is where the scheme is decided.
160
+
161
+ Detection at startup handles the common case. But a user can also flip
162
+ their OS between light and dark *while your app is running*, and Tuile
163
+ follows that too, on terminals that support **mode 2031**. The event loop
164
+ enables the mode on startup; the terminal then pushes a small report
165
+ whenever the OS appearance changes; the key thread recognizes that report
166
+ (it's a private-mode CSI sequence, longer than an ordinary key, so it's
167
+ drained specially) and turns it into an {Tuile::EventQueue::ColorSchemeEvent};
168
+ and the loop, receiving that event like any other, re-picks the matching
169
+ theme. From your code's perspective a live appearance flip and a startup
170
+ detection are the same thing arriving through the same channel — which is
171
+ exactly the single-threaded-loop payoff chapter 4 promised.
172
+
173
+ ## Theming an app durably
174
+
175
+ Detection picks between *Tuile's* two themes. To give your app its own
176
+ colors, you supply your own — and here the distinction between a
177
+ transient override and a durable definition matters, because it's easy to
178
+ reach for the wrong one.
179
+
180
+ You *can* assign `screen.theme = ...` directly, and it works: the whole
181
+ UI restyles immediately. But it's a **transient override**. The next time
182
+ the OS appearance flips, Tuile re-picks from its theme *definition* and
183
+ replaces whatever you set. A bare `theme=` is the right tool for a
184
+ one-shot experiment, not for how your app looks.
185
+
186
+ The durable tool is a {Tuile::ThemeDef} — a pair of themes, one for dark
187
+ backgrounds and one for light — assigned once to `screen.theme_def=`.
188
+ Now Tuile picks the matching member at startup *and* re-picks from your
189
+ pair on every appearance flip, so your app stays your app's colors
190
+ through a light/dark toggle. That's the durable path: define the pair,
191
+ assign it once, forget about it.
192
+
193
+ The pair is required to be a *pair* for a reason. A {Tuile::ThemeDef}
194
+ enforces at construction time that its dark and light members declare the
195
+ same set of custom tokens. Without that check, a token you defined only
196
+ on the dark side would raise `KeyError` the moment the user flipped to
197
+ light — at an unpredictable time, far from the mistake. Checking up front
198
+ turns a lurking runtime crash into an immediate, obvious construction
199
+ error.
200
+
201
+ ### Custom tokens
202
+
203
+ Your app almost certainly paints colors the framework knows nothing
204
+ about — a "tool call" cyan, an "error" red, diff-line backgrounds. Those
205
+ live in the theme's `custom` hash, a `Hash{Symbol => Color}`. You read
206
+ one with `theme[:accent]`, which **fail-fasts**: a typo'd token raises
207
+ `KeyError` rather than silently painting a default, so a missing color is
208
+ a loud bug and not a mystery. And you render with the generic `fg` / `bg`
209
+ helpers — `theme.fg(:accent, "NEW")` — the custom-token counterparts of
210
+ the built-in `hint` / `active_bg` helpers.
211
+
212
+ For an app with more than a couple of custom tokens, the tidier move is
213
+ to **subclass** {Tuile::Theme} and give each token a named coloring
214
+ method:
215
+
216
+ ```ruby
217
+ class AppTheme < Tuile::Theme
218
+ def error(text) = fg(:error, text)
219
+ def tool(text) = fg(:tool, text)
220
+ end
221
+ ```
222
+
223
+ Call sites then read `theme.error("...")` instead of the stringly-typed
224
+ `theme.fg(:error, "...")`, and because a `Data` subclass survives `with`,
225
+ your `AppTheme` stays an `AppTheme` through any `with` derivation. You
226
+ build the dark and light instances from the built-in themes plus your
227
+ custom hashes and pair them in a `ThemeDef` — the pattern is small enough
228
+ to state in full:
229
+
230
+ ```ruby
231
+ class AppTheme < Tuile::Theme
232
+ def error(text) = fg(:error, text)
233
+
234
+ DARK = new(**Tuile::Theme::DARK.to_h.merge(custom: { error: Tuile::Color::RED }))
235
+ LIGHT = new(**Tuile::Theme::LIGHT.to_h.merge(custom: { error: Tuile::Color::RED3 }))
236
+ THEME_DEF = Tuile::ThemeDef.new(dark: DARK, light: LIGHT)
237
+ end
238
+
239
+ screen.theme_def = AppTheme::THEME_DEF # once, at boot
240
+ ```
241
+
242
+ Note the light-side error color isn't the same red — bright ANSI accents
243
+ that pop on black often turn illegible on white, so a real light theme
244
+ steps its accents darker. That per-side tuning is the entire point of
245
+ carrying two themes instead of one.
246
+
247
+ One aside on declaring theme colors: a {Tuile::Theme} takes {Tuile::Color}
248
+ instances *only*, never the lenient coercions (`"red"`, a bare palette
249
+ integer) that {Tuile::Color}.coerce accepts elsewhere. A theme is
250
+ declared once per app, so the extra verbosity buys self-documentation —
251
+ `Color.palette(130)` says "palette index," and the named constant
252
+ `Color::DARK_ORANGE3` says even more, where a bare `130` at the
253
+ declaration site says nothing.
254
+
255
+ ## When the theme changes under your content
256
+
257
+ The built-in components restyle for free because they read the theme at
258
+ paint time. Your *content* can't always do that — and this is the one
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.)
263
+
264
+ The problem: when you build a {Tuile::StyledString} for a
265
+ {Tuile::Component::Label} or a {Tuile::Component::List} row, its colors
266
+ are **baked in at construction**. The string is a frozen value with its
267
+ SGR bytes already computed; it does not consult the theme at paint time,
268
+ because {Tuile::StyledString} deliberately knows nothing about `Screen` at
269
+ all (that independence is what lets it be a pure, memoizable value type).
270
+ So if you colored a label with `theme[:accent]` and the theme later
271
+ changes, that label keeps its old accent — the framework can't fix it,
272
+ because only *you* know which of the string's colors came from the theme
273
+ versus which are inherent to the data (a log line's level color, say,
274
+ should *not* follow the theme).
275
+
276
+ The hook for this is {Tuile::Component#on_theme_changed}, fired on every
277
+ attached component whenever the theme changes. Your handler does exactly
278
+ one thing: **re-run the code that rendered the content**, so it rebuilds
279
+ the StyledString against the now-current theme.
280
+
281
+ ```ruby
282
+ label.on_theme_changed = -> { label.text = render_status_line }
283
+ ```
284
+
285
+ There are two ways to consume it, matching how you built the component.
286
+ If you assembled stock components, assign the `on_theme_changed=` proc as
287
+ above. If you subclassed, override the method — and call `super`, so an
288
+ assigned listener still fires. Either way the rule is the same: the hook
289
+ is where theme-derived content gets rebuilt, and the framework handles
290
+ everything else.
291
+
292
+ ---
293
+
294
+ That closes the runtime. Across six chapters you've seen the whole
295
+ machine: a tree of components (chapter 1), repainting without flicker
296
+ (chapter 2), sized top-down by their parents (chapter 3), driven by a
297
+ single-threaded event loop (chapter 4), with keys routed through focus
298
+ (chapter 5) and accents drawn from a terminal-following theme (this one).
299
+ Everything from here is *application*: chapter 7 tours the component
300
+ library — what Tuile ships so you don't build it — and chapter 8 shows
301
+ how to test a UI built this way, using the fakes the design has been
302
+ quietly setting up all along.