tuile 0.15.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 +121 -80
- data/README.md +28 -12
- data/book/04-event-loop.md +12 -12
- data/book/05-focus.md +95 -26
- data/book/06-theming.md +58 -26
- data/book/07-components.md +61 -6
- data/book/08-testing.md +24 -22
- data/book/10-locale.md +2 -2
- data/book/README.md +6 -5
- data/examples/file_commander.rb +14 -5
- data/examples/hello_world.rb +17 -4
- data/examples/sampler.rb +392 -18
- data/lib/tuile/component/abstract_string_field.rb +16 -18
- data/lib/tuile/component/abstract_wrapping_field.rb +47 -11
- data/lib/tuile/component/button.rb +8 -8
- data/lib/tuile/component/checkbox.rb +9 -9
- data/lib/tuile/component/checkbox_group.rb +6 -5
- data/lib/tuile/component/combo_box.rb +50 -35
- data/lib/tuile/component/confirm_window.rb +7 -5
- data/lib/tuile/component/date_field.rb +28 -3
- data/lib/tuile/component/date_time_field.rb +275 -0
- data/lib/tuile/component/has_bad_input.rb +2 -2
- data/lib/tuile/component/has_content.rb +3 -3
- data/lib/tuile/component/has_placeholder.rb +1 -1
- data/lib/tuile/component/has_validation.rb +2 -2
- data/lib/tuile/component/has_value.rb +1 -1
- data/lib/tuile/component/label.rb +1 -1
- data/lib/tuile/component/layout/box.rb +4 -1
- data/lib/tuile/component/layout.rb +3 -3
- data/lib/tuile/component/list.rb +42 -32
- data/lib/tuile/component/list_dropdown.rb +3 -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 +9 -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 +6 -5
- data/lib/tuile/component/select.rb +11 -12
- 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 +12 -10
- data/lib/tuile/component/text_field.rb +14 -12
- data/lib/tuile/component/text_view.rb +15 -11
- data/lib/tuile/component/time_field.rb +29 -4
- data/lib/tuile/component.rb +201 -93
- data/lib/tuile/event_queue.rb +4 -4
- data/lib/tuile/fake_event_queue.rb +1 -1
- data/lib/tuile/fake_screen.rb +84 -2
- data/lib/tuile/mouse/router.rb +217 -0
- data/lib/tuile/mouse.rb +177 -0
- data/lib/tuile/screen.rb +98 -61
- data/lib/tuile/screen_pane.rb +41 -36
- data/lib/tuile/styled_string.rb +5 -5
- data/lib/tuile/testing.rb +8 -8
- data/lib/tuile/theme.rb +22 -34
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile/vertical_scroll_bar.rb +1 -1
- data/sig/tuile.rbs +1211 -427
- metadata +4 -16
- 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/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.
|
|
@@ -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.
|
|
@@ -166,7 +166,7 @@ right shape. **Put the key on the ancestor that owns the region.**
|
|
|
166
166
|
|
|
167
167
|
```ruby
|
|
168
168
|
class AppLayout < Tuile::Component::Layout::Absolute
|
|
169
|
-
def handle_key(key)
|
|
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
|
|
@@ -241,7 +241,7 @@ 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
|
|
@@ -273,10 +273,76 @@ 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
|
|
@@ -296,23 +362,23 @@ declaration in the system.
|
|
|
296
362
|
## A component that reacts to its own focus
|
|
297
363
|
|
|
298
364
|
Two hooks tell a component about itself, and a component overrides them —
|
|
299
|
-
nothing else calls them. {Tuile::Component#
|
|
300
|
-
focus; {Tuile::Component#
|
|
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
|
|
301
367
|
one component, never on the ancestors that light up and go dark with it.
|
|
302
368
|
|
|
303
|
-
`
|
|
369
|
+
`handle_focus` is the forwarding hook. It's how a {Tuile::Component::Window}
|
|
304
370
|
handed focus passes it to its content, and how a {Tuile::Component::Layout}
|
|
305
371
|
skips ahead to the first tab stop underneath it — which is also why it fires
|
|
306
372
|
on *every* assignment, even one that re-focuses what's already focused.
|
|
307
373
|
|
|
308
|
-
`
|
|
374
|
+
`handle_blur` is the commit point. Nothing else in Tuile is one: Tab is
|
|
309
375
|
unconditional, so a user leaving a half-finished field usually leaves by a
|
|
310
376
|
key no component ever sees, and `on_enter` never fires. If your field wants
|
|
311
377
|
to tidy up what was typed, this is where:
|
|
312
378
|
|
|
313
379
|
```ruby
|
|
314
380
|
class TrimmedField < Tuile::Component::TextField
|
|
315
|
-
protected def
|
|
381
|
+
protected def handle_blur = (self.text = text.strip)
|
|
316
382
|
end
|
|
317
383
|
```
|
|
318
384
|
|
|
@@ -331,7 +397,7 @@ nothing; and `screen.close` blurs the focused component on its way out. Keep
|
|
|
331
397
|
the handler cheap and it won't matter.
|
|
332
398
|
|
|
333
399
|
The framework sends both hooks with `__send__`, so declare them public,
|
|
334
|
-
protected or private as you like — the example above groups `
|
|
400
|
+
protected or private as you like — the example above groups `handle_blur` under
|
|
335
401
|
`protected`, which is where framework-invoked plumbing belongs.
|
|
336
402
|
|
|
337
403
|
## Writing a status line
|
|
@@ -344,7 +410,7 @@ A status line is a `Label` in your layout. That's the whole idea:
|
|
|
344
410
|
|
|
345
411
|
```ruby
|
|
346
412
|
status = Tuile::Component::Label.new
|
|
347
|
-
status.text = "q #{screen.theme.hint
|
|
413
|
+
status.text = "q #{screen.theme.fg(:hint, "quit")} Tab #{screen.theme.fg(:hint, "Switch")}"
|
|
348
414
|
|
|
349
415
|
root = Tuile::Component::Layout::Vertical.new
|
|
350
416
|
root.add(main_ui, Tuile::Component::Layout::Expand[1])
|
|
@@ -357,10 +423,13 @@ is exactly this: Tab, Enter and Backspace work in both panes, so its row is
|
|
|
357
423
|
a constant. Reaching for a focus callback there would be machinery computing
|
|
358
424
|
a value that never changes.
|
|
359
425
|
|
|
360
|
-
Two details about the text itself.
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
|
364
433
|
{Tuile::Screen#register_global_shortcut} don't advertise themselves: the
|
|
365
434
|
registry runs actions, it doesn't describe them, so a `^K menu` in your row
|
|
366
435
|
is text you write next to the registration.
|
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
|
|
@@ -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
|
|
@@ -253,7 +257,7 @@ more round trip than you might expect. The mode-2031 report says only
|
|
|
253
257
|
it writes the OSC 11 query again, and the reply comes back through the
|
|
254
258
|
key thread as another event. The new color therefore lands a frame after
|
|
255
259
|
the new theme. When it does, Tuile fires
|
|
256
|
-
{Tuile::Component}`#
|
|
260
|
+
{Tuile::Component}`#handle_theme_changed` across the tree exactly as a theme
|
|
257
261
|
swap does, on the reasoning that a tint derived from the background *is*
|
|
258
262
|
a theme-derived color, and that hook is already where you rebuild those.
|
|
259
263
|
So the same override handles both halves of a flip, and you don't need to
|
|
@@ -353,7 +357,21 @@ one with `theme[:accent]`, which **fail-fasts**: a typo'd token raises
|
|
|
353
357
|
`KeyError` rather than silently painting a default, so a missing color is
|
|
354
358
|
a loud bug and not a mystery. And you render with the generic `fg` / `bg`
|
|
355
359
|
helpers — `theme.fg(:accent, "NEW")` — the custom-token counterparts of
|
|
356
|
-
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
|
+
```
|
|
357
375
|
|
|
358
376
|
For an app with more than a couple of custom tokens, the tidier move is
|
|
359
377
|
to **subclass** {Tuile::Theme} and give each token a named coloring
|
|
@@ -422,21 +440,35 @@ because only *you* know which of the string's colors came from the theme
|
|
|
422
440
|
versus which are inherent to the data (a log line's level color, say,
|
|
423
441
|
should *not* follow the theme).
|
|
424
442
|
|
|
425
|
-
The hook for this is {Tuile::Component#
|
|
443
|
+
The hook for this is {Tuile::Component#handle_theme_changed}, fired on every
|
|
426
444
|
attached component whenever the theme changes. Your handler does exactly
|
|
427
445
|
one thing: **re-run the code that rendered the content**, so it rebuilds
|
|
428
446
|
the StyledString against the now-current theme.
|
|
429
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
|
+
|
|
430
452
|
```ruby
|
|
431
453
|
label.on_theme_changed = -> { label.text = render_status_line }
|
|
432
454
|
```
|
|
433
455
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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.
|
|
440
472
|
|
|
441
473
|
---
|
|
442
474
|
|
data/book/07-components.md
CHANGED
|
@@ -370,7 +370,7 @@ calendar is different: it answers what weekday the 17th is), and with your hands
|
|
|
370
370
|
already on the keys, typing `1345` beats scrolling to it. Tuile is
|
|
371
371
|
keyboard-first: the mouse gets what falls out of click routing for free and never
|
|
372
372
|
motivates a widget on its own. The ranking behind that is `D_mouse` in
|
|
373
|
-
`
|
|
373
|
+
`design/decisions.md`.
|
|
374
374
|
|
|
375
375
|
What you get for it is that the two questions stay independent. Switching
|
|
376
376
|
precision never touches the spelling, so a Finnish user sees `13.45` and
|
|
@@ -540,6 +540,24 @@ not parse, and goes quiet again on your next edit. Only the *ink* waits.
|
|
|
540
540
|
`bad_input?` is still answered from the current buffer the instant you ask it,
|
|
541
541
|
which is what keeps the Save handler above correct with no change at all.
|
|
542
542
|
|
|
543
|
+
There is a second half to this, and it bites harder, because some prefixes of a
|
|
544
|
+
date do not merely fail to parse — they parse *cleanly*. In a `dd.mm.yyyy`
|
|
545
|
+
field, `1.1.2` on the way to `1.1.2024` is the first of January in the year 2:
|
|
546
|
+
a perfectly good `Date`, one `bad_input?` will never flag, and one your
|
|
547
|
+
listener would be handed while the user is still typing, along with whatever
|
|
548
|
+
recalculation hangs off it. So the date and time fields settle their *notice*
|
|
549
|
+
on those same two gestures: `on_value_change` fires when you leave the field or
|
|
550
|
+
press Enter, not as you type. Reading `value` is again unaffected, so a Save on
|
|
551
|
+
a keyboard shortcut that never moves focus still sees the date on screen — and
|
|
552
|
+
a value nobody had to type, a `value=` or an Up/Down step or a `clear`, is
|
|
553
|
+
announced the moment it happens.
|
|
554
|
+
|
|
555
|
+
The ink and the notice settle together because one question decides both, and
|
|
556
|
+
it is the prefix-closed question from a few pages back. Every buffer an
|
|
557
|
+
`IntegerField` passes through really is the number it shows, so `4` on the way
|
|
558
|
+
to `42` is worth announcing and worth reddening. A date's are neither. That one
|
|
559
|
+
property of the grammar settles the filter, the ink and the notice alike.
|
|
560
|
+
|
|
543
561
|
The one discipline the writer owes is visible in those `: nil` branches: **set
|
|
544
562
|
or clear on every pass.** Only assign the message where you validate, and a
|
|
545
563
|
field that has been fixed goes back to normal on its own. Forget the clear and
|
|
@@ -570,14 +588,14 @@ label without displacing the value — so a field carries no caption at all. The
|
|
|
570
588
|
form in this book builds its own captions.
|
|
571
589
|
|
|
572
590
|
The sibling seam, one level up, is **which keys the field acts on at all**:
|
|
573
|
-
override `handle_text_input_key
|
|
591
|
+
override `handle_text_input_key?` and call `super` for everything you don't
|
|
574
592
|
claim.
|
|
575
593
|
|
|
576
594
|
```ruby
|
|
577
595
|
class SubmitField < Tuile::Component::TextArea
|
|
578
596
|
protected
|
|
579
597
|
|
|
580
|
-
def handle_text_input_key(key)
|
|
598
|
+
def handle_text_input_key?(key)
|
|
581
599
|
return super unless key == Tuile::Keys::ENTER
|
|
582
600
|
|
|
583
601
|
submit(text) # Enter submits instead of inserting a newline
|
|
@@ -616,6 +634,41 @@ that will one day sit above these components. So the seam is kept thin on
|
|
|
616
634
|
purpose: `on_value_change` carries just the new value, and there's no
|
|
617
635
|
read-only or required flag yet. Room left for that layer to grow into.
|
|
618
636
|
|
|
637
|
+
### Two fields, one value
|
|
638
|
+
|
|
639
|
+
{Tuile::Component::DateTimeField} is the first field made of *fields*: the date
|
|
640
|
+
field and the time field from earlier in this chapter, side by side on one row,
|
|
641
|
+
behind a single `DateTime`.
|
|
642
|
+
|
|
643
|
+
```ruby
|
|
644
|
+
starts = Component::DateTimeField.new
|
|
645
|
+
starts.value = DateTime.new(2026, 9, 14, 13, 45) # [2026-09-14] [13:45]
|
|
646
|
+
starts.date_field.formats = "%d.%m.%Y" # tune a half in place…
|
|
647
|
+
starts.time_field.step = 900 # …rather than through a forwarder
|
|
648
|
+
```
|
|
649
|
+
|
|
650
|
+
The halves are exposed read-only: a child you *tune* but never *supply* is
|
|
651
|
+
reached directly, so there is no second set of names to keep in step — and no
|
|
652
|
+
argument about whether `formats=` on the composite would mean the date's or the
|
|
653
|
+
time's.
|
|
654
|
+
|
|
655
|
+
The value is non-nil only when both halves parse, and a half going bad nils the
|
|
656
|
+
whole thing rather than holding the last good one: a field holds bad input **or**
|
|
657
|
+
a value, never both. What is genuinely new is the question of who goes red, and
|
|
658
|
+
the answer is one sentence — **the composite paints only the fault no half can
|
|
659
|
+
wear**. Garbage in the date half is attributable, so that half reddens itself on
|
|
660
|
+
the latch you saw a moment ago, and the composite paints nothing. A date with no
|
|
661
|
+
time is nobody else's fault, so the composite reddens *whole* — but only once you
|
|
662
|
+
leave it, so it judges you when you are done rather than while you are filling it
|
|
663
|
+
in. A rule's verdict is not attributable either, and reddens whole with no latch
|
|
664
|
+
at all.
|
|
665
|
+
|
|
666
|
+
One wrinkle follows from that. Pressing Enter over a half-filled field reports
|
|
667
|
+
`bad_input?` and its message but does not redden it; the ink waits for you to
|
|
668
|
+
leave. Latching on Enter would reopen exactly the window the rule closes — the
|
|
669
|
+
one where the field tells you that you are wrong when the truth is that you are
|
|
670
|
+
not finished.
|
|
671
|
+
|
|
619
672
|
## Choosing from a set
|
|
620
673
|
|
|
621
674
|
{Tuile::Component::List} is the workhorse: a scrollable column of *items*
|
|
@@ -967,7 +1020,7 @@ looks like in practice.)
|
|
|
967
1020
|
**A focused button consumes Enter**, and that matters the moment you have
|
|
968
1021
|
more than one. Enter on a focused `Save` activates *that* button — not some
|
|
969
1022
|
form-wide default, because Tuile has no notion of a default button at all.
|
|
970
|
-
The form's Enter-to-submit is a `handle_key
|
|
1023
|
+
The form's Enter-to-submit is a `handle_key?` on the ancestor that owns the
|
|
971
1024
|
form (chapter 5), and it only ever sees Enter when the focused widget
|
|
972
1025
|
declined it. So a dialog's two buttons are just two widgets, and which one
|
|
973
1026
|
Enter hits is simply which one has focus.
|
|
@@ -1234,7 +1287,7 @@ what a `TabSheet` does: only the selected tab's pane is a child of the
|
|
|
1234
1287
|
sheet, the rest are detached. That is a deliberate choice rather than
|
|
1235
1288
|
history, and the reason is the sentence above about hooks — inverted:
|
|
1236
1289
|
|
|
1237
|
-
- **`
|
|
1290
|
+
- **`handle_detached` fires when a pane goes away, `handle_attached` when it
|
|
1238
1291
|
returns.** A {Tuile::Component::ProgressBar} in a background tab stops its
|
|
1239
1292
|
ticker and restarts it on return, with no bookkeeping from you. Hiding
|
|
1240
1293
|
would keep it ticking, unseen.
|
|
@@ -1684,7 +1737,9 @@ layout) *or* as a popup (via a class-level `open`).
|
|
|
1684
1737
|
presentation from the body's type (an Array is rows, text is prose).
|
|
1685
1738
|
- {Tuile::Component::PickerWindow} — a menu of options each bound to a
|
|
1686
1739
|
single key, firing your block with the picked key. Popped up via `open`,
|
|
1687
|
-
it closes itself after a pick; ESC/`q` cancels without firing.
|
|
1740
|
+
it closes itself after a pick; ESC/`q` cancels without firing. Captions
|
|
1741
|
+
paint in the terminal's own foreground; hand in a {Tuile::StyledString}
|
|
1742
|
+
(or the ANSI string `theme.fg` returns) to color one, per option.
|
|
1688
1743
|
- {Tuile::Component::LogWindow} — a Window framing a
|
|
1689
1744
|
{Tuile::Component::LogTextView}: an auto-scrolling, scrollbar-equipped
|
|
1690
1745
|
TextView purpose-built for log output. The view is where the behavior
|
data/book/08-testing.md
CHANGED
|
@@ -107,7 +107,7 @@ method* assembled, and the test never held a reference to it.
|
|
|
107
107
|
one component matching a spec:
|
|
108
108
|
|
|
109
109
|
```ruby
|
|
110
|
-
Testing.get(Component::Button, caption: "Save").handle_key(Keys::ENTER)
|
|
110
|
+
Testing.get(Component::Button, caption: "Save").handle_key?(Keys::ENTER)
|
|
111
111
|
Testing.get(id: :amount).value = 42
|
|
112
112
|
```
|
|
113
113
|
|
|
@@ -132,7 +132,7 @@ walk that takes the first match —
|
|
|
132
132
|
|
|
133
133
|
```ruby
|
|
134
134
|
combo = nil
|
|
135
|
-
window.
|
|
135
|
+
window.walk_tree { |c| combo ||= c if c.is_a?(Component::ComboBox) }
|
|
136
136
|
```
|
|
137
137
|
|
|
138
138
|
— and the day the pane grows a second `ComboBox`, that silently re-points
|
|
@@ -178,34 +178,36 @@ terser, and changes nothing about the assertion channel. What a component
|
|
|
178
178
|
There are two altitudes at which you feed input, and picking the right one
|
|
179
179
|
is most of writing a good Tuile test.
|
|
180
180
|
|
|
181
|
-
**Low: call the component directly.** {Tuile::Component#handle_key} and
|
|
182
|
-
`
|
|
183
|
-
own logic in isolation — no focus, no dispatch, just "given this
|
|
184
|
-
the list move its cursor?"
|
|
185
|
-
|
|
181
|
+
**Low: call the component directly.** {Tuile::Component#handle_key?} and the
|
|
182
|
+
`handle_mouse_*` handlers are public, and calling one straight tests a
|
|
183
|
+
component's own logic in isolation — no focus, no dispatch, just "given this
|
|
184
|
+
key, does the list move its cursor?" Both answer whether they consumed the
|
|
185
|
+
event, so you assert on that too:
|
|
186
186
|
|
|
187
187
|
```ruby
|
|
188
|
-
list.handle_key(Keys::DOWN_ARROW)
|
|
188
|
+
list.handle_key?(Keys::DOWN_ARROW) # exercises the cursor directly
|
|
189
|
+
list.handle_mouse_scroll?(Mouse::ScrollEvent.new(:up, 5, 2)) # false at the top: an ancestor gets it
|
|
189
190
|
```
|
|
190
191
|
|
|
191
|
-
**A
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
192
|
+
**A press, though, wants the high altitude.** It does not only *do* something:
|
|
193
|
+
it focuses, it dismisses popups, and which component it even reaches is the
|
|
194
|
+
router's answer rather than the component's — so calling `handle_mouse_down?`
|
|
195
|
+
by hand tests a third of what a click is. Drive it through
|
|
196
|
+
{Tuile::FakeScreen}, which posts the gesture the terminal would:
|
|
196
197
|
|
|
197
198
|
```ruby
|
|
198
|
-
screen.content = list # a
|
|
199
|
+
screen.content = list # a press focuses; focus needs a tree
|
|
199
200
|
list.rect = Rect.new(0, 0, 10, 5)
|
|
200
|
-
|
|
201
|
+
screen.click(5, 2) # press then release, at that cell
|
|
201
202
|
```
|
|
202
203
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
the
|
|
204
|
+
`click` is the whole gesture; `press` / `release` are its halves, for a test
|
|
205
|
+
about what the grab does in between, and `scroll` / `move` post the other two
|
|
206
|
+
events. They all take screen-absolute, 0-based coordinates, so a test asserts
|
|
207
|
+
against the rect it assigned — and a press on a cell no component covers
|
|
208
|
+
simply does nothing.
|
|
207
209
|
|
|
208
|
-
**High: go through the pane.** {Tuile::ScreenPane#handle_key} runs the
|
|
210
|
+
**High: go through the pane.** {Tuile::ScreenPane#handle_key?} runs the
|
|
209
211
|
dispatch rung from chapter 5 that routing is actually about: delivery to
|
|
210
212
|
{Tuile::Screen#focused}, then the bubble up its ancestor chain to the scope
|
|
211
213
|
root. So when your test is about routing — that a layout's one-key pane jump
|
|
@@ -215,11 +217,11 @@ drive the pane and let the real machinery run:
|
|
|
215
217
|
|
|
216
218
|
```ruby
|
|
217
219
|
screen.focused = list # focus as production does — or list.focus
|
|
218
|
-
assert screen.pane.handle_key("1") # the layout's ancestor binding fires
|
|
220
|
+
assert screen.pane.handle_key?("1") # the layout's ancestor binding fires
|
|
219
221
|
```
|
|
220
222
|
|
|
221
223
|
The two rungs *above* the pane have their own doors, because `Screen`'s own
|
|
222
|
-
`handle_key
|
|
224
|
+
`handle_key?` — the top of the ladder — is private: it belongs to the key
|
|
223
225
|
thread, not to app code. Tab cycling is {Tuile::Screen#focus_next} /
|
|
224
226
|
`focus_previous`, both already scoped to the topmost modal popup, which is
|
|
225
227
|
what "a popup traps Tab" means. A global shortcut is a block you registered,
|