tuile 0.14.0 → 0.16.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 +159 -49
- data/README.md +53 -17
- data/book/04-event-loop.md +12 -12
- data/book/05-focus.md +152 -22
- data/book/06-theming.md +105 -25
- data/book/07-components.md +531 -50
- data/book/08-testing.md +100 -20
- data/book/10-locale.md +216 -0
- data/book/README.md +19 -9
- data/examples/file_commander.rb +14 -5
- data/examples/hello_world.rb +17 -4
- data/examples/sampler.rb +654 -40
- data/lib/tuile/component/abstract_string_field.rb +114 -68
- data/lib/tuile/component/abstract_wrapping_field.rb +279 -0
- data/lib/tuile/component/big_decimal_field.rb +52 -79
- data/lib/tuile/component/button.rb +8 -8
- data/lib/tuile/component/checkbox.rb +9 -9
- data/lib/tuile/component/checkbox_group.rb +38 -21
- data/lib/tuile/component/combo_box.rb +102 -59
- data/lib/tuile/component/confirm_window.rb +7 -5
- data/lib/tuile/component/date_field.rb +347 -0
- data/lib/tuile/component/date_time_field.rb +275 -0
- data/lib/tuile/component/float_field.rb +57 -82
- data/lib/tuile/component/has_bad_input.rb +88 -0
- data/lib/tuile/component/has_caption.rb +8 -0
- data/lib/tuile/component/has_content.rb +32 -13
- data/lib/tuile/component/has_placeholder.rb +62 -0
- data/lib/tuile/component/has_validation.rb +115 -0
- data/lib/tuile/component/has_value.rb +28 -1
- data/lib/tuile/component/integer_field.rb +51 -78
- data/lib/tuile/component/label.rb +7 -39
- data/lib/tuile/component/layout/box.rb +90 -19
- data/lib/tuile/component/layout.rb +15 -5
- data/lib/tuile/component/list.rb +53 -38
- data/lib/tuile/component/list_dropdown.rb +7 -3
- data/lib/tuile/component/menu_bar/cascade.rb +5 -5
- data/lib/tuile/component/menu_bar.rb +18 -18
- data/lib/tuile/component/notification.rb +32 -18
- data/lib/tuile/component/overlay.rb +26 -8
- data/lib/tuile/component/picker_window.rb +27 -8
- data/lib/tuile/component/popup.rb +2 -2
- data/lib/tuile/component/progress_bar.rb +10 -4
- data/lib/tuile/component/radio_group.rb +41 -23
- data/lib/tuile/component/select.rb +23 -16
- data/lib/tuile/component/slot.rb +3 -3
- data/lib/tuile/component/tab_sheet.rb +6 -6
- data/lib/tuile/component/tabs.rb +11 -11
- data/lib/tuile/component/text_area.rb +26 -18
- data/lib/tuile/component/text_field.rb +55 -26
- data/lib/tuile/component/text_view.rb +40 -19
- data/lib/tuile/component/time_field.rb +479 -0
- data/lib/tuile/component/window.rb +26 -13
- data/lib/tuile/component.rb +635 -131
- data/lib/tuile/event_queue.rb +4 -4
- data/lib/tuile/fake_event_queue.rb +1 -1
- data/lib/tuile/fake_screen.rb +95 -3
- data/lib/tuile/final.rb +75 -0
- data/lib/tuile/locale.rb +851 -0
- data/lib/tuile/mouse/router.rb +217 -0
- data/lib/tuile/mouse.rb +177 -0
- data/lib/tuile/screen.rb +219 -68
- data/lib/tuile/screen_pane.rb +51 -42
- data/lib/tuile/styled_string.rb +5 -5
- data/lib/tuile/testing.rb +198 -0
- data/lib/tuile/theme.rb +110 -32
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile/vertical_scroll_bar.rb +88 -12
- data/lib/tuile.rb +1 -0
- data/sig/tuile.rbs +4595 -825
- metadata +14 -9
- data/COMPARISON.md +0 -101
- data/DECISIONS.md +0 -5422
- data/TERMINOLOGY.md +0 -71
- data/ideas/arrow-key-navigation.md +0 -221
- data/ideas/modal-backdrop.md +0 -24
- data/ideas/new-components.md +0 -124
- data/ideas/per-component-buffers.md +0 -55
- data/lib/tuile/mouse_event.rb +0 -68
data/book/04-event-loop.md
CHANGED
|
@@ -188,28 +188,28 @@ class Spinner < Tuile::Component::Label
|
|
|
188
188
|
|
|
189
189
|
protected
|
|
190
190
|
|
|
191
|
-
def
|
|
191
|
+
def handle_attached
|
|
192
192
|
@ticker = screen.event_queue.tick_fps(8) { |n| self.text = FRAMES[n % FRAMES.size] }
|
|
193
193
|
end
|
|
194
194
|
|
|
195
|
-
def
|
|
195
|
+
def handle_detached
|
|
196
196
|
@ticker&.cancel
|
|
197
197
|
@ticker = nil
|
|
198
198
|
end
|
|
199
199
|
end
|
|
200
200
|
```
|
|
201
201
|
|
|
202
|
-
`
|
|
203
|
-
screen; `
|
|
202
|
+
`handle_attached` fires the moment this component's tree is mounted on the
|
|
203
|
+
screen; `handle_detached` fires the moment it's unmounted. Add the spinner to a
|
|
204
204
|
popup and it starts; close the popup and it stops. Nothing at the call site
|
|
205
205
|
remembers anything — `popup.close` is the whole teardown.
|
|
206
206
|
|
|
207
|
-
The contract is a mirror: **`
|
|
207
|
+
The contract is a mirror: **`handle_attached` starts what `handle_detached` stops.**
|
|
208
208
|
Keep both cheap and idempotent, because a component *moved* from one parent
|
|
209
|
-
to another gets `
|
|
209
|
+
to another gets `handle_detached` and then `handle_attached` — between those two
|
|
210
210
|
calls it genuinely is off the screen, possibly for a long time, so stopping
|
|
211
211
|
and restarting is the honest thing to do. And whatever you acquire in
|
|
212
|
-
`
|
|
212
|
+
`handle_attached` you must release in `handle_detached`, because nothing else will.
|
|
213
213
|
|
|
214
214
|
This generalizes well beyond tickers, and the interesting case is
|
|
215
215
|
subscriptions. A component may depend on a service, but a service must never
|
|
@@ -226,11 +226,11 @@ class BuildStatus < Tuile::Component::Label
|
|
|
226
226
|
|
|
227
227
|
protected
|
|
228
228
|
|
|
229
|
-
def
|
|
229
|
+
def handle_attached
|
|
230
230
|
@subscription = @service.on_change { |s| screen.event_queue.submit { self.text = s } }
|
|
231
231
|
end
|
|
232
232
|
|
|
233
|
-
def
|
|
233
|
+
def handle_detached
|
|
234
234
|
@subscription&.unsubscribe
|
|
235
235
|
@subscription = nil
|
|
236
236
|
end
|
|
@@ -245,16 +245,16 @@ screen, and no view-closing code path has to know that the subscription
|
|
|
245
245
|
exists at all.
|
|
246
246
|
|
|
247
247
|
`screen.close` counts as unmounting, so the `screen.close` at the end of
|
|
248
|
-
your `main` gives every component still on screen its `
|
|
248
|
+
your `main` gives every component still on screen its `handle_detached` — the
|
|
249
249
|
tickers stop, the subscriptions come off, and you didn't write any of that
|
|
250
250
|
teardown. What *doesn't* fire is a process that exits without closing the
|
|
251
251
|
screen at all: these are lifecycle hooks, not destructors, and Tuile
|
|
252
|
-
installs no `at_exit`. If your `
|
|
252
|
+
installs no `at_exit`. If your `handle_detached` does something that matters
|
|
253
253
|
beyond the process — flushing a file, say — close the screen deliberately
|
|
254
254
|
rather than relying on exit.
|
|
255
255
|
|
|
256
256
|
The other thing the hooks are not is a place to do layout. When
|
|
257
|
-
`
|
|
257
|
+
`handle_attached` runs, your parent hasn't assigned your `rect` yet. If you need
|
|
258
258
|
to paint, invalidate here and do the work in `repaint`, which is what
|
|
259
259
|
chapter 2 was about anyway.
|
|
260
260
|
|
data/book/05-focus.md
CHANGED
|
@@ -43,12 +43,12 @@ they're independent on purpose.
|
|
|
43
43
|
target *at all*. It's `false` by default — a {Tuile::Component::Label} is
|
|
44
44
|
decoration; clicking one shouldn't yank focus away from the window around
|
|
45
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:
|
|
47
|
-
focus on the component under the
|
|
48
|
-
otherwise
|
|
49
|
-
tree — every component whose rectangle contains the point
|
|
50
|
-
first — so "the component under the
|
|
51
|
-
settles on the deepest focusable one. The same rule governs
|
|
46
|
+
it to `true`. This gate is what makes click-to-focus sane: a press lands
|
|
47
|
+
focus on the component under the pointer *only if it's focusable*,
|
|
48
|
+
otherwise it is ignored for focus purposes. The router resolves a path
|
|
49
|
+
down the tree — every component whose rectangle contains the point, outermost
|
|
50
|
+
first — so "the component under the pointer" is really all of them, and focus
|
|
51
|
+
settles on the deepest focusable one, before any handler runs. The same rule governs
|
|
52
52
|
the automatic focus-forwarding a container does when it's focused — a
|
|
53
53
|
window handed focus passes it down to its content, but only if that
|
|
54
54
|
content is focusable.
|
|
@@ -73,6 +73,24 @@ that scope in tree order and advances by one, wrapping around; Shift+Tab
|
|
|
73
73
|
walks backward. This is what keeps Tab from escaping an open dialog — the
|
|
74
74
|
scope is the dialog, so cycling stays inside it.
|
|
75
75
|
|
|
76
|
+
There is a third gate, and it isn't a predicate you override:
|
|
77
|
+
{Tuile::Component#visible?}. A hidden component — or any component under a
|
|
78
|
+
hidden one — is skipped by Tab, by click-to-focus, and by the forwarding a
|
|
79
|
+
container does, because it is skipped by the walk that collects candidates
|
|
80
|
+
at all. You never write a `visible?` check yourself; chapter 7 covers what
|
|
81
|
+
the flag is for.
|
|
82
|
+
|
|
83
|
+
The rule to know here is what happens when you hide the thing that
|
|
84
|
+
currently *has* focus. Focus does not stay there, and it does not become
|
|
85
|
+
nothing either: it moves to the hidden component's parent, which forwards
|
|
86
|
+
it on to the first field it can reach — exactly what happens when a
|
|
87
|
+
component is removed from the tree. Nothing is stashed, so bringing the
|
|
88
|
+
component back does not bring focus back with it. That is the same
|
|
89
|
+
behaviour a browser and every desktop toolkit settle on, and the reason
|
|
90
|
+
Tuile can't simply drop focus is worth knowing: with focus at nothing, keys
|
|
91
|
+
reach nobody at all, so an unhandled `q` would fall through to the event
|
|
92
|
+
loop and quit your app.
|
|
93
|
+
|
|
76
94
|
## The dispatch order
|
|
77
95
|
|
|
78
96
|
When a key arrives, it's offered to the tree in a fixed order, and the
|
|
@@ -93,7 +111,7 @@ sees the key. These are for app-wide actions — "Ctrl+L opens the log,"
|
|
|
93
111
|
|
|
94
112
|
```ruby
|
|
95
113
|
screen.register_global_shortcut(Tuile::Keys::CTRL_L,
|
|
96
|
-
hint: "^L #{screen.theme.hint
|
|
114
|
+
hint: "^L #{screen.theme.fg(:hint, "log")}") do
|
|
97
115
|
log_popup.open
|
|
98
116
|
end
|
|
99
117
|
```
|
|
@@ -110,15 +128,15 @@ left is yours: control keys, ESC, PgUp/PgDn, function keys. A shortcut can
|
|
|
110
128
|
opt to fire even while a modal popup is open (`over_popups: true`); by
|
|
111
129
|
default it's suppressed while a popup is up, so the popup stays modal.
|
|
112
130
|
|
|
113
|
-
**3. `handle_key
|
|
114
|
-
goes to the focused component's {Tuile::Component#handle_key}, and if that
|
|
131
|
+
**3. `handle_key?`, delivered to focus and bubbling up.** Everything else
|
|
132
|
+
goes to the focused component's {Tuile::Component#handle_key?}, and if that
|
|
115
133
|
returns `false` (didn't handle it), the key bubbles up the ancestor chain —
|
|
116
134
|
the focused component, then its parent, then *its* parent — until someone
|
|
117
135
|
returns `true` or the scope root is reached. This is how a list handles
|
|
118
136
|
arrow keys itself but lets an unhandled key rise to the window around it.
|
|
119
137
|
|
|
120
138
|
A component only ever receives a key when it's on the focus chain, so
|
|
121
|
-
`handle_key
|
|
139
|
+
`handle_key?` implementations act on the key alone — they never need to
|
|
122
140
|
check their own `active?` state. And if focus is `nil`, or sits outside
|
|
123
141
|
the current modal scope, delivery reaches no one: that's precisely what
|
|
124
142
|
makes an open modal popup modal.
|
|
@@ -148,7 +166,7 @@ right shape. **Put the key on the ancestor that owns the region.**
|
|
|
148
166
|
|
|
149
167
|
```ruby
|
|
150
168
|
class AppLayout < Tuile::Component::Layout::Absolute
|
|
151
|
-
def handle_key(key)
|
|
169
|
+
def handle_key?(key)
|
|
152
170
|
case key
|
|
153
171
|
when "1" then @files.focus; true
|
|
154
172
|
when "2" then @log.focus; true
|
|
@@ -196,7 +214,7 @@ Ask a terminal to paste eight lines and, by default, it types them at your
|
|
|
196
214
|
program: one byte at a time, with every line break converted to `\r`. That
|
|
197
215
|
`\r` is byte-identical to the Enter you press with your finger. So a prompt
|
|
198
216
|
that rebinds Enter to "submit" submits eight times, and no amount of
|
|
199
|
-
cleverness in `handle_key
|
|
217
|
+
cleverness in `handle_key?` can tell the two apart — by the time the key
|
|
200
218
|
arrives, the information is gone.
|
|
201
219
|
|
|
202
220
|
The fix has to happen one layer down, at the code that talks to the
|
|
@@ -206,9 +224,11 @@ private mode 2004), which asks the terminal to wrap pasted text in
|
|
|
206
224
|
marker, reads the payload raw up to the terminator, and posts it as a
|
|
207
225
|
single `PasteEvent` — which never enters the ladder at all:
|
|
208
226
|
|
|
209
|
-
- no Tab traversal, no global shortcuts, no `handle_key
|
|
210
|
-
- straight to {Tuile::Component#handle_paste}
|
|
211
|
-
|
|
227
|
+
- no Tab traversal, no global shortcuts, no `handle_key?`;
|
|
228
|
+
- straight to {Tuile::Component#handle_paste} on the focused component —
|
|
229
|
+
and *only* it: unlike a key, a paste does not bubble to ancestors, because
|
|
230
|
+
the reasons a key does are all about scope-wide bindings and none of them
|
|
231
|
+
wants a clipboard;
|
|
212
232
|
- the whole clipboard as one `String`, `\n`-normalized.
|
|
213
233
|
|
|
214
234
|
The default `handle_paste` returns `false` and the text is dropped.
|
|
@@ -221,7 +241,7 @@ its own:
|
|
|
221
241
|
class PromptTextArea < Tuile::Component::TextArea
|
|
222
242
|
protected
|
|
223
243
|
|
|
224
|
-
def handle_text_input_key(key)
|
|
244
|
+
def handle_text_input_key?(key)
|
|
225
245
|
return super unless key == Tuile::Keys::ENTER
|
|
226
246
|
|
|
227
247
|
submit(text) # a typed Enter, and only ever a typed Enter
|
|
@@ -253,10 +273,76 @@ disagree about whether a bracketed line break is `\r`, `\r\n` or `\n`, so
|
|
|
253
273
|
Tuile settles on `\n` before the text reaches a component.
|
|
254
274
|
|
|
255
275
|
`run_event_loop(bracketed_paste: false)` turns the mode off, the same way
|
|
256
|
-
`capture_mouse: false` turns off mouse tracking
|
|
276
|
+
`capture_mouse: false` turns off mouse tracking — the next section has that
|
|
277
|
+
knob's other settings. Then a paste is keystrokes
|
|
257
278
|
again, with the ambiguity that implies — reach for it only if a terminal
|
|
258
279
|
mishandles the mode.
|
|
259
280
|
|
|
281
|
+
## The mouse takes a different road
|
|
282
|
+
|
|
283
|
+
A key goes to whoever has focus. A press goes to whoever is *under the
|
|
284
|
+
pointer*, which is a different question with a different answer, so the
|
|
285
|
+
mouse has its own dispatcher — {Tuile::Mouse::Router} — and its own
|
|
286
|
+
handlers. Components no longer walk the tree for it at all:
|
|
287
|
+
|
|
288
|
+
```ruby
|
|
289
|
+
class Tile < Component
|
|
290
|
+
def handle_mouse_down?(event) # claims the press, and is then grabbed
|
|
291
|
+
return false unless event.button == :left
|
|
292
|
+
|
|
293
|
+
flip
|
|
294
|
+
true
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
The router resolves a path first: the topmost popup containing the point,
|
|
300
|
+
else the tiled content (a modal popup eats everything outside itself), then
|
|
301
|
+
down through the children whose rects contain it. A left press **focuses the
|
|
302
|
+
innermost focusable on that path before any handler runs** — you never write
|
|
303
|
+
click-to-focus, and you cannot forget it. Then `handle_mouse_down?` is
|
|
304
|
+
offered to the innermost component and **bubbles outward until one answers
|
|
305
|
+
`true`**, exactly as a key bubbles up the focus chain. A wheel notch travels
|
|
306
|
+
the same road through `handle_mouse_scroll?`, which is why a `List` scrolled
|
|
307
|
+
to its top answers `false` and lets the pane behind it scroll instead.
|
|
308
|
+
|
|
309
|
+
Two things follow that are easy to miss. **Geometry is the router's job, not
|
|
310
|
+
yours**: a widget that declared an `extent` (chapter 7) only sees presses
|
|
311
|
+
inside it, so a press on a `Button`'s blank tail focuses the button and fires
|
|
312
|
+
nothing — no hit test in your code. And **whoever claims a press is
|
|
313
|
+
*grabbed***: until the button comes up, `handle_mouse_up` and
|
|
314
|
+
`handle_mouse_drag` go to that component wherever the pointer travels,
|
|
315
|
+
including well outside its own rect — which is what keeps a fast drag from
|
|
316
|
+
escaping the widget that started it. There is no `grab_mouse` and no
|
|
317
|
+
`release_mouse` to call: the claim *is* the request.
|
|
318
|
+
|
|
319
|
+
Because a release can be lost — over ssh, over tmux — a widget **activates on
|
|
320
|
+
the press**, never on a synthesized click, and the grab also ends on the next
|
|
321
|
+
press or on any keystroke. Treat `handle_mouse_up` as press feedback and
|
|
322
|
+
drag-ending, never as the thing that runs the action.
|
|
323
|
+
|
|
324
|
+
How much of this the terminal ever sends is the `capture_mouse:` ladder, each
|
|
325
|
+
rung a superset of the one before:
|
|
326
|
+
|
|
327
|
+
```ruby
|
|
328
|
+
screen.run_event_loop(capture_mouse: false) # nothing; native select-to-copy
|
|
329
|
+
screen.run_event_loop # == :clicks — presses, releases, the wheel
|
|
330
|
+
screen.run_event_loop(capture_mouse: :drag) # + motion while a button is held
|
|
331
|
+
screen.run_event_loop(capture_mouse: :hover) # + motion with none, and enter/exit
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
`:hover` adds `handle_mouse_move?` and the argument-less
|
|
335
|
+
`handle_mouse_enter` / `handle_mouse_exit` pair, along with
|
|
336
|
+
{Tuile::Screen#hovered}. Ask for it only if something uses it: a terminal
|
|
337
|
+
reports up to ~84 moves a second. And keep whatever hover does *cosmetic* —
|
|
338
|
+
no terminal reports the pointer leaving the window, so an exit may arrive
|
|
339
|
+
very late, or never.
|
|
340
|
+
|
|
341
|
+
The rung is one app-wide choice, made here and nowhere else — so a component
|
|
342
|
+
that overrides a hover hook under a lower rung simply never hears from it, with
|
|
343
|
+
nothing to warn you. The sampler's *Mouse* pane answers all seven handlers on
|
|
344
|
+
one surface, and is why that app asks for `:hover`.
|
|
345
|
+
|
|
260
346
|
## Where the cursor comes in — and where it doesn't
|
|
261
347
|
|
|
262
348
|
A component signals cursor ownership through
|
|
@@ -273,6 +359,47 @@ needed. With dispatch resting on nothing but "did you return `true`," the
|
|
|
273
359
|
proxy is gone, and a component's decision to consume a key is the only
|
|
274
360
|
declaration in the system.
|
|
275
361
|
|
|
362
|
+
## A component that reacts to its own focus
|
|
363
|
+
|
|
364
|
+
Two hooks tell a component about itself, and a component overrides them —
|
|
365
|
+
nothing else calls them. {Tuile::Component#handle_focus} fires when it gains
|
|
366
|
+
focus; {Tuile::Component#handle_blur} fires when it loses it. Both fire on that
|
|
367
|
+
one component, never on the ancestors that light up and go dark with it.
|
|
368
|
+
|
|
369
|
+
`handle_focus` is the forwarding hook. It's how a {Tuile::Component::Window}
|
|
370
|
+
handed focus passes it to its content, and how a {Tuile::Component::Layout}
|
|
371
|
+
skips ahead to the first tab stop underneath it — which is also why it fires
|
|
372
|
+
on *every* assignment, even one that re-focuses what's already focused.
|
|
373
|
+
|
|
374
|
+
`handle_blur` is the commit point. Nothing else in Tuile is one: Tab is
|
|
375
|
+
unconditional, so a user leaving a half-finished field usually leaves by a
|
|
376
|
+
key no component ever sees, and `on_enter` never fires. If your field wants
|
|
377
|
+
to tidy up what was typed, this is where:
|
|
378
|
+
|
|
379
|
+
```ruby
|
|
380
|
+
class TrimmedField < Tuile::Component::TextField
|
|
381
|
+
protected def handle_blur = (self.text = text.strip)
|
|
382
|
+
end
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
Both hooks fire on one component, so a *composed* widget — a
|
|
386
|
+
{Tuile::Component::ComboBox}, whose inner field is what actually holds focus —
|
|
387
|
+
gets the blur on the child, not on itself. When the question is "has focus left
|
|
388
|
+
this whole widget", override `active=` instead and compare before and after;
|
|
389
|
+
that's exactly what the combo box does to close its dropdown when you tab away.
|
|
390
|
+
|
|
391
|
+
It is a notification, not a veto — focus has already moved by the time you
|
|
392
|
+
hear about it, and a handler that tries to hold focus is picking a fight with
|
|
393
|
+
the one key nothing can suppress. It also fires wherever focus is *dropped*,
|
|
394
|
+
not only where a user moved it: when a popup closes, the component being
|
|
395
|
+
blurred has usually been detached already, so an `invalidate` there does
|
|
396
|
+
nothing; and `screen.close` blurs the focused component on its way out. Keep
|
|
397
|
+
the handler cheap and it won't matter.
|
|
398
|
+
|
|
399
|
+
The framework sends both hooks with `__send__`, so declare them public,
|
|
400
|
+
protected or private as you like — the example above groups `handle_blur` under
|
|
401
|
+
`protected`, which is where framework-invoked plumbing belongs.
|
|
402
|
+
|
|
276
403
|
## Writing a status line
|
|
277
404
|
|
|
278
405
|
Chapter 1 pointed out that Tuile draws no status bar. This is the chapter
|
|
@@ -283,7 +410,7 @@ A status line is a `Label` in your layout. That's the whole idea:
|
|
|
283
410
|
|
|
284
411
|
```ruby
|
|
285
412
|
status = Tuile::Component::Label.new
|
|
286
|
-
status.text = "q #{screen.theme.hint
|
|
413
|
+
status.text = "q #{screen.theme.fg(:hint, "quit")} Tab #{screen.theme.fg(:hint, "Switch")}"
|
|
287
414
|
|
|
288
415
|
root = Tuile::Component::Layout::Vertical.new
|
|
289
416
|
root.add(main_ui, Tuile::Component::Layout::Expand[1])
|
|
@@ -296,10 +423,13 @@ is exactly this: Tab, Enter and Backspace work in both panes, so its row is
|
|
|
296
423
|
a constant. Reaching for a focus callback there would be machinery computing
|
|
297
424
|
a value that never changes.
|
|
298
425
|
|
|
299
|
-
Two details about the text itself.
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
426
|
+
Two details about the text itself. The `:hint` above is one of *your*
|
|
427
|
+
`custom` theme tokens, not one of Tuile's — the framework colors only the
|
|
428
|
+
chrome it paints, and this row isn't its. Chapter 6 shows the four lines
|
|
429
|
+
that define the pair; the shade to reach for is a grey dimmer than the
|
|
430
|
+
terminal's own foreground, so the *key* is what pulls the eye. `theme.fg`
|
|
431
|
+
also **bakes the color in**, so a label built from it rebuilds itself from
|
|
432
|
+
its `on_theme_changed` slot to follow a light/dark flip. And keys registered with
|
|
303
433
|
{Tuile::Screen#register_global_shortcut} don't advertise themselves: the
|
|
304
434
|
registry runs actions, it doesn't describe them, so a `^K menu` in your row
|
|
305
435
|
is text you write next to the registration.
|
data/book/06-theming.md
CHANGED
|
@@ -23,18 +23,31 @@ defaults for free.
|
|
|
23
23
|
|
|
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
|
-
active window, the resting "well" of a text field, the
|
|
27
|
-
|
|
26
|
+
active window, the resting "well" of a text field, the scrollbar down a
|
|
27
|
+
scrollable pane's edge. Those are the accents, and they are exactly the tokens
|
|
28
28
|
a {Tuile::Theme} carries — `active_bg_color`, `active_border_color`,
|
|
29
|
-
`input_bg_color`, `
|
|
29
|
+
`input_bg_color`, `scrollbar_color`, the three that mark a field
|
|
30
|
+
invalid (`error_color` and the two error wells), and `placeholder_color` for
|
|
31
|
+
the hint an empty field paints into itself. There is no global `bg` or `fg` token,
|
|
30
32
|
and that absence is intentional: adding one would mean painting over the
|
|
31
33
|
terminal's defaults everywhere, which is precisely the thing that makes a
|
|
32
34
|
TUI look wrong on someone else's color scheme. The theme touches only
|
|
33
35
|
what the framework must color to be legible, and leaves the rest to the
|
|
34
36
|
terminal.
|
|
35
37
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
+
Note what that list does *not* include: a color for secondary text. The
|
|
39
|
+
shortcut captions in a status line are a good example — they want to be
|
|
40
|
+
dimmer than the keys beside them, and Tuile has no opinion about how dim,
|
|
41
|
+
because Tuile does not paint them. A status line is yours (the framework
|
|
42
|
+
reserves no row for one), so its shades are yours too, and they go in
|
|
43
|
+
`custom` — which the next section covers. The one token that *looks* like
|
|
44
|
+
a secondary-text color is `placeholder_color`, and it isn't one: it is
|
|
45
|
+
tuned to sit just above invisible, for the specific case of a hint the
|
|
46
|
+
reader is welcome to miss, on the specific background of a field's own
|
|
47
|
+
well.
|
|
48
|
+
|
|
49
|
+
So a {Tuile::Theme} is a frozen value type — a `Data.define` of colors
|
|
50
|
+
plus an app-extensible `custom` hash — and that's all. Two are
|
|
38
51
|
built in: {Tuile::Theme::DARK}, the colors Tuile has always used, and
|
|
39
52
|
{Tuile::Theme::LIGHT}, counterparts legible on a pale background.
|
|
40
53
|
|
|
@@ -66,15 +79,53 @@ terminal default is just the root of that chain, which is why an unset
|
|
|
66
79
|
|
|
67
80
|
A widget with a background of its *own* keeps it. A text field paints its
|
|
68
81
|
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
|
|
70
|
-
|
|
71
|
-
|
|
82
|
+
field in its own well, not the panel tint — the terminal equivalent of a CSS
|
|
83
|
+
element that sets its own `background`. That is a *default*, though, not a
|
|
84
|
+
refusal: set `bg_color` on the field itself and it wins, because the chain
|
|
85
|
+
asks three questions in order — what did the app set on this component, what
|
|
86
|
+
background does this widget claim of its own, and what surrounds it.
|
|
87
|
+
|
|
88
|
+
Which is also how you get a field that reads as plain text inside a tinted
|
|
89
|
+
prompt. You *can* name the panel's colour again on the field — but there is a
|
|
90
|
+
shorter way to say "I have no background of my own, use whatever is behind me":
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
field.bg_color = Component::BG_INHERIT
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
That is CSS's `background: inherit`, and it is different from leaving
|
|
97
|
+
`bg_color` unset: unset means "ask *my* default first", which for a field is
|
|
98
|
+
its well. `BG_INHERIT` skips the well and goes straight to what surrounds it.
|
|
99
|
+
|
|
100
|
+
It is the same mechanism Tuile uses internally. A
|
|
101
|
+
{Tuile::Component::ComboBox} is one widget with one surface, built out of a
|
|
102
|
+
{Tuile::Component::TextField} plus a `▾` — so the ComboBox paints the well and
|
|
103
|
+
marks its inner field `BG_INHERIT`. Exactly one well per widget, which is what
|
|
104
|
+
lets you tint the ComboBox and have the tint reach the cells the field draws.
|
|
105
|
+
|
|
106
|
+
Backgrounds can differ by state. An input is brighter while it holds focus,
|
|
107
|
+
and a flat `bg_color` replaces *both* shades — fine for a field, which shows a
|
|
108
|
+
caret when focused, and a deliberate choice for something like a
|
|
109
|
+
{Tuile::Component::Select}, which has no caret and nothing else to indicate
|
|
110
|
+
focus with. Name the states when you want to keep the distinction:
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
field.bg_color = grey # flat: focused or not
|
|
114
|
+
field.bg_color = { normal: grey, active: blue } # your own pair
|
|
115
|
+
field.bg_color = { active: blue } # keep the widget's own well,
|
|
116
|
+
# override only the focus shade
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The keys are a small closed set (`:normal`, `:active`) that Tuile defines —
|
|
120
|
+
a state with no key simply isn't answered there, and the question falls
|
|
121
|
+
through to the next level of the chain, which is what makes the third line
|
|
122
|
+
above mean what it reads as.
|
|
72
123
|
|
|
73
124
|
`bg_color` takes either a concrete {Tuile::Color} or a *live theme
|
|
74
125
|
reference* — `Theme.ref(:panel_bg)` — that names one of your app's custom
|
|
75
126
|
tokens and re-resolves it against the current theme on every paint. The
|
|
76
127
|
reference is the ergonomic path: assign it once and the panel follows
|
|
77
|
-
light and dark on its own, with no `
|
|
128
|
+
light and dark on its own, with no `handle_theme_changed` handler. That works
|
|
78
129
|
precisely because a background — unlike the baked-in colors of your
|
|
79
130
|
*content* (below) — is resolved *live* at paint, exactly like the
|
|
80
131
|
framework's own accents; `bg_color` is a single value read late, so
|
|
@@ -115,14 +166,15 @@ colors. Done.
|
|
|
115
166
|
When a component has a themed color in hand, it applies it in one of two
|
|
116
167
|
ways, and which one depends on the text.
|
|
117
168
|
|
|
118
|
-
For plain chrome — a border string, a
|
|
169
|
+
For plain chrome — a border string, a button caption — the theme's
|
|
119
170
|
**rendering helpers** wrap the text in the token's SGR color and a reset:
|
|
120
|
-
`theme.active_bg("[ Ok ]")`, `theme.
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
171
|
+
`theme.active_bg("[ Ok ]")`, `theme.active_border("┌──┐")`, and
|
|
172
|
+
`theme.fg(:hint, "quit")` for one of your own `custom` tokens. The helper
|
|
173
|
+
picks the right channel for the token's role (a `*_bg` token wraps as a
|
|
174
|
+
background, a border as a foreground) and passes the content through
|
|
175
|
+
verbatim, so the string may already contain other escape sequences — which
|
|
176
|
+
is how {Tuile::Component::Window} feeds its whole border row, cursor moves
|
|
177
|
+
and all, through `active_border`.
|
|
126
178
|
|
|
127
179
|
But chrome text is flat. Content is not. A list row or a label may be a
|
|
128
180
|
{Tuile::StyledString} with its own per-span colors, and wrapping that in
|
|
@@ -205,7 +257,7 @@ more round trip than you might expect. The mode-2031 report says only
|
|
|
205
257
|
it writes the OSC 11 query again, and the reply comes back through the
|
|
206
258
|
key thread as another event. The new color therefore lands a frame after
|
|
207
259
|
the new theme. When it does, Tuile fires
|
|
208
|
-
{Tuile::Component}`#
|
|
260
|
+
{Tuile::Component}`#handle_theme_changed` across the tree exactly as a theme
|
|
209
261
|
swap does, on the reasoning that a tint derived from the background *is*
|
|
210
262
|
a theme-derived color, and that hook is already where you rebuild those.
|
|
211
263
|
So the same override handles both halves of a flip, and you don't need to
|
|
@@ -305,7 +357,21 @@ one with `theme[:accent]`, which **fail-fasts**: a typo'd token raises
|
|
|
305
357
|
`KeyError` rather than silently painting a default, so a missing color is
|
|
306
358
|
a loud bug and not a mystery. And you render with the generic `fg` / `bg`
|
|
307
359
|
helpers — `theme.fg(:accent, "NEW")` — the custom-token counterparts of
|
|
308
|
-
the built-in `
|
|
360
|
+
the built-in `active_border` / `active_bg` helpers.
|
|
361
|
+
|
|
362
|
+
This is where a status line's shades belong, and it is worth doing even
|
|
363
|
+
for a single token. The examples that ship with Tuile all carry one
|
|
364
|
+
`hint` grey for the descriptive half of their `"q quit"` rows, paired
|
|
365
|
+
dark and light so the row follows an appearance flip:
|
|
366
|
+
|
|
367
|
+
```ruby
|
|
368
|
+
APP_THEME = Tuile::ThemeDef.new(
|
|
369
|
+
dark: Tuile::Theme::DARK.with(custom: { hint: Tuile::Color::GREY54 }),
|
|
370
|
+
light: Tuile::Theme::LIGHT.with(custom: { hint: Tuile::Color::GREY62 })
|
|
371
|
+
)
|
|
372
|
+
screen.theme_def = APP_THEME
|
|
373
|
+
status.text = "q #{screen.theme.fg(:hint, "quit")}"
|
|
374
|
+
```
|
|
309
375
|
|
|
310
376
|
For an app with more than a couple of custom tokens, the tidier move is
|
|
311
377
|
to **subclass** {Tuile::Theme} and give each token a named coloring
|
|
@@ -374,21 +440,35 @@ because only *you* know which of the string's colors came from the theme
|
|
|
374
440
|
versus which are inherent to the data (a log line's level color, say,
|
|
375
441
|
should *not* follow the theme).
|
|
376
442
|
|
|
377
|
-
The hook for this is {Tuile::Component#
|
|
443
|
+
The hook for this is {Tuile::Component#handle_theme_changed}, fired on every
|
|
378
444
|
attached component whenever the theme changes. Your handler does exactly
|
|
379
445
|
one thing: **re-run the code that rendered the content**, so it rebuilds
|
|
380
446
|
the StyledString against the now-current theme.
|
|
381
447
|
|
|
448
|
+
There are two ways to consume it, matching how you built the component,
|
|
449
|
+
and they are two different method names — the `=` tells them apart. If you
|
|
450
|
+
assembled stock components, assign the `on_theme_changed=` **listener slot**:
|
|
451
|
+
|
|
382
452
|
```ruby
|
|
383
453
|
label.on_theme_changed = -> { label.text = render_status_line }
|
|
384
454
|
```
|
|
385
455
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
456
|
+
If you subclassed, override `handle_theme_changed`, the **override point**
|
|
457
|
+
— and call `super`, so an assigned listener still fires:
|
|
458
|
+
|
|
459
|
+
```ruby
|
|
460
|
+
class StatusLabel < Tuile::Component::Label
|
|
461
|
+
protected def handle_theme_changed
|
|
462
|
+
super
|
|
463
|
+
self.text = render_status_line
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
That pair is the house rule across the whole widget set, not a special
|
|
469
|
+
case for theming. Either way the rule here is the same — the hook is where
|
|
470
|
+
theme-derived content gets rebuilt, and the framework handles everything
|
|
471
|
+
else.
|
|
392
472
|
|
|
393
473
|
---
|
|
394
474
|
|