tuile 0.15.0 → 0.17.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 +229 -80
- data/README.md +49 -24
- data/book/02-repaint.md +47 -19
- data/book/03-layout.md +98 -49
- data/book/04-event-loop.md +17 -16
- data/book/05-focus.md +106 -34
- data/book/06-theming.md +108 -38
- data/book/07-components.md +249 -46
- data/book/08-testing.md +134 -32
- data/book/10-locale.md +3 -3
- data/book/README.md +11 -10
- data/examples/file_commander.rb +52 -32
- data/examples/hello_world.rb +18 -5
- data/examples/sampler.rb +576 -146
- data/lib/tuile/buffer.rb +12 -1
- data/lib/tuile/canvas/backend.rb +46 -0
- data/lib/tuile/canvas.rb +212 -0
- data/lib/tuile/color.rb +38 -9
- data/lib/tuile/component/abstract_string_field.rb +96 -97
- data/lib/tuile/component/abstract_wrapping_field.rb +99 -58
- data/lib/tuile/component/big_decimal_field.rb +7 -6
- data/lib/tuile/component/button.rb +27 -19
- data/lib/tuile/component/checkbox.rb +21 -19
- data/lib/tuile/component/checkbox_group.rb +17 -18
- data/lib/tuile/component/combo_box.rb +69 -64
- data/lib/tuile/component/confirm_window.rb +34 -27
- data/lib/tuile/component/date_field.rb +50 -18
- data/lib/tuile/component/date_time_field.rb +319 -0
- data/lib/tuile/component/fill.rb +93 -0
- data/lib/tuile/component/float_field.rb +7 -6
- data/lib/tuile/component/form_item.rb +250 -0
- data/lib/tuile/component/form_layout.rb +206 -0
- data/lib/tuile/component/has_bad_input.rb +99 -28
- data/lib/tuile/component/has_caption.rb +14 -5
- data/lib/tuile/component/has_content.rb +8 -15
- data/lib/tuile/component/has_placeholder.rb +1 -1
- data/lib/tuile/component/has_validation.rb +40 -14
- data/lib/tuile/component/has_value.rb +71 -17
- data/lib/tuile/component/integer_field.rb +7 -6
- data/lib/tuile/component/label.rb +8 -15
- data/lib/tuile/component/layout/absolute.rb +86 -0
- data/lib/tuile/component/layout/box.rb +38 -60
- data/lib/tuile/component/layout.rb +127 -13
- data/lib/tuile/component/list.rb +233 -120
- data/lib/tuile/component/list_dropdown.rb +151 -91
- data/lib/tuile/component/menu_bar/cascade.rb +102 -32
- data/lib/tuile/component/menu_bar.rb +102 -82
- data/lib/tuile/component/notification.rb +76 -49
- data/lib/tuile/component/overlay.rb +217 -58
- data/lib/tuile/component/password_field.rb +1 -8
- data/lib/tuile/component/picker_window.rb +41 -17
- data/lib/tuile/component/popup.rb +15 -26
- data/lib/tuile/component/progress_bar.rb +17 -11
- data/lib/tuile/component/radio_group.rb +16 -17
- data/lib/tuile/component/scroller.rb +266 -0
- data/lib/tuile/component/select.rb +26 -43
- data/lib/tuile/component/slot.rb +4 -5
- data/lib/tuile/component/tab_sheet.rb +27 -34
- data/lib/tuile/component/tabs.rb +49 -34
- data/lib/tuile/component/text_area/wrapped_text.rb +1 -1
- data/lib/tuile/component/text_area.rb +32 -28
- data/lib/tuile/component/text_field.rb +68 -50
- data/lib/tuile/component/text_view.rb +157 -89
- data/lib/tuile/component/time_field.rb +51 -21
- data/lib/tuile/component/vertical_scroll_bar.rb +257 -0
- data/lib/tuile/component/window.rb +27 -26
- data/lib/tuile/component.rb +653 -323
- data/lib/tuile/component_background.rb +177 -0
- data/lib/tuile/component_util.rb +43 -0
- data/lib/tuile/event.rb +29 -0
- data/lib/tuile/event_queue.rb +18 -4
- data/lib/tuile/fake_event_queue.rb +1 -1
- data/lib/tuile/fake_screen.rb +120 -7
- data/lib/tuile/keys.rb +15 -6
- data/lib/tuile/layout_pass.rb +180 -0
- data/lib/tuile/listeners.rb +219 -0
- data/lib/tuile/mouse/router.rb +233 -0
- data/lib/tuile/mouse.rb +244 -0
- data/lib/tuile/point.rb +6 -0
- data/lib/tuile/rect.rb +33 -0
- data/lib/tuile/screen.rb +510 -138
- data/lib/tuile/screen_pane.rb +185 -67
- data/lib/tuile/strict_layout.rb +127 -0
- data/lib/tuile/styled_string.rb +144 -14
- data/lib/tuile/testing/gestures.rb +35 -0
- data/lib/tuile/testing.rb +316 -42
- data/lib/tuile/theme.rb +192 -53
- data/lib/tuile/theme_def.rb +4 -0
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +53 -0
- data/sig/tuile.rbs +6084 -1507
- metadata +19 -17
- data/COMPARISON.md +0 -101
- data/DECISIONS.md +0 -8562
- data/TERMINOLOGY.md +0 -85
- data/ideas/arrow-key-navigation.md +0 -221
- data/ideas/binder.md +0 -177
- data/ideas/composite-field.md +0 -77
- data/ideas/focus-accent.md +0 -116
- data/ideas/form-layout.md +0 -151
- data/ideas/hover/probe.rb +0 -241
- data/ideas/hover/probe_spec.rb +0 -82
- data/ideas/hover.md +0 -909
- data/ideas/modal-backdrop.md +0 -24
- data/ideas/new-components.md +0 -144
- data/ideas/per-component-buffers.md +0 -55
- data/lib/tuile/mouse_event.rb +0 -68
- data/lib/tuile/vertical_scroll_bar.rb +0 -122
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.
|
|
@@ -111,7 +111,7 @@ sees the key. These are for app-wide actions — "Ctrl+L opens the log,"
|
|
|
111
111
|
|
|
112
112
|
```ruby
|
|
113
113
|
screen.register_global_shortcut(Tuile::Keys::CTRL_L,
|
|
114
|
-
hint: "^L #{screen.theme.hint
|
|
114
|
+
hint: "^L #{screen.theme.fg(:hint, "log")}") do
|
|
115
115
|
log_popup.open
|
|
116
116
|
end
|
|
117
117
|
```
|
|
@@ -128,15 +128,15 @@ left is yours: control keys, ESC, PgUp/PgDn, function keys. A shortcut can
|
|
|
128
128
|
opt to fire even while a modal popup is open (`over_popups: true`); by
|
|
129
129
|
default it's suppressed while a popup is up, so the popup stays modal.
|
|
130
130
|
|
|
131
|
-
**3. `handle_key
|
|
132
|
-
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
|
|
133
133
|
returns `false` (didn't handle it), the key bubbles up the ancestor chain —
|
|
134
134
|
the focused component, then its parent, then *its* parent — until someone
|
|
135
135
|
returns `true` or the scope root is reached. This is how a list handles
|
|
136
136
|
arrow keys itself but lets an unhandled key rise to the window around it.
|
|
137
137
|
|
|
138
138
|
A component only ever receives a key when it's on the focus chain, so
|
|
139
|
-
`handle_key
|
|
139
|
+
`handle_key?` implementations act on the key alone — they never need to
|
|
140
140
|
check their own `active?` state. And if focus is `nil`, or sits outside
|
|
141
141
|
the current modal scope, delivery reaches no one: that's precisely what
|
|
142
142
|
makes an open modal popup modal.
|
|
@@ -165,8 +165,8 @@ the tree — and neither needs machinery, because bubbling already has the
|
|
|
165
165
|
right shape. **Put the key on the ancestor that owns the region.**
|
|
166
166
|
|
|
167
167
|
```ruby
|
|
168
|
-
class AppLayout < Tuile::Component::Layout
|
|
169
|
-
def handle_key(key)
|
|
168
|
+
class AppLayout < Tuile::Component::Layout
|
|
169
|
+
def handle_key?(key)
|
|
170
170
|
case key
|
|
171
171
|
when "1" then @files.focus; true
|
|
172
172
|
when "2" then @log.focus; true
|
|
@@ -214,7 +214,7 @@ Ask a terminal to paste eight lines and, by default, it types them at your
|
|
|
214
214
|
program: one byte at a time, with every line break converted to `\r`. That
|
|
215
215
|
`\r` is byte-identical to the Enter you press with your finger. So a prompt
|
|
216
216
|
that rebinds Enter to "submit" submits eight times, and no amount of
|
|
217
|
-
cleverness in `handle_key
|
|
217
|
+
cleverness in `handle_key?` can tell the two apart — by the time the key
|
|
218
218
|
arrives, the information is gone.
|
|
219
219
|
|
|
220
220
|
The fix has to happen one layer down, at the code that talks to the
|
|
@@ -224,7 +224,7 @@ private mode 2004), which asks the terminal to wrap pasted text in
|
|
|
224
224
|
marker, reads the payload raw up to the terminator, and posts it as a
|
|
225
225
|
single `PasteEvent` — which never enters the ladder at all:
|
|
226
226
|
|
|
227
|
-
- no Tab traversal, no global shortcuts, no `handle_key
|
|
227
|
+
- no Tab traversal, no global shortcuts, no `handle_key?`;
|
|
228
228
|
- straight to {Tuile::Component#handle_paste} on the focused component —
|
|
229
229
|
and *only* it: unlike a key, a paste does not bubble to ancestors, because
|
|
230
230
|
the reasons a key does are all about scope-wide bindings and none of them
|
|
@@ -233,7 +233,7 @@ single `PasteEvent` — which never enters the ladder at all:
|
|
|
233
233
|
|
|
234
234
|
The default `handle_paste` returns `false` and the text is dropped.
|
|
235
235
|
{Tuile::Component::AbstractStringField} overrides it to insert at the caret
|
|
236
|
-
as **one** mutation — so `
|
|
236
|
+
as **one** mutation — so `on_value_change` fires once for the paste rather than
|
|
237
237
|
once per character, and a subclass that claims Enter needs no paste code of
|
|
238
238
|
its own:
|
|
239
239
|
|
|
@@ -241,11 +241,11 @@ its own:
|
|
|
241
241
|
class PromptTextArea < Tuile::Component::TextArea
|
|
242
242
|
protected
|
|
243
243
|
|
|
244
|
-
def handle_text_input_key(key)
|
|
244
|
+
def handle_text_input_key?(key)
|
|
245
245
|
return super unless key == Tuile::Keys::ENTER
|
|
246
246
|
|
|
247
247
|
submit(text) # a typed Enter, and only ever a typed Enter
|
|
248
|
-
self.
|
|
248
|
+
self.value = ""
|
|
249
249
|
true
|
|
250
250
|
end
|
|
251
251
|
end
|
|
@@ -260,7 +260,7 @@ def handle_paste(text)
|
|
|
260
260
|
return super if text.lines.size < 20
|
|
261
261
|
|
|
262
262
|
attach_as_file(text)
|
|
263
|
-
self.
|
|
263
|
+
self.value = "#{text.lines.size} lines attached"
|
|
264
264
|
true
|
|
265
265
|
end
|
|
266
266
|
```
|
|
@@ -273,15 +273,84 @@ disagree about whether a bracketed line break is `\r`, `\r\n` or `\n`, so
|
|
|
273
273
|
Tuile settles on `\n` before the text reaches a component.
|
|
274
274
|
|
|
275
275
|
`run_event_loop(bracketed_paste: false)` turns the mode off, the same way
|
|
276
|
-
`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
|
|
277
278
|
again, with the ambiguity that implies — reach for it only if a terminal
|
|
278
279
|
mishandles the mode.
|
|
279
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
|
+
|
|
280
346
|
## Where the cursor comes in — and where it doesn't
|
|
281
347
|
|
|
282
348
|
A component signals cursor ownership through
|
|
283
|
-
{Tuile::Component#cursor_position} — return a `Point`
|
|
284
|
-
|
|
349
|
+
{Tuile::Component#cursor_position} — return a `Point` in your *own*
|
|
350
|
+
coordinates (the ones you paint in) and the terminal cursor is shown there;
|
|
351
|
+
return `nil` (the default) and there's no cursor. {Tuile::Screen} is what
|
|
352
|
+
converts the point to a screen position, so a caret is a column and a row and
|
|
353
|
+
nothing more. A
|
|
285
354
|
{Tuile::Component::TextField} being edited returns its caret position, so
|
|
286
355
|
the caret you see blinking is the focused component's answer to that one
|
|
287
356
|
question.
|
|
@@ -296,23 +365,23 @@ declaration in the system.
|
|
|
296
365
|
## A component that reacts to its own focus
|
|
297
366
|
|
|
298
367
|
Two hooks tell a component about itself, and a component overrides them —
|
|
299
|
-
nothing else calls them. {Tuile::Component#
|
|
300
|
-
focus; {Tuile::Component#
|
|
368
|
+
nothing else calls them. {Tuile::Component#handle_focus} fires when it gains
|
|
369
|
+
focus; {Tuile::Component#handle_blur} fires when it loses it. Both fire on that
|
|
301
370
|
one component, never on the ancestors that light up and go dark with it.
|
|
302
371
|
|
|
303
|
-
`
|
|
372
|
+
`handle_focus` is the forwarding hook. It's how a {Tuile::Component::Window}
|
|
304
373
|
handed focus passes it to its content, and how a {Tuile::Component::Layout}
|
|
305
374
|
skips ahead to the first tab stop underneath it — which is also why it fires
|
|
306
375
|
on *every* assignment, even one that re-focuses what's already focused.
|
|
307
376
|
|
|
308
|
-
`
|
|
377
|
+
`handle_blur` is the commit point. Nothing else in Tuile is one: Tab is
|
|
309
378
|
unconditional, so a user leaving a half-finished field usually leaves by a
|
|
310
379
|
key no component ever sees, and `on_enter` never fires. If your field wants
|
|
311
380
|
to tidy up what was typed, this is where:
|
|
312
381
|
|
|
313
382
|
```ruby
|
|
314
383
|
class TrimmedField < Tuile::Component::TextField
|
|
315
|
-
protected def
|
|
384
|
+
protected def handle_blur = (self.value = text.strip)
|
|
316
385
|
end
|
|
317
386
|
```
|
|
318
387
|
|
|
@@ -331,7 +400,7 @@ nothing; and `screen.close` blurs the focused component on its way out. Keep
|
|
|
331
400
|
the handler cheap and it won't matter.
|
|
332
401
|
|
|
333
402
|
The framework sends both hooks with `__send__`, so declare them public,
|
|
334
|
-
protected or private as you like — the example above groups `
|
|
403
|
+
protected or private as you like — the example above groups `handle_blur` under
|
|
335
404
|
`protected`, which is where framework-invoked plumbing belongs.
|
|
336
405
|
|
|
337
406
|
## Writing a status line
|
|
@@ -344,7 +413,7 @@ A status line is a `Label` in your layout. That's the whole idea:
|
|
|
344
413
|
|
|
345
414
|
```ruby
|
|
346
415
|
status = Tuile::Component::Label.new
|
|
347
|
-
status.text = "q #{screen.theme.hint
|
|
416
|
+
status.text = "q #{screen.theme.fg(:hint, "quit")} Tab #{screen.theme.fg(:hint, "Switch")}"
|
|
348
417
|
|
|
349
418
|
root = Tuile::Component::Layout::Vertical.new
|
|
350
419
|
root.add(main_ui, Tuile::Component::Layout::Expand[1])
|
|
@@ -357,10 +426,13 @@ is exactly this: Tab, Enter and Backspace work in both panes, so its row is
|
|
|
357
426
|
a constant. Reaching for a focus callback there would be machinery computing
|
|
358
427
|
a value that never changes.
|
|
359
428
|
|
|
360
|
-
Two details about the text itself.
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
429
|
+
Two details about the text itself. The `:hint` above is one of *your*
|
|
430
|
+
`custom` theme tokens, not one of Tuile's — the framework colors only the
|
|
431
|
+
chrome it paints, and this row isn't its. Chapter 6 shows the four lines
|
|
432
|
+
that define the pair; the shade to reach for is a grey dimmer than the
|
|
433
|
+
terminal's own foreground, so the *key* is what pulls the eye. `theme.fg`
|
|
434
|
+
also **bakes the color in**, so a label built from it rebuilds itself from
|
|
435
|
+
its `on_theme_changed` slot to follow a light/dark flip. And keys registered with
|
|
364
436
|
{Tuile::Screen#register_global_shortcut} don't advertise themselves: the
|
|
365
437
|
registry runs actions, it doesn't describe them, so a `^K menu` in your row
|
|
366
438
|
is text you write next to the registration.
|
|
@@ -369,10 +441,10 @@ is text you write next to the registration.
|
|
|
369
441
|
|
|
370
442
|
Some apps genuinely show different keys in different places — a window with
|
|
371
443
|
a search mode, or a pane whose commands only apply to it. For those,
|
|
372
|
-
{Tuile::Screen#on_focus_changed
|
|
444
|
+
{Tuile::Screen#on_focus_changed} is the notification:
|
|
373
445
|
|
|
374
446
|
```ruby
|
|
375
|
-
screen.on_focus_changed
|
|
447
|
+
screen.on_focus_changed { status.text = hint_for(screen.focused) }
|
|
376
448
|
```
|
|
377
449
|
|
|
378
450
|
It fires after every focus *change* — to and from `nil` included, and after
|
data/book/06-theming.md
CHANGED
|
@@ -24,10 +24,9 @@ defaults for free.
|
|
|
24
24
|
What Tuile *does* color is the small set of cues that signal
|
|
25
25
|
interaction: the highlight behind the focused list row, the border of the
|
|
26
26
|
active window, the resting "well" of a text field, the scrollbar down a
|
|
27
|
-
scrollable pane's edge, the
|
|
28
|
-
in a status line you write. Those are the accents, and they are exactly the tokens
|
|
27
|
+
scrollable pane's edge. Those are the accents, and they are exactly the tokens
|
|
29
28
|
a {Tuile::Theme} carries — `active_bg_color`, `active_border_color`,
|
|
30
|
-
`input_bg_color`, `scrollbar_color`,
|
|
29
|
+
`input_bg_color`, `scrollbar_color`, the three that mark a field
|
|
31
30
|
invalid (`error_color` and the two error wells), and `placeholder_color` for
|
|
32
31
|
the hint an empty field paints into itself. There is no global `bg` or `fg` token,
|
|
33
32
|
and that absence is intentional: adding one would mean painting over the
|
|
@@ -36,12 +35,16 @@ TUI look wrong on someone else's color scheme. The theme touches only
|
|
|
36
35
|
what the framework must color to be legible, and leaves the rest to the
|
|
37
36
|
terminal.
|
|
38
37
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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.
|
|
45
48
|
|
|
46
49
|
So a {Tuile::Theme} is a frozen value type — a `Data.define` of colors
|
|
47
50
|
plus an app-extensible `custom` hash — and that's all. Two are
|
|
@@ -87,17 +90,17 @@ prompt. You *can* name the panel's colour again on the field — but there is a
|
|
|
87
90
|
shorter way to say "I have no background of my own, use whatever is behind me":
|
|
88
91
|
|
|
89
92
|
```ruby
|
|
90
|
-
field.bg_color =
|
|
93
|
+
field.bg_color = ComponentBackground::INHERIT
|
|
91
94
|
```
|
|
92
95
|
|
|
93
96
|
That is CSS's `background: inherit`, and it is different from leaving
|
|
94
97
|
`bg_color` unset: unset means "ask *my* default first", which for a field is
|
|
95
|
-
its well. `
|
|
98
|
+
its well. `ComponentBackground::INHERIT` skips the well and goes straight to what surrounds it.
|
|
96
99
|
|
|
97
100
|
It is the same mechanism Tuile uses internally. A
|
|
98
101
|
{Tuile::Component::ComboBox} is one widget with one surface, built out of a
|
|
99
102
|
{Tuile::Component::TextField} plus a `▾` — so the ComboBox paints the well and
|
|
100
|
-
marks its inner field `
|
|
103
|
+
marks its inner field `ComponentBackground::INHERIT`. Exactly one well per widget, which is what
|
|
101
104
|
lets you tint the ComboBox and have the tint reach the cells the field draws.
|
|
102
105
|
|
|
103
106
|
Backgrounds can differ by state. An input is brighter while it holds focus,
|
|
@@ -122,7 +125,7 @@ above mean what it reads as.
|
|
|
122
125
|
reference* — `Theme.ref(:panel_bg)` — that names one of your app's custom
|
|
123
126
|
tokens and re-resolves it against the current theme on every paint. The
|
|
124
127
|
reference is the ergonomic path: assign it once and the panel follows
|
|
125
|
-
light and dark on its own, with no `
|
|
128
|
+
light and dark on its own, with no `handle_theme_changed` handler. That works
|
|
126
129
|
precisely because a background — unlike the baked-in colors of your
|
|
127
130
|
*content* (below) — is resolved *live* at paint, exactly like the
|
|
128
131
|
framework's own accents; `bg_color` is a single value read late, so
|
|
@@ -163,14 +166,15 @@ colors. Done.
|
|
|
163
166
|
When a component has a themed color in hand, it applies it in one of two
|
|
164
167
|
ways, and which one depends on the text.
|
|
165
168
|
|
|
166
|
-
For plain chrome — a border string, a
|
|
169
|
+
For plain chrome — a border string, a button caption — the theme's
|
|
167
170
|
**rendering helpers** wrap the text in the token's SGR color and a reset:
|
|
168
|
-
`theme.active_bg("[ Ok ]")`, `theme.
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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`.
|
|
174
178
|
|
|
175
179
|
But chrome text is flat. Content is not. A list row or a label may be a
|
|
176
180
|
{Tuile::StyledString} with its own per-span colors, and wrapping that in
|
|
@@ -234,12 +238,30 @@ background, and Tuile has it: the OSC 11 reply carries the RGB, and
|
|
|
234
238
|
{Tuile::Screen}`#background_color` hands it to you as a
|
|
235
239
|
{Tuile::Color}.
|
|
236
240
|
|
|
241
|
+
The place to use it is the theme itself. A token may be a Proc of the
|
|
242
|
+
background instead of a fixed {Tuile::Color}, and the screen calls it for
|
|
243
|
+
you:
|
|
244
|
+
|
|
237
245
|
```ruby
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
246
|
+
LIFT = ->(color, by) { Tuile::Color.rgb(*color.rgb.map { (_1 + by).clamp(0, 255) }) }
|
|
247
|
+
|
|
248
|
+
APP_THEME = Tuile::ThemeDef.new(
|
|
249
|
+
dark: Tuile::Theme::DARK.with(custom: {
|
|
250
|
+
pane_bg: ->(bg) { bg ? LIFT.call(bg, 10) : FALLBACK_TINT },
|
|
251
|
+
pane_frame: ->(_bg, t) { LIFT.call(t[:pane_bg], 20) }
|
|
252
|
+
}),
|
|
253
|
+
light: …
|
|
254
|
+
)
|
|
255
|
+
screen.theme_def = APP_THEME
|
|
256
|
+
sidebar.bg_color = Tuile::Theme.ref(:pane_bg)
|
|
241
257
|
```
|
|
242
258
|
|
|
259
|
+
The arithmetic is yours — Tuile ships no `lighten`, because how far to
|
|
260
|
+
step, in which direction, and whether to step at all is a design choice,
|
|
261
|
+
not a fact about the terminal. The second parameter, when a Proc asks for
|
|
262
|
+
it, reads the other tokens, so a hairline can be derived from the pane it
|
|
263
|
+
sits on, whatever order you declared them in.
|
|
264
|
+
|
|
243
265
|
That `FALLBACK_TINT` is not defensive padding — it's the branch you
|
|
244
266
|
should expect to hit. Plenty of terminals answer neither probe, and the
|
|
245
267
|
`COLORFGBG` fallback reports a palette *index* with no RGB behind it, so
|
|
@@ -252,12 +274,21 @@ more round trip than you might expect. The mode-2031 report says only
|
|
|
252
274
|
"the OS is light now" — it carries no RGB — so when the screen sees one,
|
|
253
275
|
it writes the OSC 11 query again, and the reply comes back through the
|
|
254
276
|
key thread as another event. The new color therefore lands a frame after
|
|
255
|
-
the new theme. When it does,
|
|
256
|
-
{Tuile::
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
know which
|
|
277
|
+
the new theme. When it does, the screen calls every derived token again,
|
|
278
|
+
producing a fresh, fully concrete {Tuile::Screen}`#theme`, and fires
|
|
279
|
+
{Tuile::Component}`#handle_theme_changed` across the tree once, exactly as a
|
|
280
|
+
theme swap does. A `Theme.ref` slot follows with no code of yours; content
|
|
281
|
+
you baked from the theme rebuilds in the same hook it always did, and you
|
|
282
|
+
don't need to know which half of the flip woke you.
|
|
283
|
+
|
|
284
|
+
Why a Proc in the theme, rather than a color that knows how to recompute
|
|
285
|
+
itself? Because {Tuile::Color} is a value: the back buffer decides whether a
|
|
286
|
+
cell changed by comparing colors, and a color that answered differently
|
|
287
|
+
depending on the terminal would compare equal to itself while painting
|
|
288
|
+
something new. Deriving once, when the inputs change, keeps every color
|
|
289
|
+
Tuile paints with a plain value — and the tree walks once per change,
|
|
290
|
+
instead of an app re-assigning its theme from inside the very hook the
|
|
291
|
+
walk is calling.
|
|
261
292
|
|
|
262
293
|
## Not every terminal can show what you computed
|
|
263
294
|
|
|
@@ -353,7 +384,21 @@ one with `theme[:accent]`, which **fail-fasts**: a typo'd token raises
|
|
|
353
384
|
`KeyError` rather than silently painting a default, so a missing color is
|
|
354
385
|
a loud bug and not a mystery. And you render with the generic `fg` / `bg`
|
|
355
386
|
helpers — `theme.fg(:accent, "NEW")` — the custom-token counterparts of
|
|
356
|
-
the built-in `
|
|
387
|
+
the built-in `active_border` / `active_bg` helpers.
|
|
388
|
+
|
|
389
|
+
This is where a status line's shades belong, and it is worth doing even
|
|
390
|
+
for a single token. The examples that ship with Tuile all carry one
|
|
391
|
+
`hint` grey for the descriptive half of their `"q quit"` rows, paired
|
|
392
|
+
dark and light so the row follows an appearance flip:
|
|
393
|
+
|
|
394
|
+
```ruby
|
|
395
|
+
APP_THEME = Tuile::ThemeDef.new(
|
|
396
|
+
dark: Tuile::Theme::DARK.with(custom: { hint: Tuile::Color::GREY54 }),
|
|
397
|
+
light: Tuile::Theme::LIGHT.with(custom: { hint: Tuile::Color::GREY62 })
|
|
398
|
+
)
|
|
399
|
+
screen.theme_def = APP_THEME
|
|
400
|
+
status.text = "q #{screen.theme.fg(:hint, "quit")}"
|
|
401
|
+
```
|
|
357
402
|
|
|
358
403
|
For an app with more than a couple of custom tokens, the tidier move is
|
|
359
404
|
to **subclass** {Tuile::Theme} and give each token a named coloring
|
|
@@ -422,21 +467,46 @@ because only *you* know which of the string's colors came from the theme
|
|
|
422
467
|
versus which are inherent to the data (a log line's level color, say,
|
|
423
468
|
should *not* follow the theme).
|
|
424
469
|
|
|
425
|
-
The hook for this is {Tuile::Component#
|
|
470
|
+
The hook for this is {Tuile::Component#handle_theme_changed}, fired on every
|
|
426
471
|
attached component whenever the theme changes. Your handler does exactly
|
|
427
472
|
one thing: **re-run the code that rendered the content**, so it rebuilds
|
|
428
473
|
the StyledString against the now-current theme.
|
|
429
474
|
|
|
475
|
+
There are two ways to consume it, matching how you built the component, and
|
|
476
|
+
they are two different names: `on_` for the slot, `handle_` for the override. If
|
|
477
|
+
you assembled stock components, register on the `on_theme_changed` **listener
|
|
478
|
+
slot** — the reader *is* the registrar, and a slot holds as many listeners as
|
|
479
|
+
you give it:
|
|
480
|
+
|
|
481
|
+
```ruby
|
|
482
|
+
label.on_theme_changed { label.text = render_status_line }
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
There is no `on_theme_changed=`, deliberately: nothing you register can displace
|
|
486
|
+
what the widget — or another part of your app — already wired there. Hold what
|
|
487
|
+
`on_theme_changed` returns you if you mean to take it back off later:
|
|
488
|
+
|
|
489
|
+
```ruby
|
|
490
|
+
cb = label.on_theme_changed { … }
|
|
491
|
+
label.on_theme_changed.remove(cb)
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
If you subclassed, override `handle_theme_changed`, the **override point**
|
|
495
|
+
— and call `super`, so registered listeners still fire:
|
|
496
|
+
|
|
430
497
|
```ruby
|
|
431
|
-
|
|
498
|
+
class StatusLabel < Tuile::Component::Label
|
|
499
|
+
protected def handle_theme_changed
|
|
500
|
+
super
|
|
501
|
+
self.text = render_status_line
|
|
502
|
+
end
|
|
503
|
+
end
|
|
432
504
|
```
|
|
433
505
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
is where theme-derived content gets rebuilt, and the framework handles
|
|
439
|
-
everything else.
|
|
506
|
+
That pair is the house rule across the whole widget set, not a special
|
|
507
|
+
case for theming. Either way the rule here is the same — the hook is where
|
|
508
|
+
theme-derived content gets rebuilt, and the framework handles everything
|
|
509
|
+
else.
|
|
440
510
|
|
|
441
511
|
---
|
|
442
512
|
|