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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +47 -0
- data/DECISIONS.md +1961 -0
- data/README.md +82 -48
- data/book/01-first-app.md +186 -0
- data/book/02-repaint.md +177 -0
- data/book/03-layout.md +379 -0
- data/book/04-event-loop.md +295 -0
- data/book/05-focus.md +219 -0
- data/book/06-theming.md +302 -0
- data/book/07-components.md +585 -0
- data/book/08-testing.md +199 -0
- data/book/09-styled-text.md +132 -0
- data/book/README.md +85 -0
- data/examples/hello_world.rb +1 -2
- data/examples/sampler.rb +435 -20
- data/ideas/new-components.md +109 -0
- data/ideas/per-component-buffers.md +55 -0
- data/lib/tuile/buffer.rb +113 -43
- data/lib/tuile/color.rb +4 -10
- data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
- data/lib/tuile/component/button.rb +25 -29
- data/lib/tuile/component/checkbox.rb +133 -0
- data/lib/tuile/component/checkbox_group.rb +188 -0
- data/lib/tuile/component/combo_box.rb +281 -0
- data/lib/tuile/component/has_caption.rb +44 -0
- data/lib/tuile/component/has_content.rb +5 -5
- data/lib/tuile/component/has_value.rb +64 -0
- data/lib/tuile/component/info_window.rb +4 -2
- data/lib/tuile/component/integer_field.rb +135 -0
- data/lib/tuile/component/label.rb +20 -25
- data/lib/tuile/component/layout.rb +3 -26
- data/lib/tuile/component/list.rb +8 -33
- data/lib/tuile/component/list_dropdown.rb +106 -0
- data/lib/tuile/component/log_window.rb +0 -14
- data/lib/tuile/component/password_field.rb +105 -0
- data/lib/tuile/component/popup.rb +70 -79
- data/lib/tuile/component/progress_bar.rb +278 -0
- data/lib/tuile/component/radio_group.rb +188 -0
- data/lib/tuile/component/text_area.rb +189 -65
- data/lib/tuile/component/text_field.rb +170 -32
- data/lib/tuile/component/text_view.rb +57 -137
- data/lib/tuile/component/window.rb +88 -121
- data/lib/tuile/component.rb +246 -142
- data/lib/tuile/event_queue.rb +39 -21
- data/lib/tuile/fake_event_queue.rb +32 -7
- data/lib/tuile/fake_screen.rb +4 -5
- data/lib/tuile/fraction.rb +42 -0
- data/lib/tuile/screen.rb +210 -109
- data/lib/tuile/screen_pane.rb +56 -44
- data/lib/tuile/styled_string.rb +112 -83
- data/lib/tuile/theme.rb +78 -41
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +2291 -890
- metadata +28 -9
- data/ideas/back-buffer.md +0 -217
- 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*.
|
data/book/06-theming.md
ADDED
|
@@ -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.
|