tuile 0.9.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 +32 -0
- data/DECISIONS.md +1961 -0
- data/README.md +31 -24
- data/book/04-event-loop.md +86 -0
- data/book/05-focus.md +82 -51
- data/book/06-theming.md +53 -1
- data/book/07-components.md +363 -9
- data/book/08-testing.md +21 -8
- data/book/09-styled-text.md +132 -0
- data/book/README.md +19 -13
- 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 +52 -51
- 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 -21
- 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/integer_field.rb +135 -0
- data/lib/tuile/component/label.rb +13 -10
- data/lib/tuile/component/layout.rb +3 -17
- data/lib/tuile/component/list.rb +8 -7
- data/lib/tuile/component/list_dropdown.rb +106 -0
- data/lib/tuile/component/password_field.rb +105 -0
- data/lib/tuile/component/popup.rb +10 -14
- 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 -114
- data/lib/tuile/component/window.rb +32 -47
- data/lib/tuile/component.rb +250 -87
- data/lib/tuile/event_queue.rb +14 -17
- data/lib/tuile/fake_event_queue.rb +11 -2
- data/lib/tuile/fake_screen.rb +4 -5
- data/lib/tuile/fraction.rb +6 -10
- data/lib/tuile/screen.rb +202 -104
- data/lib/tuile/screen_pane.rb +51 -41
- 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 +2043 -614
- metadata +18 -7
data/README.md
CHANGED
|
@@ -98,9 +98,8 @@ Shift+Tab move focus between the list and the demo's widgets.
|
|
|
98
98
|
### Component tree
|
|
99
99
|
|
|
100
100
|
Everything on screen is a `Tuile::Component`. Components have a `parent`,
|
|
101
|
-
`children`, a `rect` (absolute position), an `active?` flag (true for
|
|
102
|
-
component on the focus chain root → focused)
|
|
103
|
-
that the framework will route keys to from anywhere in the tree.
|
|
101
|
+
`children`, a `rect` (absolute position), and an `active?` flag (true for
|
|
102
|
+
every component on the focus chain root → focused).
|
|
104
103
|
|
|
105
104
|
A single `Tuile::Screen` (process singleton) owns the tree. Under it sits a
|
|
106
105
|
structural `ScreenPane` with three slots: tiled `content` (your app's main
|
|
@@ -194,11 +193,13 @@ mechanism that handles it wins:
|
|
|
194
193
|
screen.unregister_global_shortcut(Tuile::Keys::CTRL_L)
|
|
195
194
|
```
|
|
196
195
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
196
|
+
This registry sits above the component tree and nothing suppresses it,
|
|
197
|
+
so it only accepts keys no widget can need: printable keys raise (they'd
|
|
198
|
+
hijack typing), as do Tab/Shift+Tab and `Screen::EDITING_KEYS` (Enter,
|
|
199
|
+
Backspace, Delete, the arrows). Control characters, ESC, `PgUp`/`PgDn`
|
|
200
|
+
and F-keys are yours. By default, the shortcut is suppressed while any
|
|
201
|
+
popup is open and the popup receives the key; pass `over_popups: true`
|
|
202
|
+
to pre-empt the popup.
|
|
202
203
|
|
|
203
204
|
Pass `hint:` to surface the shortcut in the status bar. It's a
|
|
204
205
|
preformatted string the caller fully owns (color it however the rest
|
|
@@ -207,23 +208,10 @@ mechanism that handles it wins:
|
|
|
207
208
|
`over_popups: true` hints show up, prepended before the popup's
|
|
208
209
|
`q Close`. Omit `hint:` to leave the shortcut silent in the status bar.
|
|
209
210
|
|
|
210
|
-
3. **`Component#
|
|
211
|
-
component. The framework walks the focused component's subtree for a
|
|
212
|
-
match and focuses the winner. Good fit for "press F to focus the filter
|
|
213
|
-
field" or one-key tab pickers. The lookup is suppressed while the
|
|
214
|
-
focused component owns the hardware cursor (e.g. a `TextField` the user
|
|
215
|
-
is typing into) so editing isn't interrupted:
|
|
216
|
-
|
|
217
|
-
```ruby
|
|
218
|
-
filter_field.key_shortcut = "f"
|
|
219
|
-
```
|
|
220
|
-
|
|
221
|
-
4. **`Component#handle_key`** — override this on your own component when
|
|
211
|
+
3. **`Component#handle_key`** — override this on your own component when
|
|
222
212
|
it needs to react to keys directly (a list reacting to arrows, a custom
|
|
223
213
|
widget handling Enter, …). Return `true` to mark the key handled,
|
|
224
|
-
`false` to let the
|
|
225
|
-
default `key_shortcut` subtree lookup; suppress it only when you
|
|
226
|
-
deliberately want this component to swallow everything:
|
|
214
|
+
`false` to let the key keep travelling:
|
|
227
215
|
|
|
228
216
|
```ruby
|
|
229
217
|
class Toggle < Tuile::Component
|
|
@@ -233,7 +221,26 @@ mechanism that handles it wins:
|
|
|
233
221
|
invalidate
|
|
234
222
|
true
|
|
235
223
|
else
|
|
236
|
-
|
|
224
|
+
false
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
The key goes to the focused component first, then **bubbles up its
|
|
231
|
+
ancestors** to the scope root (the topmost popup, or the tiled content).
|
|
232
|
+
That makes an ancestor the right home for scope-wide keys — a form's
|
|
233
|
+
default button, or one-key jumps between panes — and it needs no special
|
|
234
|
+
protection, because a focused `TextField` consumes the key before the
|
|
235
|
+
ancestor ever sees it:
|
|
236
|
+
|
|
237
|
+
```ruby
|
|
238
|
+
class AppLayout < Tuile::Component::Layout::Absolute
|
|
239
|
+
def handle_key(key)
|
|
240
|
+
case key
|
|
241
|
+
when "1" then @files.focus; true
|
|
242
|
+
when "2" then @log.focus; true
|
|
243
|
+
else false
|
|
237
244
|
end
|
|
238
245
|
end
|
|
239
246
|
end
|
data/book/04-event-loop.md
CHANGED
|
@@ -172,6 +172,92 @@ Reach for `tick` when you're pacing work ("check every two seconds"),
|
|
|
172
172
|
`tick_fps` when you're driving an animation ("spin at 8 fps"). Same
|
|
173
173
|
machinery underneath; pick the unit that matches how you're thinking.
|
|
174
174
|
|
|
175
|
+
## Owning a resource for as long as you're on screen
|
|
176
|
+
|
|
177
|
+
Both examples above end with a loose thread: *who calls `cancel`, and
|
|
178
|
+
when?* If the spinner lives in a popup the user can close, then someone
|
|
179
|
+
has to remember to stop the ticker at exactly the moment the popup goes
|
|
180
|
+
away — and "someone remembers" is how you end up with a timer firing
|
|
181
|
+
against a component nobody can see, forever.
|
|
182
|
+
|
|
183
|
+
The component itself is the only thing that knows. So it gets told:
|
|
184
|
+
|
|
185
|
+
```ruby
|
|
186
|
+
class Spinner < Tuile::Component::Label
|
|
187
|
+
FRAMES = %w[/ - \\ |]
|
|
188
|
+
|
|
189
|
+
protected
|
|
190
|
+
|
|
191
|
+
def on_attached
|
|
192
|
+
@ticker = screen.event_queue.tick_fps(8) { |n| self.text = FRAMES[n % FRAMES.size] }
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def on_detached
|
|
196
|
+
@ticker&.cancel
|
|
197
|
+
@ticker = nil
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
`on_attached` fires the moment this component's tree is mounted on the
|
|
203
|
+
screen; `on_detached` fires the moment it's unmounted. Add the spinner to a
|
|
204
|
+
popup and it starts; close the popup and it stops. Nothing at the call site
|
|
205
|
+
remembers anything — `popup.close` is the whole teardown.
|
|
206
|
+
|
|
207
|
+
The contract is a mirror: **`on_attached` starts what `on_detached` stops.**
|
|
208
|
+
Keep both cheap and idempotent, because a component *moved* from one parent
|
|
209
|
+
to another gets `on_detached` and then `on_attached` — between those two
|
|
210
|
+
calls it genuinely is off the screen, possibly for a long time, so stopping
|
|
211
|
+
and restarting is the honest thing to do. And whatever you acquire in
|
|
212
|
+
`on_attached` you must release in `on_detached`, because nothing else will.
|
|
213
|
+
|
|
214
|
+
This generalizes well beyond tickers, and the interesting case is
|
|
215
|
+
subscriptions. A component may depend on a service, but a service must never
|
|
216
|
+
reach back up into the UI — so when data has to flow *upward*, the component
|
|
217
|
+
subscribes and the service emits blind. That subscription is a resource with
|
|
218
|
+
exactly the lifetime the hooks describe:
|
|
219
|
+
|
|
220
|
+
```ruby
|
|
221
|
+
class BuildStatus < Tuile::Component::Label
|
|
222
|
+
def initialize(service)
|
|
223
|
+
super()
|
|
224
|
+
@service = service
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
protected
|
|
228
|
+
|
|
229
|
+
def on_attached
|
|
230
|
+
@subscription = @service.on_change { |s| screen.event_queue.submit { self.text = s } }
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def on_detached
|
|
234
|
+
@subscription&.unsubscribe
|
|
235
|
+
@subscription = nil
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Note the `submit` inside the listener — the service emits from whatever
|
|
241
|
+
thread it likes, and marshalling onto the UI thread is the listener's job,
|
|
242
|
+
exactly as earlier in this chapter. What the hooks add is the other half:
|
|
243
|
+
the subscription exists for precisely as long as the component is on
|
|
244
|
+
screen, and no view-closing code path has to know that the subscription
|
|
245
|
+
exists at all.
|
|
246
|
+
|
|
247
|
+
`screen.close` counts as unmounting, so the `screen.close` at the end of
|
|
248
|
+
your `main` gives every component still on screen its `on_detached` — the
|
|
249
|
+
tickers stop, the subscriptions come off, and you didn't write any of that
|
|
250
|
+
teardown. What *doesn't* fire is a process that exits without closing the
|
|
251
|
+
screen at all: these are lifecycle hooks, not destructors, and Tuile
|
|
252
|
+
installs no `at_exit`. If your `on_detached` does something that matters
|
|
253
|
+
beyond the process — flushing a file, say — close the screen deliberately
|
|
254
|
+
rather than relying on exit.
|
|
255
|
+
|
|
256
|
+
The other thing the hooks are not is a place to do layout. When
|
|
257
|
+
`on_attached` runs, your parent hasn't assigned your `rect` yet. If you need
|
|
258
|
+
to paint, invalidate here and do the work in `repaint`, which is what
|
|
259
|
+
chapter 2 was about anyway.
|
|
260
|
+
|
|
175
261
|
## Resize is just another event
|
|
176
262
|
|
|
177
263
|
A terminal resize could have been handled off the `SIGWINCH` signal
|
data/book/05-focus.md
CHANGED
|
@@ -55,8 +55,8 @@ Shift+Tab land on this component while cycling. It's also `false` by
|
|
|
55
55
|
default, and it *implies* focusable — a tab stop is always a valid focus
|
|
56
56
|
target, but not every focus target is a tab stop. The distinction matters
|
|
57
57
|
for containers: a {Tuile::Component::Window} is focusable (so a click on
|
|
58
|
-
its chrome
|
|
59
|
-
|
|
58
|
+
its chrome can focus it) but is *not* a tab stop (Tab should skip the frame
|
|
59
|
+
and stop on the actual inputs inside it). So:
|
|
60
60
|
|
|
61
61
|
- **Label** — neither. Decoration.
|
|
62
62
|
- **Window, Popup** — focusable, not a tab stop. Clickable chrome,
|
|
@@ -80,8 +80,8 @@ earlier.
|
|
|
80
80
|
**1. Tab and Shift+Tab — focus navigation, first, always.** These are
|
|
81
81
|
intercepted before anything else and drive the cycling described above.
|
|
82
82
|
They're taken off the top deliberately: a focused text field swallows
|
|
83
|
-
almost every printable key
|
|
84
|
-
|
|
83
|
+
almost every printable key, and if Tab weren't reserved here, a field would
|
|
84
|
+
trap it too and you could never Tab out.
|
|
85
85
|
|
|
86
86
|
**2. Global shortcuts.** App-level shortcuts registered with
|
|
87
87
|
{Tuile::Screen#register_global_shortcut} fire next, before any component
|
|
@@ -95,32 +95,24 @@ screen.register_global_shortcut(Tuile::Keys::CTRL_L,
|
|
|
95
95
|
end
|
|
96
96
|
```
|
|
97
97
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
**4. `handle_key` on the focus chain.** Finally, if nothing above claimed
|
|
118
|
-
the key, it's delivered to the focused component's
|
|
119
|
-
{Tuile::Component#handle_key}, and if that returns `false` (didn't handle
|
|
120
|
-
it), it bubbles up the ancestor chain — the focused component, then its
|
|
121
|
-
parent, then *its* parent — until someone returns `true` or the scope
|
|
122
|
-
root is reached. This is how a list handles arrow keys itself but lets an
|
|
123
|
-
unhandled key rise to the window around it.
|
|
98
|
+
This registry is the only keyboard mechanism that sits *above* the
|
|
99
|
+
component tree, and nothing suppresses it — which is exactly why it's
|
|
100
|
+
picky about what it accepts. Printable keys are rejected at registration
|
|
101
|
+
time, because a global binding on `a` would hijack someone typing `a` into
|
|
102
|
+
a text field. So are Tab and Shift+Tab (step 1 already took them), and so
|
|
103
|
+
are `Screen::EDITING_KEYS` — Enter, Backspace, Delete and the arrows —
|
|
104
|
+
because every editable widget needs those, and a global binding would
|
|
105
|
+
break text entry app-wide with no way for a field to defend itself. What's
|
|
106
|
+
left is yours: control keys, ESC, PgUp/PgDn, function keys. A shortcut can
|
|
107
|
+
opt to fire even while a modal popup is open (`over_popups: true`); by
|
|
108
|
+
default it's suppressed while a popup is up, so the popup stays modal.
|
|
109
|
+
|
|
110
|
+
**3. `handle_key`, delivered to focus and bubbling up.** Everything else
|
|
111
|
+
goes to the focused component's {Tuile::Component#handle_key}, and if that
|
|
112
|
+
returns `false` (didn't handle it), the key bubbles up the ancestor chain —
|
|
113
|
+
the focused component, then its parent, then *its* parent — until someone
|
|
114
|
+
returns `true` or the scope root is reached. This is how a list handles
|
|
115
|
+
arrow keys itself but lets an unhandled key rise to the window around it.
|
|
124
116
|
|
|
125
117
|
A component only ever receives a key when it's on the focus chain, so
|
|
126
118
|
`handle_key` implementations act on the key alone — they never need to
|
|
@@ -128,32 +120,71 @@ check their own `active?` state. And if focus is `nil`, or sits outside
|
|
|
128
120
|
the current modal scope, delivery reaches no one: that's precisely what
|
|
129
121
|
makes an open modal popup modal.
|
|
130
122
|
|
|
131
|
-
|
|
123
|
+
Three rungs, and that's the whole ladder. Tuile used to have a fourth — a
|
|
124
|
+
scan of the scope for a component carrying a matching "shortcut key,"
|
|
125
|
+
which would jump focus to it. It's gone; the next section explains why the
|
|
126
|
+
bubble does that job better.
|
|
132
127
|
|
|
133
|
-
|
|
134
|
-
a search field, and elsewhere in the same window a list has `key_shortcut
|
|
135
|
-
= "d"` for "delete." You click into the field and type "add item." Should
|
|
136
|
-
the "d" in "add" trigger delete?
|
|
128
|
+
## Scope-wide keys live on an ancestor
|
|
137
129
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
the
|
|
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
|
|
142
174
|
|
|
143
175
|
A component signals cursor ownership through
|
|
144
176
|
{Tuile::Component#cursor_position} — return a `Point` and the terminal
|
|
145
|
-
cursor is shown there; return `nil` (the default) and there's no cursor.
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
otherwise pick them off.
|
|
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.
|
|
157
188
|
|
|
158
189
|
## The status bar writes itself
|
|
159
190
|
|
data/book/06-theming.md
CHANGED
|
@@ -38,6 +38,55 @@ colors plus an app-extensible `custom` hash — and that's all. Two are
|
|
|
38
38
|
built in: {Tuile::Theme::DARK}, the colors Tuile has always used, and
|
|
39
39
|
{Tuile::Theme::LIGHT}, counterparts legible on a pale background.
|
|
40
40
|
|
|
41
|
+
## Backgrounds are opt-in
|
|
42
|
+
|
|
43
|
+
The stance above — paint accents, leave the rest to the terminal — is how
|
|
44
|
+
the *framework* paints. But sometimes your *app* wants a real background:
|
|
45
|
+
an overlay panel, a slash-command menu, a dropdown that has to read as one
|
|
46
|
+
solid tinted block floating over the content beneath it — filler rows
|
|
47
|
+
included, not a ragged half-shaded box. That is {Tuile::Component#bg_color}.
|
|
48
|
+
|
|
49
|
+
Set it on a component and that component paints a background behind
|
|
50
|
+
everything it draws; leave it unset (the default) and you get the
|
|
51
|
+
terminal default, exactly as before. The useful part is that it
|
|
52
|
+
**inherits**: set `bg_color` once on a container — a
|
|
53
|
+
{Tuile::Component::Popup}, a layout, a window — and every descendant that
|
|
54
|
+
hasn't set its own picks it up. You tint the panel, and the labels and
|
|
55
|
+
lists inside come out on the same tint without being told individually.
|
|
56
|
+
|
|
57
|
+
That inheritance has to be *manufactured*, because a terminal has no
|
|
58
|
+
transparency: every cell holds exactly one background, and painting a
|
|
59
|
+
glyph replaces the whole cell (chapter 2's opaque grid). So Tuile resolves
|
|
60
|
+
the effective background at paint time by walking up to the nearest
|
|
61
|
+
ancestor that set a `bg_color` — the same read-at-paint discipline the
|
|
62
|
+
theme uses, and for the same reason: change a container's background and
|
|
63
|
+
its subtree is invalidated and simply repaints in the new color. The
|
|
64
|
+
terminal default is just the root of that chain, which is why an unset
|
|
65
|
+
`bg_color` everywhere behaves exactly like it always has.
|
|
66
|
+
|
|
67
|
+
A widget with a background of its *own* keeps it. A text field paints its
|
|
68
|
+
well across its whole rect, so dropping one into a tinted panel shows the
|
|
69
|
+
field in its own well, not the panel tint — the explicit background wins
|
|
70
|
+
over the inherited one, the terminal equivalent of a CSS element that sets
|
|
71
|
+
its own `background`.
|
|
72
|
+
|
|
73
|
+
`bg_color` takes either a concrete {Tuile::Color} or a *live theme
|
|
74
|
+
reference* — `Theme.ref(:panel_bg)` — that names one of your app's custom
|
|
75
|
+
tokens and re-resolves it against the current theme on every paint. The
|
|
76
|
+
reference is the ergonomic path: assign it once and the panel follows
|
|
77
|
+
light and dark on its own, with no `on_theme_changed` handler. That works
|
|
78
|
+
precisely because a background — unlike the baked-in colors of your
|
|
79
|
+
*content* (below) — is resolved *live* at paint, exactly like the
|
|
80
|
+
framework's own accents; `bg_color` is a single value read late, so
|
|
81
|
+
late-binding it to a token costs nothing. It is the one place an app color
|
|
82
|
+
tracks the theme without the hook.
|
|
83
|
+
|
|
84
|
+
A reference reaches your *custom* tokens only, never a framework-imposed
|
|
85
|
+
global, so the "no global background token" line holds either way: the
|
|
86
|
+
framework still paints no background of its own. You opt in per
|
|
87
|
+
component — a concrete `Color` when you want it fixed, a `Theme.ref` when
|
|
88
|
+
you want it to follow the scheme.
|
|
89
|
+
|
|
41
90
|
## Read at paint time, never cached
|
|
42
91
|
|
|
43
92
|
There is one rule about *using* the theme that everything else depends
|
|
@@ -207,7 +256,10 @@ declaration site says nothing.
|
|
|
207
256
|
|
|
208
257
|
The built-in components restyle for free because they read the theme at
|
|
209
258
|
paint time. Your *content* can't always do that — and this is the one
|
|
210
|
-
theming responsibility that lands on the app.
|
|
259
|
+
theming responsibility that lands on the app. (A *background* is the
|
|
260
|
+
exception: `bg_color = Theme.ref(:token)` tracks the theme live with no
|
|
261
|
+
handler, because it's resolved at paint — see "Backgrounds are opt-in"
|
|
262
|
+
above. The hook below is for baked-in *content* colors.)
|
|
211
263
|
|
|
212
264
|
The problem: when you build a {Tuile::StyledString} for a
|
|
213
265
|
{Tuile::Component::Label} or a {Tuile::Component::List} row, its colors
|