tuile 0.12.0 → 0.14.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 +116 -27
- data/COMPARISON.md +101 -0
- data/DECISIONS.md +2342 -158
- data/README.md +151 -505
- data/TERMINOLOGY.md +15 -5
- data/book/01-first-app.md +22 -17
- data/book/02-repaint.md +18 -5
- data/book/03-layout.md +28 -20
- data/book/05-focus.md +137 -19
- data/book/06-theming.md +103 -2
- data/book/07-components.md +567 -27
- data/book/08-testing.md +34 -4
- data/book/09-styled-text.md +3 -3
- data/book/README.md +7 -5
- data/examples/file_commander.rb +23 -17
- data/examples/hello_world.rb +17 -5
- data/examples/sampler.rb +527 -108
- data/ideas/arrow-key-navigation.md +17 -1
- data/ideas/modal-backdrop.md +24 -0
- data/ideas/new-components.md +28 -27
- data/lib/tuile/ansi.rb +10 -0
- data/lib/tuile/buffer.rb +51 -3
- data/lib/tuile/color.rb +143 -0
- data/lib/tuile/color_depth.rb +80 -0
- data/lib/tuile/component/abstract_string_field.rb +36 -0
- data/lib/tuile/component/button.rb +3 -3
- data/lib/tuile/component/checkbox.rb +3 -3
- data/lib/tuile/component/combo_box.rb +12 -3
- data/lib/tuile/component/confirm_window.rb +442 -0
- data/lib/tuile/component/has_content.rb +22 -9
- data/lib/tuile/component/has_value.rb +1 -1
- data/lib/tuile/component/info_window.rb +64 -16
- data/lib/tuile/component/layout.rb +0 -10
- data/lib/tuile/component/list.rb +22 -0
- data/lib/tuile/component/list_dropdown.rb +102 -11
- data/lib/tuile/component/log_text_view.rb +71 -0
- data/lib/tuile/component/log_window.rb +13 -48
- data/lib/tuile/component/menu_bar/cascade.rb +255 -0
- data/lib/tuile/component/menu_bar.rb +582 -0
- data/lib/tuile/component/notification.rb +24 -39
- data/lib/tuile/component/overlay.rb +192 -0
- data/lib/tuile/component/picker_window.rb +0 -5
- data/lib/tuile/component/popup.rb +61 -123
- data/lib/tuile/component/progress_bar.rb +1 -1
- data/lib/tuile/component/select.rb +17 -7
- data/lib/tuile/component/slot.rb +54 -0
- data/lib/tuile/component/tab_sheet.rb +231 -0
- data/lib/tuile/component/tabs.rb +528 -0
- data/lib/tuile/component/text_area.rb +5 -4
- data/lib/tuile/component/text_field.rb +23 -6
- data/lib/tuile/component/text_view.rb +8 -5
- data/lib/tuile/component/window.rb +22 -46
- data/lib/tuile/component.rb +186 -31
- data/lib/tuile/event_queue.rb +45 -1
- data/lib/tuile/fake_screen.rb +40 -2
- data/lib/tuile/keys.rb +72 -0
- data/lib/tuile/screen.rb +212 -113
- data/lib/tuile/screen_pane.rb +125 -41
- data/lib/tuile/styled_string.rb +80 -7
- data/lib/tuile/terminal_background.rb +74 -16
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +2527 -358
- metadata +13 -3
- data/mise.toml +0 -2
data/DECISIONS.md
CHANGED
|
@@ -13,10 +13,15 @@ It is the *why-we-chose* record; it is not the *how-it-works* reference
|
|
|
13
13
|
those, put it there and don't restate it — an entry here links out rather
|
|
14
14
|
than duplicating.
|
|
15
15
|
|
|
16
|
-
**Format.** One entry per decision. The ID is a slug, not a number: `
|
|
17
|
-
(says "this is a decision") plus a 1–4-word
|
|
18
|
-
(`
|
|
19
|
-
counter would not.
|
|
16
|
+
**Format.** One entry per decision. The ID is a slug, not a number: `D_`
|
|
17
|
+
(says "this is a decision") plus a 1–4-word hint at the subject
|
|
18
|
+
(`D_bg_inherit`), so a reference carries meaning on its own — a running
|
|
19
|
+
counter would not. **Underscores throughout, never hyphens**: the id has
|
|
20
|
+
to be one *token*, so that vim's `w` / `*` / `ciw` and `grep -w` act on
|
|
21
|
+
the whole thing rather than on a fragment. Backtick it in prose, both
|
|
22
|
+
because some downstream Markdown parsers italicise intraword `_` and
|
|
23
|
+
because a backticked id is copy-pasteable into a search. The `(date)` on
|
|
24
|
+
the heading is *decided* provenance,
|
|
20
25
|
not a log position; git owns the edit history (consistent with the "No
|
|
21
26
|
history" rule — don't narrate how an entry used to read). Keep each entry
|
|
22
27
|
tight: context, the decision, the alternatives rejected and why, and the
|
|
@@ -34,7 +39,7 @@ choice, now sharper or broader). Two things this does *not* license:
|
|
|
34
39
|
It's the most valuable thing in the file.
|
|
35
40
|
- **A reversed *shipped* decision forks a tombstone, it is not overwritten.**
|
|
36
41
|
When a design was tried, shipped, and then thrown away, leave the old
|
|
37
|
-
entry as the scar, set its `Status:` to **Superseded by
|
|
42
|
+
entry as the scar, set its `Status:` to **Superseded by `D_<slug>`**, and
|
|
38
43
|
write the replacement fresh. (The shape of such a reversal: the deleted
|
|
39
44
|
bottom-up `content_size` sizing channel, replaced by top-down layout —
|
|
40
45
|
see AGENTS.md "Layout is top-down".) The line: *refined or extended* →
|
|
@@ -42,7 +47,7 @@ choice, now sharper or broader). Two things this does *not* license:
|
|
|
42
47
|
|
|
43
48
|
---
|
|
44
49
|
|
|
45
|
-
##
|
|
50
|
+
## D_bg_inherit — Background color: fill-the-gaps inheritance (2026-07-23)
|
|
46
51
|
|
|
47
52
|
**Status:** Accepted; implemented 2026-07-23. Tracks
|
|
48
53
|
[issue #1](https://github.com/mvysny/tuile/issues/1).
|
|
@@ -115,21 +120,21 @@ are opt-in"). {Component::Label} already carried its own `#bg` (override-all
|
|
|
115
120
|
via `with_bg`); it composes with `bg_color` (explicit span bgs survive
|
|
116
121
|
`under_bg`, so `#bg` wins locally), but the two-knob overlap is a wart
|
|
117
122
|
flagged for a later consolidation decision. The theme-token variant that
|
|
118
|
-
surfaced during design landed separately — see `
|
|
123
|
+
surfaced during design landed separately — see `D_theme_ref`.
|
|
119
124
|
|
|
120
125
|
---
|
|
121
126
|
|
|
122
|
-
##
|
|
127
|
+
## D_theme_ref — Live theme references for `bg_color` (2026-07-23)
|
|
123
128
|
|
|
124
129
|
**Status:** Accepted; implemented 2026-07-23. Tracks
|
|
125
130
|
[issue #1](https://github.com/mvysny/tuile/issues/1). Relaxes the
|
|
126
|
-
`bg_color`-takes-`Color`-only stance of `
|
|
131
|
+
`bg_color`-takes-`Color`-only stance of `D_bg_inherit`, which rejected a
|
|
127
132
|
built-in `panel_bg` token and deferred the general "themeable color
|
|
128
133
|
property" question.
|
|
129
134
|
|
|
130
135
|
**Context.** Tracking a themed background meant setting the color *twice* —
|
|
131
136
|
once as a concrete `Color`, and again in an `on_theme_changed` block so it
|
|
132
|
-
survives light/dark flips — for every tinted panel. `
|
|
137
|
+
survives light/dark flips — for every tinted panel. `D_bg_inherit` deferred
|
|
133
138
|
the fix; this is it.
|
|
134
139
|
|
|
135
140
|
**Decision.** `Component#bg_color` accepts a `Theme::Ref` (built by
|
|
@@ -163,7 +168,7 @@ existing chrome accent and tracking flips — concretely
|
|
|
163
168
|
{Component::ComboBox}'s borderless dropdown, which tints with
|
|
164
169
|
`input_bg_color` (tying it to the field's own well) and would otherwise need
|
|
165
170
|
the very `on_theme_changed`/resolve-on-open boilerplate `Theme::Ref` exists
|
|
166
|
-
to kill. The invariant `
|
|
171
|
+
to kill. The invariant `D_bg_inherit` actually protects is *the Theme
|
|
167
172
|
carries no global bg/fg field* — and every chrome token is an **accent**
|
|
168
173
|
(`active_bg`, `active_border`, `input_bg`, `hint`), never a global
|
|
169
174
|
background. A `Ref` to one adds no new token and creates no global
|
|
@@ -200,7 +205,7 @@ reaching chrome tokens leaves the no-global-bg/fg guard untouched.
|
|
|
200
205
|
**Consequences.**
|
|
201
206
|
- A `Ref` adds no new token (chrome tokens are all accents; `custom` is
|
|
202
207
|
app-supplied), so it **cannot** reintroduce the global bg/fg token that
|
|
203
|
-
`
|
|
208
|
+
`D_bg_inherit` and the AGENTS.md theme stance refuse — the two stay
|
|
204
209
|
orthogonal.
|
|
205
210
|
- Collision precedence is chrome-wins; a `custom` token named after a chrome
|
|
206
211
|
token is shadowed when referenced by `Ref` (harmless, documented on
|
|
@@ -214,7 +219,7 @@ reaching chrome tokens leaves the no-global-bg/fg guard untouched.
|
|
|
214
219
|
|
|
215
220
|
---
|
|
216
221
|
|
|
217
|
-
##
|
|
222
|
+
## D_has_value — Typed value seam (`HasValue`) over String-only (2026-07-23)
|
|
218
223
|
|
|
219
224
|
**Status:** Accepted; implemented 2026-07-23 (`Component::HasValue`, included
|
|
220
225
|
by `AbstractStringField`; first typed consumer is `ComboBox`). Tracks the "do input
|
|
@@ -271,10 +276,10 @@ values *and* a uniform seam for free.
|
|
|
271
276
|
|
|
272
277
|
---
|
|
273
278
|
|
|
274
|
-
##
|
|
279
|
+
## D_combobox — `ComboBox`: composed, typed, filterable-first (2026-07-23)
|
|
275
280
|
|
|
276
281
|
**Status:** Accepted; implemented 2026-07-23 (`Component::ComboBox`, demoed in
|
|
277
|
-
the sampler). Builds on `
|
|
282
|
+
the sampler). Builds on `D_has_value`, `D_bg_inherit`, `D_theme_ref`.
|
|
278
283
|
|
|
279
284
|
**Context.** A text field with a filtering dropdown. The ad-hoc version already
|
|
280
285
|
existed in the sampler's slash-command demo (a `TextField` + a non-modal
|
|
@@ -302,18 +307,18 @@ component.
|
|
|
302
307
|
make value-before-items the normal case rather than a corner. The cost — the
|
|
303
308
|
app owns keeping them in sync, reconciling with a one-line intersection when
|
|
304
309
|
it wants to — is smaller than any framework reconcile step (see the rejected
|
|
305
|
-
three in `
|
|
310
|
+
three in `D_checkbox_group`, where the set-valued case forced the question).
|
|
306
311
|
One rule, two instances: singular here, a `Set` of items in `CheckboxGroup`.
|
|
307
312
|
- **Two values, never conflated.** `value` = the committed selection (changes
|
|
308
313
|
only on Enter/click; sole trigger of `on_value_change`); the field's `text`
|
|
309
314
|
= a transient **query** that filters the list and reverts to the value's
|
|
310
315
|
label on ESC/blur.
|
|
311
316
|
- **Filterable first;** the non-filterable `Select` is deferred (it wants the
|
|
312
|
-
read-only field behavior `
|
|
317
|
+
read-only field behavior `D_has_value` parked for the forms layer).
|
|
313
318
|
- **Borderless tinted dropdown** (no `Window`): a bare `Popup(List)` told apart
|
|
314
319
|
from the content by a background tint, `bg_color = Theme.ref(:input_bg_color)`
|
|
315
|
-
— live-tracked, no `on_theme_changed` hook (leans on `
|
|
316
|
-
`
|
|
320
|
+
— live-tracked, no `on_theme_changed` hook (leans on `D_bg_inherit` +
|
|
321
|
+
`D_theme_ref`). A `▾` affordance marks the field; the dropdown flips above
|
|
317
322
|
when it would overrun the screen bottom.
|
|
318
323
|
|
|
319
324
|
**Alternatives rejected.**
|
|
@@ -321,7 +326,7 @@ component.
|
|
|
321
326
|
above.
|
|
322
327
|
- *String value (the display text):* fails identity-across-duplicate-labels,
|
|
323
328
|
the whole reason to prefer a component over `List` + a lookup hash
|
|
324
|
-
(`
|
|
329
|
+
(`D_has_value`).
|
|
325
330
|
- *Store the selected **index** rather than the object* (and clear the selection
|
|
326
331
|
when `value=` gets something not in `items`): the plausible misreading of the
|
|
327
332
|
identity rule, and it breaks the chrome/value split above — replacing `items`
|
|
@@ -330,7 +335,7 @@ component.
|
|
|
330
335
|
*resolution* mechanism, valid only at the instant of a click.
|
|
331
336
|
- *`Window`-framed dropdown:* the border is redundant chrome once a tint
|
|
332
337
|
separates the panel, and costs 2 rows + 2 cols; the tint is what
|
|
333
|
-
`
|
|
338
|
+
`D_bg_inherit` was built to make solid.
|
|
334
339
|
- *`allow_custom_value`* (Vaadin's "typed text not in the list" escape hatch):
|
|
335
340
|
deferred — a custom value is a `String`, reintroducing the String/`T` tension
|
|
336
341
|
at the value boundary; no use case needs it yet.
|
|
@@ -347,10 +352,10 @@ component.
|
|
|
347
352
|
|
|
348
353
|
---
|
|
349
354
|
|
|
350
|
-
##
|
|
355
|
+
## D_integer_field — `IntegerField`: the second typed input, and the composed-field taxonomy (2026-07-23)
|
|
351
356
|
|
|
352
357
|
**Status:** Accepted; implemented 2026-07-23 (`Component::IntegerField`). Builds
|
|
353
|
-
on `
|
|
358
|
+
on `D_has_value`, `D_combobox`. Its real job was to *validate the `HasValue`
|
|
354
359
|
seam* for the case where `value`'s type diverges from the editing buffer:
|
|
355
360
|
`ComboBox` proved the fully-detached case (value ⟂ query), `IntegerField`
|
|
356
361
|
probes the *derived* case (value = a parse of the buffer). Extended 2026-08-02
|
|
@@ -367,7 +372,7 @@ moment to settle the input taxonomy while still pre-1.0.
|
|
|
367
372
|
not reuse: subclassing drags `TextField`'s `String`-typed `text`/`value` seam
|
|
368
373
|
onto the field's public face, next to the real `Integer` `value` as a
|
|
369
374
|
conflicting second seam, and Ruby can't cleanly hide inherited public
|
|
370
|
-
methods. (Same shape as `
|
|
375
|
+
methods. (Same shape as `D_combobox`; makes `IntegerField` a *simpler
|
|
371
376
|
ComboBox* — the identical structure minus the dropdown.)
|
|
372
377
|
**The taxonomy is two-sided: compose when the value's type diverges from the
|
|
373
378
|
buffer, subclass when it doesn't.** `Component::PasswordField < TextField`
|
|
@@ -388,7 +393,7 @@ moment to settle the input taxonomy while still pre-1.0.
|
|
|
388
393
|
`cycle_focus` collects stops via `on_tree`).
|
|
389
394
|
- **The converter stays private and hardcoded** (`Integer(t, 10)` / `to_s`),
|
|
390
395
|
exactly as `TextField` hardcodes identity-String. No public `converter=`
|
|
391
|
-
strategy — that is the future Binder's job (`
|
|
396
|
+
strategy — that is the future Binder's job (`D_has_value` keeps converters
|
|
392
397
|
*above* the field).
|
|
393
398
|
- **Value is a derived parse, fired eagerly.** `value` is recomputed from the
|
|
394
399
|
buffer on read; `on_value_change` fires per keystroke but only on a real
|
|
@@ -443,7 +448,7 @@ what Java needs a class for, and `is_a?(HasValue)` is the Binder's marker.
|
|
|
443
448
|
|
|
444
449
|
---
|
|
445
450
|
|
|
446
|
-
##
|
|
451
|
+
## D_ambiguous_width — Bet on ambiguous-as-narrow; keep the inventory small (2026-07-30)
|
|
447
452
|
|
|
448
453
|
**Status:** Accepted 2026-07-30; describes what Tuile already does, plus one
|
|
449
454
|
new *forward-looking* rule (the inventory discipline) that governs new glyph
|
|
@@ -546,7 +551,7 @@ Detect once and swap glyphs, rather than re-deriving widths everywhere:
|
|
|
546
551
|
|
|
547
552
|
---
|
|
548
553
|
|
|
549
|
-
##
|
|
554
|
+
## D_key_dispatch — Delete `key_shortcut`; scope-wide keys ride the bubble (2026-07-30)
|
|
550
555
|
|
|
551
556
|
**Status:** Accepted 2026-07-30; implemented the same day. Supersedes the
|
|
552
557
|
shipped capture phase of `ScreenPane#handle_key` — see *the scar* at the end.
|
|
@@ -758,13 +763,13 @@ on a per-binding flag).
|
|
|
758
763
|
|
|
759
764
|
---
|
|
760
765
|
|
|
761
|
-
##
|
|
766
|
+
## D_boolean_fields — `Checkbox`: two-state value, painted extent, ASCII glyphs (2026-07-30)
|
|
762
767
|
|
|
763
768
|
**Status:** Accepted; `Component::Checkbox` implemented 2026-07-30. Builds on
|
|
764
|
-
`
|
|
765
|
-
`Component::CheckboxGroup` (`
|
|
769
|
+
`D_has_value`. The glyph and caption rulings are shared with
|
|
770
|
+
`Component::CheckboxGroup` (`D_checkbox_group`, which scopes the key and hit-test
|
|
766
771
|
rulings below to a *standalone* widget) and with `RadioGroup`
|
|
767
|
-
(`
|
|
772
|
+
(`D_radio_group`). Tri-state is settled here but **not built**, and this
|
|
768
773
|
entry is its only home — see the last section.
|
|
769
774
|
|
|
770
775
|
**Context.** The first boolean input: one row, `[x] Enable syslog forwarding`,
|
|
@@ -791,7 +796,7 @@ will follow.
|
|
|
791
796
|
don't need is the irreversible direction. What tipped it is *consistency with
|
|
792
797
|
the group components*: a checkable row inside a `List` toggles on Enter, since
|
|
793
798
|
Enter is `List`'s own choose-the-item-under-the-cursor gesture
|
|
794
|
-
(`
|
|
799
|
+
(`D_checkbox_group`, `D_radio_group`). So `[ ] Verbose` flipped on Enter when
|
|
795
800
|
it sat in a `CheckboxGroup` and did nothing when it sat alone in a form — a
|
|
796
801
|
distinction the user cannot see, and one that reads as a bug in the standalone
|
|
797
802
|
widget rather than as restraint. One gesture set, both shapes, is worth more
|
|
@@ -827,7 +832,7 @@ will follow.
|
|
|
827
832
|
`ellipsize(rect.width)` already equals `ellipsize(extent.width)` in both
|
|
828
833
|
directions.
|
|
829
834
|
**The rule is scoped to a *standalone* one-row field.** A checkable row
|
|
830
|
-
*inside a list* hit-tests its full width instead (`
|
|
835
|
+
*inside a list* hit-tests its full width instead (`D_checkbox_group`), and the
|
|
831
836
|
difference is perceptual rather than a relaxation of rigor: with a cursor
|
|
832
837
|
visible and ten rows stacked, the unit the user aims at is a **row**, and a
|
|
833
838
|
row's affordance is its whole width — which is what `List`'s row-wide cursor
|
|
@@ -845,7 +850,7 @@ will follow.
|
|
|
845
850
|
so the two states degrade *asymmetrically* to tofu — checked renders,
|
|
846
851
|
unchecked doesn't, which reads as a bug rather than a fallback) and on **ink
|
|
847
852
|
overflow** (the fallback glyph is drawn wider than its cell in Alacritty —
|
|
848
|
-
cosmetic, coordinates stay correct; see `
|
|
853
|
+
cosmetic, coordinates stay correct; see `D_ambiguous_width` for why that's a
|
|
849
854
|
different problem). Locally, three columns is also a bigger click target that
|
|
850
855
|
survives a monochrome terminal, and keeps `region_text` assertions ASCII.
|
|
851
856
|
|
|
@@ -860,7 +865,7 @@ will follow.
|
|
|
860
865
|
the promise in `CheckboxGroup` would have forced it onto the
|
|
861
866
|
`ListDropdown::Menu` shape — a non-focusable `List` subclass plus
|
|
862
867
|
hand-forwarded movement keys — to protect a guarantee nothing relied on
|
|
863
|
-
(`
|
|
868
|
+
(`D_checkbox_group`). Enter-reaches-your-form is a per-assembly property the
|
|
864
869
|
app verifies for its own focusable widgets, not a framework invariant. Still
|
|
865
870
|
rejected, and now moot in both directions: the standalone widget claims Enter
|
|
866
871
|
too, which is what made the two shapes agree.
|
|
@@ -892,7 +897,7 @@ will follow.
|
|
|
892
897
|
(`Screen#refresh_status_bar` can't even reach a leaf field — it consults the
|
|
893
898
|
active `Window` or the top popup's *direct* content.)
|
|
894
899
|
- *A read-only flag:* parked with the rest of the forms-layer axes by
|
|
895
|
-
`
|
|
900
|
+
`D_has_value`.
|
|
896
901
|
|
|
897
902
|
**Tri-state (indeterminate) — settled, not built.** When it lands it adopts
|
|
898
903
|
**Vaadin's orthogonal flag**: `indeterminate`/`indeterminate=` as a plain
|
|
@@ -924,23 +929,23 @@ marker is live-resolved chrome like every other built-in accent.
|
|
|
924
929
|
|
|
925
930
|
Deferred because the use case (a partially-checked tree parent) has no home in
|
|
926
931
|
Tuile today. Its first plausible consumer would be a `CheckboxGroup` header row
|
|
927
|
-
— which `
|
|
932
|
+
— which `D_checkbox_group` declined to build, leaving this unbuilt too; that
|
|
928
933
|
entry names the forcing function to watch for.
|
|
929
934
|
|
|
930
935
|
---
|
|
931
936
|
|
|
932
|
-
##
|
|
937
|
+
## D_checkbox_group — `CheckboxGroup`: a field composing a `List`, `Set`-valued (2026-07-30)
|
|
933
938
|
|
|
934
939
|
**Status:** Accepted; `Component::CheckboxGroup` implemented 2026-07-30, demoed
|
|
935
|
-
in the sampler. Builds on `
|
|
936
|
-
generalizes), `
|
|
937
|
-
`
|
|
940
|
+
in the sampler. Builds on `D_has_value`, `D_combobox` (the chrome/value split it
|
|
941
|
+
generalizes), `D_integer_field` (the composed-field taxonomy it extends) and
|
|
942
|
+
`D_boolean_fields` (the glyphs, and the two rulings it scopes).
|
|
938
943
|
|
|
939
944
|
**Context.** Multi-select from a handful of typed items, one `[x] label` row
|
|
940
945
|
each. The cursor and the selection are genuinely two pieces of state here —
|
|
941
946
|
which is exactly the shape `List` already implements, so the question was how
|
|
942
947
|
much of `List` to reuse and what the value should be. (A single-select group
|
|
943
|
-
*could* have conflated them, and `
|
|
948
|
+
*could* have conflated them, and `D_radio_group` records why it doesn't.)
|
|
944
949
|
|
|
945
950
|
**Decision.**
|
|
946
951
|
- **Compose a plain `List`, unmodified.** `CheckboxGroup` holds one as its single
|
|
@@ -949,7 +954,7 @@ much of `List` to reuse and what the value should be. (A single-select group
|
|
|
949
954
|
`lines=` on any change to items/labels/selection, claim **Space** in
|
|
950
955
|
`handle_key`, and toggle from `on_item_chosen`. That one callback covers Enter
|
|
951
956
|
*and* click (`list.rb:209` and `:264`), so there is no `handle_mouse` override
|
|
952
|
-
at all. This **extends `
|
|
957
|
+
at all. This **extends `D_integer_field`'s taxonomy** from "a typed field
|
|
953
958
|
composes a `TextField`" to "a typed field composes whatever widget already has
|
|
954
959
|
the interaction" — the tab stop lives on the inner widget, the wrapper is not
|
|
955
960
|
one, exactly as for `ComboBox`.
|
|
@@ -969,10 +974,10 @@ much of `List` to reuse and what the value should be. (A single-select group
|
|
|
969
974
|
end — i.e. the observable order is the user's *toggle history*. Documented as
|
|
970
975
|
unordered so nobody builds on that; `items & value.to_a` is the idiom for
|
|
971
976
|
items order, and the sampler pane uses it visibly.
|
|
972
|
-
- **Items are chrome (`
|
|
977
|
+
- **Items are chrome (`D_combobox`), so `items=` never touches `value`** and never
|
|
973
978
|
fires `on_value_change`. A selected item absent from `items` renders no checked
|
|
974
979
|
row and survives intact.
|
|
975
|
-
- **Two `
|
|
980
|
+
- **Two `D_boolean_fields` rulings are scoped, not broken.** A click anywhere on
|
|
976
981
|
a row toggles it (a row's affordance is its full width, which its cursor
|
|
977
982
|
highlight already advertises) while a *standalone* checkbox still ignores its
|
|
978
983
|
blank tail; and Enter toggles here because that is `List`'s choose gesture. The
|
|
@@ -980,7 +985,7 @@ much of `List` to reuse and what the value should be. (A single-select group
|
|
|
980
985
|
`on_item_chosen` only for `line < @lines.size`, so a click below the last row
|
|
981
986
|
toggles nothing.
|
|
982
987
|
- **No header row, no tri-state, no select-all.** A header is the only plausible
|
|
983
|
-
consumer of `
|
|
988
|
+
consumer of `D_boolean_fields`' settled-but-unbuilt `indeterminate` flag, and
|
|
984
989
|
it is also where every policy question lives: which children it governs,
|
|
985
990
|
whether checking it selects all, one change event or N, whether it scrolls with
|
|
986
991
|
the rows. That entry already rules a header *app policy*, so building one here
|
|
@@ -998,11 +1003,11 @@ much of `List` to reuse and what the value should be. (A single-select group
|
|
|
998
1003
|
whatever now occupies that index; *re-map by `==`* is the honest one but still
|
|
999
1004
|
can't preserve intent across duplicates and must decide whether to fire; *clear*
|
|
1000
1005
|
discards the user's work when items merely gained a row. Storing items deletes
|
|
1001
|
-
the question rather than answering it — see `
|
|
1006
|
+
the question rather than answering it — see `D_combobox`'s matching rejection.
|
|
1002
1007
|
- *The `ListDropdown::Menu` shape — a non-focusable `List` subclass, focus on the
|
|
1003
1008
|
wrapper, movement keys hand-forwarded:* the design forced by taking Enter away
|
|
1004
1009
|
from the list. Correct, and about 15 lines of forwarding plus a subclass, all
|
|
1005
|
-
to protect a promise nothing relied on (see `
|
|
1010
|
+
to protect a promise nothing relied on (see `D_boolean_fields`' rejected Enter
|
|
1006
1011
|
reservation). Reach for it only if a driver genuinely needs Enter for itself.
|
|
1007
1012
|
- *Paint the rows directly (`< Component`, `draw_text` per row):* wrong here.
|
|
1008
1013
|
The cursor-distinct-from-selection structure *is* `List`, a checkbox group is
|
|
@@ -1010,7 +1015,7 @@ much of `List` to reuse and what the value should be. (A single-select group
|
|
|
1010
1015
|
re-implementing the cursor, the viewport, the scrollbar and the mouse
|
|
1011
1016
|
arithmetic. This was left explicitly open for a radio group, on the grounds
|
|
1012
1017
|
that three rows and a selection-follows-cursor model would need almost none of
|
|
1013
|
-
it; `
|
|
1018
|
+
it; `D_radio_group` then closed it the same way, because dropping that model
|
|
1014
1019
|
removed the friction that made painting attractive.
|
|
1015
1020
|
- *An `Array`-valued `value` in `items` order:* would make ordering meaningful and
|
|
1016
1021
|
so make it a contract to maintain, plus `==` would then treat two identical
|
|
@@ -1022,7 +1027,7 @@ much of `List` to reuse and what the value should be. (A single-select group
|
|
|
1022
1027
|
because that rule is `ComboBox`'s already (the `cop` duplicate-rather-than-fold
|
|
1023
1028
|
rule).
|
|
1024
1029
|
- *Public `CHECKED`/`UNCHECKED` glyph constants shared with `Checkbox`:* declined
|
|
1025
|
-
again here for the reason `
|
|
1030
|
+
again here for the reason `D_boolean_fields` gives — the group paints its own
|
|
1026
1031
|
rows and never instantiates a `Checkbox`, so importing a constant would read as
|
|
1027
1032
|
a dependency that isn't there. Drift between the two copies surfaces as a
|
|
1028
1033
|
`region_text` mismatch, not a silent bug.
|
|
@@ -1040,12 +1045,12 @@ independent.
|
|
|
1040
1045
|
|
|
1041
1046
|
---
|
|
1042
1047
|
|
|
1043
|
-
##
|
|
1048
|
+
## D_radio_group — `RadioGroup`: cursor roams, selection commits; the cursor is chrome (2026-07-31)
|
|
1044
1049
|
|
|
1045
1050
|
**Status:** Accepted; `Component::RadioGroup` implemented 2026-07-31, demoed in
|
|
1046
|
-
the sampler. Builds on `
|
|
1047
|
-
`
|
|
1048
|
-
`List`-composing shape it copies) and `
|
|
1051
|
+
the sampler. Builds on `D_has_value`, `D_combobox` (the chrome/value split),
|
|
1052
|
+
`D_integer_field` (the composed-field taxonomy), `D_checkbox_group` (the
|
|
1053
|
+
`List`-composing shape it copies) and `D_ambiguous_width` (the glyphs). Most of
|
|
1049
1054
|
this component was settled by those five; what it owns is the **interaction
|
|
1050
1055
|
model**, which reverses both the desktop convention and this note's own first
|
|
1051
1056
|
design.
|
|
@@ -1114,12 +1119,12 @@ list floors at 0 and a `Cursor::Limited` keeps its own notion of "last". The
|
|
|
1114
1119
|
note held open:* it existed to escape the four frictions above, which the
|
|
1115
1120
|
interaction model removes. Composing a `List` then costs nothing and keeps the
|
|
1116
1121
|
cursor, viewport, scrollbar and mouse arithmetic in one place.
|
|
1117
|
-
- *A `glyphs=` knob for `(•)`:* `
|
|
1122
|
+
- *A `glyphs=` knob for `(•)`:* `D_ambiguous_width` blesses an opt-in knob but
|
|
1118
1123
|
doesn't demand one, and `Checkbox`/`CheckboxGroup` both ship literals. Adding
|
|
1119
1124
|
it here alone would create symmetry pressure for a third. Ship `(*)`/`( )`;
|
|
1120
1125
|
add the knob to all three the day someone wants the bullet.
|
|
1121
1126
|
- *A shared base with `CheckboxGroup`:* declined for the third time (see
|
|
1122
|
-
`
|
|
1127
|
+
`D_checkbox_group`). The two differ in exactly one line — `Set` membership vs
|
|
1123
1128
|
`==` — and the `cop` duplicate-rather-than-fold rule covers the rest.
|
|
1124
1129
|
|
|
1125
1130
|
**Consequences.** Space on the already-selected row is a no-op, not a deselect:
|
|
@@ -1129,10 +1134,10 @@ selection and *both* rows render `(*)`, while two distinct items sharing a label
|
|
|
1129
1134
|
stay independent (a row resolves to an item by index). The sampler pane reports
|
|
1130
1135
|
value and cursor side by side, which is the cheapest way to see the split.
|
|
1131
1136
|
|
|
1132
|
-
##
|
|
1137
|
+
## D_text_field_axes — `TextField`: two axes, horizontal scrolling, a logical cap (2026-07-31)
|
|
1133
1138
|
|
|
1134
1139
|
**Status:** Accepted; `Component::TextField` rewritten 2026-07-31. Builds on
|
|
1135
|
-
`
|
|
1140
|
+
`D_ambiguous_width` (which already asserted that "every rect, caret column and
|
|
1136
1141
|
clip derives from `StyledString#display_width`" — a claim `TextField` was quietly
|
|
1137
1142
|
violating). Scoped to `TextField`; `TextArea` carries the same bug and is *not*
|
|
1138
1143
|
fixed here.
|
|
@@ -1184,8 +1189,8 @@ half-painted.
|
|
|
1184
1189
|
(`nil` by default), counted **in characters** — a wide glyph counts once — and it
|
|
1185
1190
|
gates *typing only*: at the cap a printable key does nothing and is still
|
|
1186
1191
|
consumed. It deliberately does not police `text=`, which stays authoritative as
|
|
1187
|
-
it is for `ComboBox#value` and `CheckboxGroup#value` (`
|
|
1188
|
-
`
|
|
1192
|
+
it is for `ComboBox#value` and `CheckboxGroup#value` (`D_combobox`,
|
|
1193
|
+
`D_checkbox_group`), so lowering the cap under an existing value leaves that
|
|
1189
1194
|
value intact instead of silently trimming it. A cap in *columns* was rejected: it
|
|
1190
1195
|
would make the maximum text depend on which characters were typed, which is
|
|
1191
1196
|
exactly the width-vs-length confusion this note removes.
|
|
@@ -1215,12 +1220,12 @@ or a long number is now reachable instead of rejected. `TextArea` is now the
|
|
|
1215
1220
|
only component still conflating the axes — its wrap computation measures
|
|
1216
1221
|
characters against a column width, so CJK prose overflows every row.
|
|
1217
1222
|
|
|
1218
|
-
##
|
|
1223
|
+
## D_text_area_columns — `TextArea`: a cluster-iterating wrap over two axes (2026-07-31)
|
|
1219
1224
|
|
|
1220
1225
|
**Status:** Accepted; `Component::TextArea` wrap rewritten 2026-07-31. The second
|
|
1221
|
-
half of `
|
|
1226
|
+
half of `D_text_field_axes`, which fixed `TextField` and recorded this as open.
|
|
1222
1227
|
Deliberately does **not** touch how the caret *steps* — that is
|
|
1223
|
-
`
|
|
1228
|
+
`D_cluster_caret`.
|
|
1224
1229
|
|
|
1225
1230
|
**Context.** `compute_display_rows` filled each row by counting **characters**
|
|
1226
1231
|
against `rect.width`, a **column** budget. So CJK prose wrapped at roughly twice
|
|
@@ -1285,17 +1290,17 @@ not just a bug fix, and it matches every editor.
|
|
|
1285
1290
|
integer per row for a conversion on every mutation.
|
|
1286
1291
|
|
|
1287
1292
|
**Consequences.** A row's `start` and `length` stay **character** counts, and
|
|
1288
|
-
`
|
|
1293
|
+
`D_cluster_caret` kept them that way — boundary-locking the caret needed no
|
|
1289
1294
|
change here at all, precisely because this wrap is already cluster-iterating and
|
|
1290
1295
|
`chars_for_column` / `caret_to_display` already return boundary-aligned counts.
|
|
1291
1296
|
The cluster-**width** question this entry left open was closed separately by
|
|
1292
|
-
`
|
|
1297
|
+
`D_cluster_width`.
|
|
1293
1298
|
|
|
1294
|
-
##
|
|
1299
|
+
## D_cluster_width — Emoji width policy `:rgi`; a cluster may exceed two columns (2026-07-31)
|
|
1295
1300
|
|
|
1296
1301
|
**Status:** Accepted; implemented 2026-07-31. Completes the width story begun in
|
|
1297
|
-
`
|
|
1298
|
-
`
|
|
1302
|
+
`D_ambiguous_width` and continued through `D_text_field_axes` /
|
|
1303
|
+
`D_text_area_columns`, which fixed *where* widths were measured while this fixes
|
|
1299
1304
|
*what a width is*.
|
|
1300
1305
|
|
|
1301
1306
|
**Context.** Two independent bugs, both about the grapheme cluster as the unit a
|
|
@@ -1333,7 +1338,7 @@ cosmetics. `:rgi` is the only setting never wrong in the corrupting direction
|
|
|
1333
1338
|
for a sequence it is exact when the terminal draws the parts and over-measures
|
|
1334
1339
|
when the terminal combines them, and it treats VS16 emoji presentation as 2.
|
|
1335
1340
|
|
|
1336
|
-
Note this bets the *opposite* way from `
|
|
1341
|
+
Note this bets the *opposite* way from `D_ambiguous_width`, deliberately. That
|
|
1337
1342
|
note bets narrow because the glyphs at stake are Tuile's **own chrome** — box
|
|
1338
1343
|
drawing, the scrollbar block — which the framework controls and needs at one
|
|
1339
1344
|
column. Here the glyphs are **app content**, where the framework controls
|
|
@@ -1370,7 +1375,7 @@ rather than assumed.
|
|
|
1370
1375
|
Rejected: it makes layout arithmetic non-reproducible across machines and
|
|
1371
1376
|
makes the spec suite depend on whoever's `$TERM_PROGRAM` runs it — and Tuile's
|
|
1372
1377
|
whole width strategy is one global answer with a small, enumerable inventory
|
|
1373
|
-
(`
|
|
1378
|
+
(`D_ambiguous_width`). An app that needs its terminal's exact answer is better
|
|
1374
1379
|
served by a future explicit override than by ambient detection.
|
|
1375
1380
|
- **Clamp any cluster to 2 columns.** Would have avoided touching `put_char`,
|
|
1376
1381
|
and is simply wrong for a non-RGI sequence the terminal really does draw
|
|
@@ -1384,15 +1389,15 @@ of its parts to 2, so any app that hard-coded the old number will disagree.
|
|
|
1384
1389
|
return *fewer* columns than asked when a wide glyph straddles the boundary — it
|
|
1385
1390
|
drops the glyph rather than halving it, as it already did for CJK. Unaffected: a
|
|
1386
1391
|
cluster spanning two style spans takes the first span's style rather than being
|
|
1387
|
-
split. The caret stepped by character when this landed; `
|
|
1392
|
+
split. The caret stepped by character when this landed; `D_cluster_caret` fixed
|
|
1388
1393
|
that separately.
|
|
1389
1394
|
|
|
1390
1395
|
---
|
|
1391
1396
|
|
|
1392
|
-
##
|
|
1397
|
+
## D_screen_lifecycle — UI thread confinement, and three named screen states (2026-08-01)
|
|
1393
1398
|
|
|
1394
1399
|
**Status:** Accepted; implemented 2026-08-01. First step of the tree-first
|
|
1395
|
-
sequencing (`
|
|
1400
|
+
sequencing (`D_tree_first`), and independent of the rest of it.
|
|
1396
1401
|
|
|
1397
1402
|
**Context.** `Screen` carried a two-valued, unnamed state machine:
|
|
1398
1403
|
`@pretend_ui_lock = true` in `initialize`, flipped to `false` on
|
|
@@ -1469,7 +1474,7 @@ raised `ENOTTY`, masking the real error.
|
|
|
1469
1474
|
|
|
1470
1475
|
---
|
|
1471
1476
|
|
|
1472
|
-
##
|
|
1477
|
+
## D_tree_api — `@children` is authoritative; `add_child`/`remove_child` are the only path (2026-08-01)
|
|
1473
1478
|
|
|
1474
1479
|
**Status:** Accepted and implemented 2026-08-01. No `children` override
|
|
1475
1480
|
remains in `lib/`; the only `parent =` assignments left are the two inside
|
|
@@ -1478,7 +1483,7 @@ remains in `lib/`; the only `parent =` assignments left are the two inside
|
|
|
1478
1483
|
**Context.** Five call sites used to hand-wire `child.parent = …` alongside
|
|
1479
1484
|
their own child bookkeeping, each in its own order. That is where the
|
|
1480
1485
|
transient tree inconsistency and the focus-repair ordering accident came
|
|
1481
|
-
from (`
|
|
1486
|
+
from (`D_tree_first`), and it is what the attach/detach hooks would
|
|
1482
1487
|
have to fire *through*. Two shapes fix it, and they are not equivalent:
|
|
1483
1488
|
|
|
1484
1489
|
- **A** — `Component` owns an `@children` array; `children` is a plain
|
|
@@ -1546,10 +1551,10 @@ for B.
|
|
|
1546
1551
|
|
|
1547
1552
|
---
|
|
1548
1553
|
|
|
1549
|
-
##
|
|
1554
|
+
## D_attach_hooks — `on_attached` / `on_detached`: an edge trigger on the component (2026-08-01)
|
|
1550
1555
|
|
|
1551
1556
|
**Status:** Accepted and implemented 2026-08-01. Last step of the tree-first
|
|
1552
|
-
sequencing (`
|
|
1557
|
+
sequencing (`D_tree_first`); both `ideas/` notes it was designed in are retired.
|
|
1553
1558
|
|
|
1554
1559
|
**Context.** Tuile had two thirds of a tree lifecycle: `attached?` (a computed
|
|
1555
1560
|
predicate) and `on_child_removed` (a *container-side* notification used for
|
|
@@ -1606,7 +1611,7 @@ it must release in the mirror, because nothing else will.
|
|
|
1606
1611
|
that goes on serving other sessions — so a missed `onDetach` there leaks into
|
|
1607
1612
|
a *surviving* process and here it does not. That still holds, and it is why
|
|
1608
1613
|
teardown-detach was never *urgent*; what overrode it is that `attached?`
|
|
1609
|
-
became a type test (`
|
|
1614
|
+
became a type test (`D_tree_api`), so a tree rooted at a nilled `@pane` went
|
|
1610
1615
|
on claiming to be attached forever and touching it raised "Screen not
|
|
1611
1616
|
initialized". Firing is also just cheaper than explaining that. So
|
|
1612
1617
|
`Screen#close` now calls `ScreenPane#detach_all`.
|
|
@@ -1622,7 +1627,7 @@ it must release in the mirror, because nothing else will.
|
|
|
1622
1627
|
- **A generic `Component#remove_all_children`** as the unmount primitive.
|
|
1623
1628
|
Unsafe: a slot container calling it would empty `@children` while `#content`
|
|
1624
1629
|
/ `#footer` still pointed at detached components — exactly the desync
|
|
1625
|
-
`
|
|
1630
|
+
`D_tree_api` exists to prevent. Unmounting also has to clear the pane's own
|
|
1626
1631
|
slots, so it is not a generic tree operation. Named `detach_all` rather than
|
|
1627
1632
|
`close` because `Popup#close` already means "remove *me* from the pane".
|
|
1628
1633
|
|
|
@@ -1637,16 +1642,16 @@ being detached (repair runs after), and must not inspect the ex-parent's
|
|
|
1637
1642
|
bookkeeping. A raising hook propagates and leaves the tree undefined —
|
|
1638
1643
|
durably so on the detach path, where the container's remaining work is skipped.
|
|
1639
1644
|
Finally, hooks fire during `:idle` on the normal app path (a tree is assembled
|
|
1640
|
-
before `run_event_loop`), which `
|
|
1645
|
+
before `run_event_loop`), which `D_screen_lifecycle` made a decision rather
|
|
1641
1646
|
than an accident.
|
|
1642
1647
|
|
|
1643
1648
|
---
|
|
1644
1649
|
|
|
1645
|
-
##
|
|
1650
|
+
## D_tree_first — `Screen` is the service, `ScreenPane` is the UI (2026-08-01)
|
|
1646
1651
|
|
|
1647
1652
|
**Status:** Accepted and implemented 2026-08-01, in five steps
|
|
1648
|
-
(`
|
|
1649
|
-
`
|
|
1653
|
+
(`D_screen_lifecycle`, the one-axis `attached?`, `D_tree_api` in two parts,
|
|
1654
|
+
`D_attach_hooks`). The `ideas/` note it was designed in is retired.
|
|
1650
1655
|
|
|
1651
1656
|
**Context.** Designing two no-op lifecycle hooks
|
|
1652
1657
|
(`Component#on_attached` / `#on_detached`) took *ten* documented corner cases:
|
|
@@ -1670,8 +1675,8 @@ bookkeeping, each in its own order.
|
|
|
1670
1675
|
`attached?` became `root.is_a?(ScreenPane)`: one axis, no `Screen`
|
|
1671
1676
|
reference, so it never raises and a tree can be assembled with no screen in
|
|
1672
1677
|
the process.
|
|
1673
|
-
- **The tree API is final** (`
|
|
1674
|
-
through it — is the sole lifecycle firing site (`
|
|
1678
|
+
- **The tree API is final** (`D_tree_api`), and `parent=` — reachable only
|
|
1679
|
+
through it — is the sole lifecycle firing site (`D_attach_hooks`).
|
|
1675
1680
|
|
|
1676
1681
|
Deleting the second axis deleted six edges outright rather than documenting
|
|
1677
1682
|
them: the raise, the status-bar exception, the two-`@pane`-writes framing, the
|
|
@@ -1710,13 +1715,13 @@ means one screen, and the one-line change if that ever stops being true.
|
|
|
1710
1715
|
|
|
1711
1716
|
---
|
|
1712
1717
|
|
|
1713
|
-
##
|
|
1718
|
+
## D_color_slots — A component color slot, not a new chrome token (2026-08-01)
|
|
1714
1719
|
|
|
1715
1720
|
**Status:** Accepted; first applied by `Component::ProgressBar#bar_color`
|
|
1716
1721
|
(implemented 2026-08-02). Binds Slider and Badge when they land — the question
|
|
1717
1722
|
was cross-component from the start, so it is settled once here rather than
|
|
1718
|
-
re-argued per widget. Builds on `
|
|
1719
|
-
bg/fg token) and `
|
|
1723
|
+
re-argued per widget. Builds on `D_bg_inherit` (accents-only theme, no global
|
|
1724
|
+
bg/fg token) and `D_theme_ref` (the live-resolved slot machinery this reuses).
|
|
1720
1725
|
|
|
1721
1726
|
**Context.** {Theme} carries four chrome tokens — `active_bg_color`,
|
|
1722
1727
|
`active_border_color`, `input_bg_color`, `hint_color` — and a component
|
|
@@ -1781,17 +1786,17 @@ member is additive, removing one is not.
|
|
|
1781
1786
|
all. A `track_color` would have doubled the surface to weaken that.
|
|
1782
1787
|
- **A slot's `Ref` is validated eagerly** (KeyError at assignment, as
|
|
1783
1788
|
`bg_color=` does) and re-resolved at paint, never cached — same rules as
|
|
1784
|
-
`
|
|
1785
|
-
- **This licenses no global bg/fg token.** `
|
|
1789
|
+
`D_theme_ref`, including riding the invalidate-everything pass on `theme=`.
|
|
1790
|
+
- **This licenses no global bg/fg token.** `D_bg_inherit` stands: a slot's
|
|
1786
1791
|
`Ref` can only point at a color the theme *already* carries.
|
|
1787
1792
|
|
|
1788
1793
|
---
|
|
1789
1794
|
|
|
1790
|
-
##
|
|
1795
|
+
## D_progress_bar — A value that is not a field; no text on the bar (2026-08-01)
|
|
1791
1796
|
|
|
1792
1797
|
**Status:** Accepted; `Component::ProgressBar` implemented 2026-08-02, demoed in
|
|
1793
|
-
the sampler. Color is `
|
|
1794
|
-
the ticker rides `
|
|
1798
|
+
the sampler. Color is `D_color_slots`; the glyph pair rides `D_ambiguous_width`;
|
|
1799
|
+
the ticker rides `D_attach_hooks`. What this entry owns is the *shape*.
|
|
1795
1800
|
|
|
1796
1801
|
**Context.** The first component with a `value` that is emphatically **not** an
|
|
1797
1802
|
input: nothing focuses it, nothing types into it, and its number comes from the
|
|
@@ -1879,11 +1884,11 @@ paint over").
|
|
|
1879
1884
|
|
|
1880
1885
|
---
|
|
1881
1886
|
|
|
1882
|
-
##
|
|
1887
|
+
## D_cluster_caret — The caret is boundary-locked; edits step by cluster (2026-08-02)
|
|
1883
1888
|
|
|
1884
1889
|
**Status:** Accepted; implemented 2026-08-02 in `AbstractStringField`, so it
|
|
1885
1890
|
landed on `TextField`, `PasswordField` and `TextArea` at once. Closes the gap
|
|
1886
|
-
`
|
|
1891
|
+
`D_text_field_axes` / `D_text_area_columns` / `D_cluster_width` each recorded as
|
|
1887
1892
|
open.
|
|
1888
1893
|
|
|
1889
1894
|
**Context.** `@caret` indexed **codepoints** while the terminal draws **grapheme
|
|
@@ -1941,7 +1946,7 @@ makes the orphan bug unreachable.
|
|
|
1941
1946
|
lines. Three concrete costs: (1) **it moves the axis, so every
|
|
1942
1947
|
`caret = <something>.length` breaks silently** — five sites in `lib/` plus
|
|
1943
1948
|
`examples/sampler.rb`'s `area.caret = start + command.length + 1`, all correct
|
|
1944
|
-
for ASCII and wrong otherwise, which is the failure mode `
|
|
1949
|
+
for ASCII and wrong otherwise, which is the failure mode `D_text_field_axes`
|
|
1945
1950
|
deleted, relocated from the framework to its callers; it then forced an open
|
|
1946
1951
|
question about a loud rename migration purely to convert those silent breaks
|
|
1947
1952
|
into `NoMethodError`s. (2) `max_text_length` would silently change meaning,
|
|
@@ -1961,7 +1966,7 @@ makes the orphan bug unreachable.
|
|
|
1961
1966
|
repeated at every reader, and `caret=` already adjusts by clamping, so
|
|
1962
1967
|
snapping there is not a new kind of surprise.
|
|
1963
1968
|
- **Move `max_text_length` to counting clusters** alongside this. Deliberately
|
|
1964
|
-
not bundled: it stays character-counting and stays `
|
|
1969
|
+
not bundled: it stays character-counting and stays `D_text_field_axes`'s
|
|
1965
1970
|
decision. Now a knowing choice rather than an untouched default — a decomposed
|
|
1966
1971
|
`é` burns 2 of 10, and a field at its cap refuses an accent on its last letter
|
|
1967
1972
|
because `insert`'s check fires before the mark can merge.
|
|
@@ -1977,10 +1982,10 @@ an empty field, which is input validation, not an axis question.
|
|
|
1977
1982
|
|
|
1978
1983
|
---
|
|
1979
1984
|
|
|
1980
|
-
##
|
|
1985
|
+
## D_float_field — `FloatField`: named for its Ruby type, and a deliberate copy of `IntegerField` (2026-08-07)
|
|
1981
1986
|
|
|
1982
1987
|
**Status:** Accepted; implemented 2026-08-07 (`Component::FloatField`). The
|
|
1983
|
-
`Float` half of `
|
|
1988
|
+
`Float` half of `D_integer_field`'s "derived parse" case — same wrapper shape,
|
|
1984
1989
|
same taxonomy slot, so only what *differs* is recorded here.
|
|
1985
1990
|
|
|
1986
1991
|
**Context.** Vaadin calls this a *Number Field*; the survey in
|
|
@@ -2001,7 +2006,7 @@ category, not this field's value, and it would force the eventual sibling to be
|
|
|
2001
2006
|
~90% of their body (the `HasContent` shell, the `on_key` filter interceptor, the
|
|
2002
2007
|
`fire_if_changed` guard) and differ in exactly the three places that matter: the
|
|
2003
2008
|
filter, the parse, and the format. An `AbstractNumericField` with abstract
|
|
2004
|
-
`parse`/`format` hooks **is** the converter strategy `
|
|
2009
|
+
`parse`/`format` hooks **is** the converter strategy `D_integer_field` kept out,
|
|
2005
2010
|
reached through inheritance instead of a setter — and the `cop` rule is to
|
|
2006
2011
|
duplicate rather than fold a shallow commonality into a base. The duplication is
|
|
2007
2012
|
visible and boring; the base would be machinery.
|
|
@@ -2036,7 +2041,7 @@ spinner as `IntegerField`. A settable step is not free on a binary float:
|
|
|
2036
2041
|
stepping by `0.1` accumulates `0.30000000000000004` straight into the visible
|
|
2037
2042
|
buffer, so the knob would need a rounding policy (decimals? significant
|
|
2038
2043
|
digits?), and rounding is formatting — a forms concern, parked with `min`/`max`
|
|
2039
|
-
in `
|
|
2044
|
+
in `D_integer_field`.
|
|
2040
2045
|
|
|
2041
2046
|
**Alternatives rejected.**
|
|
2042
2047
|
- *`BigDecimal` as the value type:* correct for money, but it needs the
|
|
@@ -2051,14 +2056,14 @@ in `D-integer-field`.
|
|
|
2051
2056
|
|
|
2052
2057
|
---
|
|
2053
2058
|
|
|
2054
|
-
##
|
|
2059
|
+
## D_bigdecimal_field — `BigDecimalField`, and Tuile's first optional dependency (2026-08-07)
|
|
2055
2060
|
|
|
2056
2061
|
**Status:** Accepted; implemented 2026-08-07 (`Component::BigDecimalField`).
|
|
2057
|
-
The third numeric field, so it inherits `
|
|
2062
|
+
The third numeric field, so it inherits `D_float_field` wholesale (named for
|
|
2058
2063
|
its Ruby value type, a deliberate copy rather than a shared base) — only the
|
|
2059
2064
|
two things that are new are recorded here: exactness, and the packaging.
|
|
2060
2065
|
|
|
2061
|
-
**Context.** `
|
|
2066
|
+
**Context.** `D_float_field` closes with "the wrong field for money — hold that
|
|
2062
2067
|
as `Integer` cents"; this is the field that makes the honest answer available.
|
|
2063
2068
|
`BigDecimal`, though, is not a language built-in: it was a *default* gem
|
|
2064
2069
|
through Ruby 3.3 and became a **bundled** gem in 3.4, so from 3.4 on a Bundler
|
|
@@ -2124,16 +2129,16 @@ through the value seam: `"1.0"`→`"1.00"` fires nothing, because the two
|
|
|
2124
2129
|
wanted, it belongs at the call site, where it is visible.
|
|
2125
2130
|
- *A `scale=` / `decimals=` knob to pad the display:* it would have to rewrite
|
|
2126
2131
|
the buffer under the caret while typing (`19.9` → `19.90` mid-edit), which
|
|
2127
|
-
needs a blur/commit point a TUI lacks — the same reason `
|
|
2132
|
+
needs a blur/commit point a TUI lacks — the same reason `D_integer_field`
|
|
2128
2133
|
gave for not normalizing.
|
|
2129
|
-
- *A settable `step=`:* `
|
|
2134
|
+
- *A settable `step=`:* `D_float_field` rejected it over binary-float noise,
|
|
2130
2135
|
which genuinely doesn't apply here (`BigDecimal` steps exactly). Kept out
|
|
2131
2136
|
anyway, so the three numeric fields stay one shape; this is the field to
|
|
2132
2137
|
revisit first if the knob is ever wanted.
|
|
2133
2138
|
|
|
2134
2139
|
---
|
|
2135
2140
|
|
|
2136
|
-
##
|
|
2141
|
+
## D_box_layouts — `Vertical` / `Horizontal`: declarative sugar with no `Auto` (2026-08-07)
|
|
2137
2142
|
|
|
2138
2143
|
**Status:** Accepted; implemented 2026-08-07 (`Component::Layout::Box`,
|
|
2139
2144
|
`::Vertical`, `::Horizontal`, and the `Fixed` / `Percent` / `Expand` / `Insets`
|
|
@@ -2150,7 +2155,7 @@ PasswordField pane alone — renumbered by hand whenever a prompt gained a line)
|
|
|
2150
2155
|
plus hand-rolled expansion (`[inner.height - 8, 2].max`) and hand-rolled cross
|
|
2151
2156
|
clamps (`[inner.width, 30].min`). The cost was not that hand-rolling is
|
|
2152
2157
|
impossible but that the code newcomers read to *learn* Tuile demonstrated the
|
|
2153
|
-
tedious version. The port took the sampler to
|
|
2158
|
+
tedious version. The port took the sampler to a handful of `Rect.new` (5 today).
|
|
2154
2159
|
|
|
2155
2160
|
**Decision — the vocabulary is `Fixed` / `Percent` / `Expand`, and there is no
|
|
2156
2161
|
`Auto`.** Shrink-to-fit is the bottom-up `content_size` channel deleted in
|
|
@@ -2229,7 +2234,7 @@ override, so both class methods carry the guard (found by the spec, not by
|
|
|
2229
2234
|
reading).
|
|
2230
2235
|
|
|
2231
2236
|
**Decision — `Box` is a shared base, against the duplicate-don't-DRY rule.**
|
|
2232
|
-
`
|
|
2237
|
+
`D_float_field` says duplicate rather than fold a *shallow* commonality into a
|
|
2233
2238
|
base. This isn't shallow: the greedy pass is substantial and byte-for-byte
|
|
2234
2239
|
identical except for which of `(left, top)` / `(width, height)` it reads, so
|
|
2235
2240
|
`Box` parameterizes it behind two private hooks and `Vertical` / `Horizontal`
|
|
@@ -2269,11 +2274,10 @@ are ~10-line concretes. That is the sanctioned cohesive base
|
|
|
2269
2274
|
is what a sidebar wants. Vaadin 8's ratio and CSS `flex-grow` both chose
|
|
2270
2275
|
weights.
|
|
2271
2276
|
- *`Min` / `Max` constraints:* deliberately not shipped, and the sampler shows
|
|
2272
|
-
what that costs — its
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
capped proportions turn out to be common.
|
|
2277
|
+
what that costs — its two sidebars (`min(16, width / 3)`) are caps on a
|
|
2278
|
+
*proportion*, unsayable in three constraints, so they keep a rect-callback
|
|
2279
|
+
`Absolute`. That is the intended division of labour: only the part needing
|
|
2280
|
+
arithmetic has any. Revisit only if capped proportions turn out to be common.
|
|
2277
2281
|
- *`BorderLayout` / `BorderPane` / Textual's `dock:`:* nesting
|
|
2278
2282
|
`Vertical(Fixed, Expand, Fixed)` covers it, and `ScreenPane` (content + status
|
|
2279
2283
|
bar) already *is* one, hard-coded.
|
|
@@ -2309,7 +2313,7 @@ are ~10-line concretes. That is the sanctioned cohesive base
|
|
|
2309
2313
|
|
|
2310
2314
|
---
|
|
2311
2315
|
|
|
2312
|
-
##
|
|
2316
|
+
## D_wrap_leading_space — An indent is content; no flag, and no hanging indent (2026-08-12)
|
|
2313
2317
|
|
|
2314
2318
|
**Status:** Accepted; implemented 2026-08-12 (`StyledString#wrap_one`). Fixes
|
|
2315
2319
|
[issue #2](https://github.com/mvysny/tuile/issues/2). The continuation half —
|
|
@@ -2378,17 +2382,17 @@ so it is a no-op there, and the indented case is the only one with an opinion.
|
|
|
2378
2382
|
|
|
2379
2383
|
---
|
|
2380
2384
|
|
|
2381
|
-
##
|
|
2385
|
+
## D_select — `Select`: the enum field, claiming no printable key but Space (2026-08-12)
|
|
2382
2386
|
|
|
2383
2387
|
**Status:** Accepted; `Component::Select` implemented 2026-08-12, demoed in the
|
|
2384
|
-
sampler. Builds on `
|
|
2385
|
-
resolve-don't-store-an-index rule, both adopted verbatim), `
|
|
2386
|
-
cursor-is-chrome rule) and `
|
|
2388
|
+
sampler. Builds on `D_has_value`, `D_combobox` (the chrome/value split and the
|
|
2389
|
+
resolve-don't-store-an-index rule, both adopted verbatim), `D_radio_group` (the
|
|
2390
|
+
cursor-is-chrome rule) and `D_ambiguous_width`.
|
|
2387
2391
|
|
|
2388
2392
|
**Context.** A one-row closed-choice field: a face showing the selected item's
|
|
2389
|
-
label plus a `▾`, dropping open a `ListDropdown` of the options. `
|
|
2393
|
+
label plus a `▾`, dropping open a `ListDropdown` of the options. `D_combobox`
|
|
2390
2394
|
deferred it once ("filterable first"), on the assumption that it needed the
|
|
2391
|
-
read-only-field axis `
|
|
2395
|
+
read-only-field axis `D_has_value` parked for the forms layer. That assumption
|
|
2392
2396
|
was an artifact of picturing a read-only `TextField` as the face; nothing gates
|
|
2393
2397
|
this component.
|
|
2394
2398
|
|
|
@@ -2449,18 +2453,18 @@ dropdown as an overlay. Two consequences worth naming:
|
|
|
2449
2453
|
having the child removes the wrinkle instead of documenting it.
|
|
2450
2454
|
|
|
2451
2455
|
**Decision — promote `ComboBox#anchor` to `ListDropdown#anchor_to`.** Select needs
|
|
2452
|
-
byte-identical vertical geometry, and `
|
|
2456
|
+
byte-identical vertical geometry, and `D_float_field`'s duplicate-don't-DRY rule
|
|
2453
2457
|
**does not apply**: that licensed copying a *shell* around three genuine
|
|
2454
2458
|
differences, whereas this is the same computation with zero differences, so a
|
|
2455
2459
|
later fix to the flip rule would land in one copy and silently not the other —
|
|
2456
2460
|
and the symptom appears only near a screen edge, which is invisible under test.
|
|
2457
|
-
The promotion threshold is the project's existing one (`
|
|
2461
|
+
The promotion threshold is the project's existing one (`D_color_slots`: "a
|
|
2458
2462
|
*second* built-in needing the same thing"). Two rulings ride along:
|
|
2459
2463
|
|
|
2460
2464
|
- **Width stays a caller-supplied parameter** (defaulting to the anchor's), so
|
|
2461
2465
|
`ComboBox` keeps its lines-up-with-the-field policy and Select keeps its
|
|
2462
2466
|
measured one, and `anchor_to` never measures content itself. Same shape as
|
|
2463
|
-
`
|
|
2467
|
+
`D_box_layouts`' "`align:` is legal only because the cross extent is
|
|
2464
2468
|
caller-supplied", and it keeps Select's measuring within the top-down re-grow
|
|
2465
2469
|
rule: an optional, caller-side query feeding a rect the caller then assigns.
|
|
2466
2470
|
- **Horizontally we slide, vertically we flip.** Covering the driver would hide
|
|
@@ -2508,7 +2512,7 @@ two callers that already know the answer.
|
|
|
2508
2512
|
discover them one at a time by cycling, with no way to see the set or know how
|
|
2509
2513
|
many there are. The dropdown is better at every item count, so the
|
|
2510
2514
|
`ListDropdown` face is the only face, and the vocabulary does not grow a fourth
|
|
2511
|
-
closed-choice widget (cf. `
|
|
2515
|
+
closed-choice widget (cf. `D_box_layouts`' "there is no `Auto`"). *Re-grow
|
|
2512
2516
|
rule:* if it returns it is a **face** on this component (a `dropdown: false`
|
|
2513
2517
|
knob over the identical value seam), never a separate component, and it needs a
|
|
2514
2518
|
real argument about visibility rather than a row-budget one.
|
|
@@ -2519,7 +2523,7 @@ two callers that already know the answer.
|
|
|
2519
2523
|
one row itself.
|
|
2520
2524
|
- *A shared base with `RadioGroup`* (`AbstractClosedChoiceField`): the ~15-line
|
|
2521
2525
|
`items=` / `item_label=` / `label_for` shell is duplicated instead, per
|
|
2522
|
-
`
|
|
2526
|
+
`D_float_field`. The test is whether the commonality is a *shell around genuine
|
|
2523
2527
|
differences* or the *same computation* — `anchor_to` is the latter (extract), the
|
|
2524
2528
|
items shell is the former (duplicate). The three differences a base would have
|
|
2525
2529
|
to paper over with hooks: row rendering (`(*) label` glyphs vs. a bare label,
|
|
@@ -2527,7 +2531,7 @@ two callers that already know the answer.
|
|
|
2527
2531
|
Space commits the row it's on, vs. the highlight *being* the pending selection),
|
|
2528
2532
|
and where the rows live (always, in the component's own rect, vs. only while
|
|
2529
2533
|
open, in a `Popup`'s). Three hooks over fifteen lines, reached through
|
|
2530
|
-
inheritance, is the converter-strategy-by-inheritance shape `
|
|
2534
|
+
inheritance, is the converter-strategy-by-inheritance shape `D_float_field`
|
|
2531
2535
|
rejected — and it would couple two widgets that should stay free to diverge.
|
|
2532
2536
|
This is the third copy of that shell, the same count `IntegerField` /
|
|
2533
2537
|
`FloatField` / `BigDecimalField` reached; a *fourth* is when to re-argue it.
|
|
@@ -2545,7 +2549,7 @@ two callers that already know the answer.
|
|
|
2545
2549
|
attempt is keystroke-driven, so it would flood a host's log on autorepeat, and
|
|
2546
2550
|
an app may legitimately pass through item-less while loading. Enter/Space/Down
|
|
2547
2551
|
are claimed either way — one rule, no branch. (An item-less Select is arguably
|
|
2548
|
-
a *disabled* field, which touches the read-only/disabled axis `
|
|
2552
|
+
a *disabled* field, which touches the read-only/disabled axis `D_has_value`
|
|
2549
2553
|
parked for the forms layer. Not designed here, not foreclosed either.)
|
|
2550
2554
|
- The dropdown is measured to the widest label plus `List`'s **two** row gutters
|
|
2551
2555
|
(`pad_to_row` ellipsizes to `content_width - 2`, one leading and one trailing
|
|
@@ -2565,13 +2569,13 @@ two callers that already know the answer.
|
|
|
2565
2569
|
`ComboBox#active=` leans on, and filtering / row rendering / the commit action
|
|
2566
2570
|
really do vary.
|
|
2567
2571
|
|
|
2568
|
-
##
|
|
2572
|
+
## D_list_items — `List` takes items + a renderer, rendered lazily (2026-08-14)
|
|
2569
2573
|
|
|
2570
2574
|
**Status:** Accepted; implemented 2026-08-14, with the five composers folded onto
|
|
2571
|
-
it in the same series. Builds on `
|
|
2572
|
-
`
|
|
2575
|
+
it in the same series. Builds on `D_has_value` (typed, not stringly),
|
|
2576
|
+
`D_combobox` (resolve an index, never store one), `D_float_field` (duplicate
|
|
2573
2577
|
rather than fold a shallow commonality) and the top-down layout rule
|
|
2574
|
-
(`
|
|
2578
|
+
(`D_box_layouts`). Delivers the first half of the "typed items + data provider on
|
|
2575
2579
|
`List`" item that gated List Box, Grid and Virtual List.
|
|
2576
2580
|
|
|
2577
2581
|
**Context.** `List` took pre-rendered rows: `lines=` stored `Array<StyledString>`
|
|
@@ -2583,7 +2587,7 @@ seam:
|
|
|
2583
2587
|
because the framework handed back a string.
|
|
2584
2588
|
- Four components (`ComboBox`, `Select`, `RadioGroup`, `CheckboxGroup`) kept a
|
|
2585
2589
|
private copy of the `@items` / `@item_label` / `label_for` / `rebuild_rows`
|
|
2586
|
-
shell. `
|
|
2590
|
+
shell. `D_select` set the trigger for re-arguing a shared base at the *fourth*
|
|
2587
2591
|
copy; this is it.
|
|
2588
2592
|
|
|
2589
2593
|
**Decision — externalize rendering on the generic component.** `List` holds
|
|
@@ -2594,7 +2598,7 @@ follows elsewhere — a domain component takes data, a generic one takes strateg
|
|
|
2594
2598
|
|
|
2595
2599
|
**Not a shared base class.** The alternative reading of four duplicated shells is
|
|
2596
2600
|
"extract `AbstractItemsComponent`". That is exactly the `parse`/`format`-hook base
|
|
2597
|
-
`
|
|
2601
|
+
`D_float_field` rejected, one level up: it would need a render hook, a
|
|
2598
2602
|
commit-gesture hook and a where-do-rows-live hook to span a dropdown driver and a
|
|
2599
2603
|
row-per-item group. The duplication was a symptom of a missing *seam*, not of a
|
|
2600
2604
|
missing *ancestor*, and adding the seam deleted the duplication that actually
|
|
@@ -2704,12 +2708,12 @@ having `value=` rebuild every row is the O(n) pass this decision just deleted.
|
|
|
2704
2708
|
rendering — is the shape that makes the bug unsayable, and the PTY test now
|
|
2705
2709
|
presses Enter.
|
|
2706
2710
|
|
|
2707
|
-
##
|
|
2711
|
+
## D_scroll_nomenclature — `row` is the grid, `line` is `\n`, `items` are domain objects (2026-08-14)
|
|
2708
2712
|
|
|
2709
|
-
**Status:** Accepted; implemented 2026-08-14. Builds on `
|
|
2710
|
-
made the item vocabulary real), `
|
|
2713
|
+
**Status:** Accepted; implemented 2026-08-14. Builds on `D_list_items` (which
|
|
2714
|
+
made the item vocabulary real), `D_text_area_columns` and `D_text_field_axes`
|
|
2711
2715
|
(which named the index-vs-column axes inside the inputs) and
|
|
2712
|
-
`
|
|
2716
|
+
`D_ambiguous_width` (whose `display_width` is the column authority).
|
|
2713
2717
|
|
|
2714
2718
|
**Context.** Three scrolling components had grown three vocabularies for the
|
|
2715
2719
|
same four concepts — a content unit, a wrapped unit, a viewport-relative row,
|
|
@@ -2787,7 +2791,7 @@ which this scheme makes unwriteable — `line` is never a coordinate.
|
|
|
2787
2791
|
stop using row-words loosely. Naming no row is not an exception to "row
|
|
2788
2792
|
everywhere".
|
|
2789
2793
|
- **`List#items` → `List#rows`,** which a List item arguably is. Rejected:
|
|
2790
|
-
`items` is where `cop` wants the domain-object noun (`
|
|
2794
|
+
`items` is where `cop` wants the domain-object noun (`D_list_items` had just
|
|
2791
2795
|
landed it), and it is the word the enum widgets above `List` already use.
|
|
2792
2796
|
- **`scroll_top`** (CSS's `scrollTop`, shorter). Rejected for `scroll_top_row`:
|
|
2793
2797
|
it names no unit, and `list.scroll_top` reads as an imperative — *scroll to
|
|
@@ -2815,7 +2819,7 @@ which this scheme makes unwriteable — `line` is never a coordinate.
|
|
|
2815
2819
|
wrong.
|
|
2816
2820
|
- **`row_count` was reserved here, then created separately.** Making it a public
|
|
2817
2821
|
reader was held to be a behavioural addition needing its own argument; that
|
|
2818
|
-
argument is `
|
|
2822
|
+
argument is `D_text_area_rows`, which granted it on `TextArea` only. The point
|
|
2819
2823
|
this entry settled — that the *name* is already taken, so the addition need not
|
|
2820
2824
|
re-litigate its spelling — held.
|
|
2821
2825
|
- **`CHANGELOG.md` was not swept.** Its 0.4.0 entry announcing the `set_line` /
|
|
@@ -2823,10 +2827,10 @@ which this scheme makes unwriteable — `line` is never a coordinate.
|
|
|
2823
2827
|
and describes what shipped *then*, so retro-editing it would make a released
|
|
2824
2828
|
migration note reference a method that release did not have.
|
|
2825
2829
|
|
|
2826
|
-
##
|
|
2830
|
+
## D_text_area_rows — `TextArea#caret_row` / `#row_count`, not a hook and not the wrap (2026-08-15)
|
|
2827
2831
|
|
|
2828
2832
|
**Status:** Accepted; implemented 2026-08-15. Grants the reader
|
|
2829
|
-
`
|
|
2833
|
+
`D_scroll_nomenclature` reserved the name for. Answers
|
|
2830
2834
|
[#3](https://github.com/mvysny/tuile/issues/3).
|
|
2831
2835
|
|
|
2832
2836
|
**Context.** Shell-style prompt-history recall in a `TextArea`: Up recalls the
|
|
@@ -2866,7 +2870,7 @@ the auto-growing prompt strip — the case the name was reserved for — uses
|
|
|
2866
2870
|
0.9.0 layout re-grow rule explicitly sanctions the opposite — capability
|
|
2867
2871
|
returning as "an *optional, read-only, caller-side query* … never as an
|
|
2868
2872
|
automatic channel the framework consults" — which is also why
|
|
2869
|
-
`
|
|
2873
|
+
`D_scroll_nomenclature` rejected a general `Component` scroll seam. It serves
|
|
2870
2874
|
one question, in one direction, at one moment, where the readers also serve the
|
|
2871
2875
|
prompt strip, a "row 3/7" readout and a caller-drawn scrollbar. And it needs a
|
|
2872
2876
|
subclass, where the readers serve `on_key` too. In COP terms it is neither a
|
|
@@ -2898,7 +2902,7 @@ the auto-growing prompt strip — the case the name was reserved for — uses
|
|
|
2898
2902
|
responsible for keeping them consistent. **(4) It flips a written invariant for
|
|
2899
2903
|
no argued caller** — AGENTS.md holds the class private "until a second caller
|
|
2900
2904
|
actually exists", and nobody has asked for `row_text` / `index_at` from
|
|
2901
|
-
outside. Forwarders grow on demand at one line each; `
|
|
2905
|
+
outside. Forwarders grow on demand at one line each; `D_float_field`'s
|
|
2902
2906
|
temperament ("a fourth copy is when to re-argue it") applies.
|
|
2903
2907
|
- **A `wrapped_text` method documented "do not store".** Same staleness, renamed.
|
|
2904
2908
|
- **A validity token on `WrappedText`,** so a holder can detect a stale snapshot.
|
|
@@ -2922,15 +2926,17 @@ the auto-growing prompt strip — the case the name was reserved for — uses
|
|
|
2922
2926
|
which is the whole reason the object stays private. Specs pin that both track a
|
|
2923
2927
|
text change and a width change.
|
|
2924
2928
|
|
|
2925
|
-
##
|
|
2929
|
+
## D_text_view_scroll_verbs — `TextView#scroll_half_page_up` / `#scroll_half_page_down` (2026-08-15)
|
|
2926
2930
|
|
|
2927
|
-
**Status:** Accepted; implemented 2026-08-15.
|
|
2931
|
+
**Status:** Accepted; implemented 2026-08-15. Amended 2026-08-23: the
|
|
2932
|
+
`active?` guard this was originally argued *from* turned out to be dead code
|
|
2933
|
+
(see the correction at the end) — the decision stands on its other grounds.
|
|
2928
2934
|
|
|
2929
2935
|
**Context.** A chat TUI keeps focus in the input field beneath its transcript,
|
|
2930
|
-
so the transcript's own scroll keys never fire:
|
|
2931
|
-
|
|
2932
|
-
*prompt* to page the *view*, half a screen at a time so the reader keeps
|
|
2933
|
-
overlap while output streams in. `TextView` already knows how to do exactly
|
|
2936
|
+
so the transcript's own scroll keys never fire: dispatch delivers a key along
|
|
2937
|
+
the focus chain only, and the view is not on it. The host wants PageUp/PageDown
|
|
2938
|
+
at the *prompt* to page the *view*, half a screen at a time so the reader keeps
|
|
2939
|
+
an overlap while output streams in. `TextView` already knows how to do exactly
|
|
2934
2940
|
that — `Ctrl+U` / `Ctrl+D` have scrolled by half a viewport since the scroll
|
|
2935
2941
|
ladder landed — but every clamped primitive behind those bindings
|
|
2936
2942
|
(`move_scroll_top_row_by`, `move_scroll_top_row_to`, `viewport_rows`,
|
|
@@ -2952,8 +2958,11 @@ it never learns the row count, never clamps, and never touches focus.
|
|
|
2952
2958
|
that wants it, where the two spellings drift. `TERMINOLOGY.md` also pins
|
|
2953
2959
|
`viewport_rows` private on purpose — `rect.height` is its public form.
|
|
2954
2960
|
- **Let the host forward a synthetic key** (`view.handle_key(Keys::CTRL_U)`).
|
|
2955
|
-
|
|
2956
|
-
|
|
2961
|
+
A keystroke aimed at an unfocused widget is a lie about where focus is: the
|
|
2962
|
+
host's question is "scroll this view", and spelling it as a key makes the
|
|
2963
|
+
view's key bindings part of its API — rename `Ctrl+U` and the caller breaks.
|
|
2964
|
+
(At the time this was also *dead on arrival*, the guard rejecting it; that
|
|
2965
|
+
guard is gone and the forward would now work. It is still the wrong spelling.)
|
|
2957
2966
|
- **App-side arithmetic on the existing public `scroll_top_row=`.** It raises
|
|
2958
2967
|
below `0` and is deliberately *not* clamped above, so a caller who overshoots
|
|
2959
2968
|
the last row leaves `at_bottom?` false and silently kills `auto_scroll`
|
|
@@ -2962,7 +2971,7 @@ it never learns the row count, never clamps, and never touches focus.
|
|
|
2962
2971
|
"Page" should page, it would break `Ctrl+U`/`Ctrl+D`'s reason to exist, and it
|
|
2963
2972
|
fixes nothing anyway: an unfocused view still sees no keys.
|
|
2964
2973
|
- **Ship the whole ladder as verbs** (full page, top, bottom, by-row). No caller
|
|
2965
|
-
yet; `
|
|
2974
|
+
yet; `D_text_area_rows`'s temperament applies — a future caller argues its own
|
|
2966
2975
|
case, and these two settle the spelling for the rest.
|
|
2967
2976
|
|
|
2968
2977
|
**Consequences.**
|
|
@@ -2979,10 +2988,26 @@ it never learns the row count, never clamps, and never touches focus.
|
|
|
2979
2988
|
paging back to the last row re-arms it. The host gets read-while-streaming for
|
|
2980
2989
|
free and has nothing to wire.
|
|
2981
2990
|
|
|
2982
|
-
|
|
2991
|
+
**The correction (2026-08-23).** `TextView#handle_key`'s opening
|
|
2992
|
+
`return false unless active?` was **vestigial**, and this entry took it for a
|
|
2993
|
+
live constraint. It was a leaf backstop for the one place the pre-0.8 framework
|
|
2994
|
+
over-delivered (`ScreenPane` forwarding to `content` unconditionally); e1777fe
|
|
2995
|
+
centralized dispatch and dropped the same guard from `TextInput`, `List` and
|
|
2996
|
+
`Button` — but `text_view.rb`, three weeks old at the time, was missed. It could
|
|
2997
|
+
never fire once removed from that context: `bubble_key` walks `Screen#focused`
|
|
2998
|
+
upward and `focused=` marks that chain `active`, and a `TextView` is a leaf, so
|
|
2999
|
+
the only chain position it can hold is `focused` itself. The stale
|
|
3000
|
+
`return true if super` above the `case` went with it — `Component#handle_key`
|
|
3001
|
+
has collapsed to `false` since the same commit. Both lines are deleted; the
|
|
3002
|
+
widget now obeys the framework-wide rule (AGENTS.md, book ch5) that a
|
|
3003
|
+
`handle_key` acts on the key alone. The *visible* change is that hand-feeding a
|
|
3004
|
+
key to an unfocused view now scrolls it, which is what every other widget in the
|
|
3005
|
+
gem already did (`examples/sampler.rb`'s unfocused `List` is the house idiom).
|
|
3006
|
+
|
|
3007
|
+
## D_notification — One corner toast, N messages, one ticker draining them (2026-08-17)
|
|
2983
3008
|
|
|
2984
3009
|
**Status:** Accepted and implemented, `Component::Notification`. Builds on
|
|
2985
|
-
`
|
|
3010
|
+
`D_attach_hooks` (the synced-from-an-invariant ticker), `D_color_slots` (the
|
|
2986
3011
|
per-message color), and Tier 1 of the component survey. Book ch7 "Notifications"
|
|
2987
3012
|
is the user-facing half; the rdoc owns the per-symbol contract. What this entry
|
|
2988
3013
|
owns is *why each choice*, and the alternatives that looked right first.
|
|
@@ -3074,7 +3099,7 @@ makes "at most one" true.
|
|
|
3074
3099
|
- Corollary for a future factory: `self.show` calls bare `new`, never
|
|
3075
3100
|
`Notification.new`, so a subclass's `show` builds the subclass.
|
|
3076
3101
|
- This widget is what surfaced `Popup.self.open` as a subclass trap (it had to be
|
|
3077
|
-
privatized here too, until the factory was deleted outright — `
|
|
3102
|
+
privatized here too, until the factory was deleted outright — `D_popup_open`).
|
|
3078
3103
|
|
|
3079
3104
|
### The singleton lives in the popups stack, never in a class ivar
|
|
3080
3105
|
|
|
@@ -3192,11 +3217,11 @@ second *kind* of anchoring that would unlock it (per the component survey), but
|
|
|
3192
3217
|
`Notification` ships its own `reposition` first so the extraction is judged with
|
|
3193
3218
|
two real implementations rather than one and a guess.
|
|
3194
3219
|
|
|
3195
|
-
##
|
|
3220
|
+
## D_popup_open — No class-level `Popup.open` factory; `#open` returns `self` (2026-08-17)
|
|
3196
3221
|
|
|
3197
3222
|
**Status:** Accepted and implemented; `Component::Popup.open` **removed**, and
|
|
3198
3223
|
`Popup#open` now returns `self`. Surfaced while building
|
|
3199
|
-
{Tuile::Component::Notification} (`
|
|
3224
|
+
{Tuile::Component::Notification} (`D_notification`), which had to privatize the
|
|
3200
3225
|
inherited factory to stop it undermining a private constructor.
|
|
3201
3226
|
|
|
3202
3227
|
**Context.** `Popup.open(content:, modal:, size:)` was one-line sugar for
|
|
@@ -3236,3 +3261,2162 @@ needs the popup *before* mounting it in order to wire `on_pick`.
|
|
|
3236
3261
|
own arguments, and wraps the popup rather than *being* one — none of them is an
|
|
3237
3262
|
inherited factory, so the trap does not apply. `popup_spec` asserts that neither
|
|
3238
3263
|
`Popup` nor `ListDropdown` responds to `open` at the class level.
|
|
3264
|
+
|
|
3265
|
+
## D_bracketed_paste — A paste is its own event, not a burst of keys (2026-08-23)
|
|
3266
|
+
|
|
3267
|
+
**Status:** Accepted and implemented in `Keys` (`BRACKETED_PASTE_ON`,
|
|
3268
|
+
`PASTE_START`, `read_paste`, `normalize_paste`), `EventQueue::PasteEvent`,
|
|
3269
|
+
`Screen#run_event_loop(bracketed_paste:)`, `ScreenPane#handle_paste`,
|
|
3270
|
+
`Component#handle_paste`, `AbstractStringField#handle_paste`, and
|
|
3271
|
+
`FakeScreen#paste`. Reported as
|
|
3272
|
+
[issue #4](https://github.com/mvysny/tuile/issues/4).
|
|
3273
|
+
|
|
3274
|
+
**Context — the two bytes are the same byte.** Pressing Return in raw mode sends
|
|
3275
|
+
`\r`. Pasting into a terminal that has *not* been told the app can tell a paste
|
|
3276
|
+
apart also sends `\r` for every clipboard line break: xterm, VTE and tmux all
|
|
3277
|
+
rewrite the selection's `\n` on the way out, deliberately, so that a paste looks
|
|
3278
|
+
exactly like typing (tmux's `paste-buffer -r` exists to opt out of it). So a
|
|
3279
|
+
{Tuile::Component::TextArea} subclass that rebinds ENTER to submit — the
|
|
3280
|
+
chat-prompt shape — submitted **once per pasted line**, and the first line was
|
|
3281
|
+
gone before the second arrived.
|
|
3282
|
+
|
|
3283
|
+
Nothing downstream can repair that. By the time `handle_key("\r")` runs, "the
|
|
3284
|
+
user pressed Enter" and "the clipboard held a line break" are the same event.
|
|
3285
|
+
The only downstream lever is inter-keystroke timing, which `D_select` already
|
|
3286
|
+
rejected for type-ahead on exactly this ground: a terminal degrades that signal
|
|
3287
|
+
(bytes in one read burst merge into a single key) and a paste has no gaps at all.
|
|
3288
|
+
The information exists only at the layer that talks to the terminal, which is
|
|
3289
|
+
Tuile's.
|
|
3290
|
+
|
|
3291
|
+
**Decision — drive DEC private mode 2004, on by default.** `run_event_loop`
|
|
3292
|
+
prints `\e[?2004h` alongside the mode-2031 notify and `\e[?2004l` in the same
|
|
3293
|
+
`ensure`, and takes `bracketed_paste: false` to opt out, mirroring
|
|
3294
|
+
`capture_mouse:`. Terminals that don't know the mode ignore the sequence, so
|
|
3295
|
+
there is no capability probe and nothing to detect — which is what makes
|
|
3296
|
+
defaulting it *on* safe rather than a gamble. The off switch exists for the same
|
|
3297
|
+
reason `capture_mouse: false` does: a terminal that mishandles the mode, and a
|
|
3298
|
+
one-flag escape beats a fork of the loop.
|
|
3299
|
+
|
|
3300
|
+
**Decision — the payload is read raw, not through `Keys.getkey`.** `getkey`
|
|
3301
|
+
returns `\e[200~` cleanly (its 5-byte tail gulp fits the marker exactly), but the
|
|
3302
|
+
*content* must not go back through it: a pasted `\e` would send it gulping five
|
|
3303
|
+
bytes of clipboard as an escape tail and surfacing them as phantom keypresses —
|
|
3304
|
+
the failure the `\e[M` and `\e[?` drains already exist to prevent. So
|
|
3305
|
+
`Keys.read_paste` reads **one byte at a time** to the `\e[201~` terminator.
|
|
3306
|
+
One byte at a time, and not a chunked read, because a chunk would over-read past
|
|
3307
|
+
the terminator and swallow whatever the user typed behind the paste; there is
|
|
3308
|
+
deliberately no pushback buffer in `Keys` to make chunking safe. A paste is
|
|
3309
|
+
human-scale and arrives once, so the syscall count is not worth a second
|
|
3310
|
+
mechanism.
|
|
3311
|
+
|
|
3312
|
+
**Decision — a `PasteEvent`, and it never touches the key ladder.** The key
|
|
3313
|
+
thread posts one event carrying the whole payload; `Screen#event_loop` routes it
|
|
3314
|
+
to `handle_paste` down the focus chain, with the same modal scoping as a key and
|
|
3315
|
+
no other rung. *Rejected: reusing `KeyEvent` with a flag*, which would put a
|
|
3316
|
+
`pasted?` predicate on the ladder and re-create the runtime gate `D_key_dispatch`
|
|
3317
|
+
deleted — every `handle_key` would have to remember to check it, and the ones
|
|
3318
|
+
that forgot would be exactly today's bug. *Rejected: replaying an unhandled paste
|
|
3319
|
+
as individual keys.* It reads like graceful degradation and is the ambiguity
|
|
3320
|
+
walking back in through the fallback: a component that declines a paste would
|
|
3321
|
+
still get eight ENTERs. Unhandled text is dropped.
|
|
3322
|
+
|
|
3323
|
+
**Decision — the field inserts it as one mutation.**
|
|
3324
|
+
`AbstractStringField#handle_paste` inserts at the caret in a single `text=`, so
|
|
3325
|
+
`on_change` fires once for the paste rather than once per character. That is what
|
|
3326
|
+
lets a submit-on-Enter subclass need *no* paste code at all — it keeps
|
|
3327
|
+
`handle_key` for the typed ENTER and inherits paste-inserts-text — and it
|
|
3328
|
+
incidentally retires an O(n) re-render and, for a slash-command overlay, an O(n)
|
|
3329
|
+
re-filter.
|
|
3330
|
+
|
|
3331
|
+
**Where each layer sanitizes, and why the line is there.** `Keys.normalize_paste`
|
|
3332
|
+
fixes only what is a *terminal* artifact: `\r`/`\r\n` → `\n` (terminals disagree
|
|
3333
|
+
about which they send inside the brackets — readline carries its own `\r`→`\n`
|
|
3334
|
+
pass for precisely that reason, so this cannot be left to the caller), and an
|
|
3335
|
+
invalid-UTF-8 scrub so a pasted binary file cannot make a downstream
|
|
3336
|
+
grapheme-cluster walk raise. Control characters are *content* and survive that
|
|
3337
|
+
layer. What a **text buffer** may hold is the field's call:
|
|
3338
|
+
`AbstractStringField#preprocess_paste` drops the C0 controls (a raw `\e` or `\t`
|
|
3339
|
+
reaching {Tuile::Buffer} would move the real cursor mid-frame), keeps `\n`, and
|
|
3340
|
+
turns a tab into one space rather than inventing a tab width;
|
|
3341
|
+
`TextField#preprocess_paste` narrows further — newlines to spaces, since a
|
|
3342
|
+
one-row field holds no line break, and a trim to `max_text_length` rather than a
|
|
3343
|
+
rejection, because that is what typing the same characters would have done. An
|
|
3344
|
+
app wanting tab *expansion* or a `[Pasted 230 lines]` placeholder overrides
|
|
3345
|
+
`handle_paste`, which is the seam that exists for it.
|
|
3346
|
+
|
|
3347
|
+
**Testing is three layers, because no one of them covers the others.**
|
|
3348
|
+
`FakeScreen#paste` (normalize + dispatch) is the unit door and starts one layer
|
|
3349
|
+
above the terminal; the sampler's *Paste* pane is the visual demo; and one PTY
|
|
3350
|
+
example in `spec/examples/sampler_spec.rb` is the only place mode 2004, the
|
|
3351
|
+
marker recognition and `read_paste` run for real. That PTY test writes the whole
|
|
3352
|
+
`\e[200~…\e[201~` sequence as **one burst**, which is the one place AGENTS.md's
|
|
3353
|
+
pace-the-keys rule is deliberately inverted: a real paste *is* a gapless burst,
|
|
3354
|
+
and the payload is drained raw, so nothing in it can be mistaken for a key. Its
|
|
3355
|
+
assertions read newly painted log rows rather than the counter row, because the
|
|
3356
|
+
buffer flushes the minimal diff — `rows in draft: 1` becoming `…: 3` puts one
|
|
3357
|
+
character on the wire, not the phrase.
|
|
3358
|
+
|
|
3359
|
+
**Corrected while here.** `TextArea`'s rdoc claimed a pasted line break arrived
|
|
3360
|
+
as `\n` and a typed one as `\r`, which is backwards and read as though multi-line
|
|
3361
|
+
paste already worked. Accepting {Keys::CTRL_J} is still right, but its
|
|
3362
|
+
justification is now the honest one: that is the byte a *typed* Ctrl+J sends.
|
|
3363
|
+
|
|
3364
|
+
## D_repaint_cascade — the repaint cascade skips the clear, never the invalidate (2026-08-23)
|
|
3365
|
+
|
|
3366
|
+
**Status:** Accepted and implemented in {Tuile::Component#repaint}. Found while
|
|
3367
|
+
building {Tuile::Component::TabSheet}, but the bug predates it and was already
|
|
3368
|
+
visible in three shipped sampler panes.
|
|
3369
|
+
|
|
3370
|
+
**Context — the symptom.** Focus the sampler's *TabSheet* pane and press Tab to
|
|
3371
|
+
put focus on the strip: the pane below it vanishes. It is still there — Tab once
|
|
3372
|
+
more and it comes back — so nothing was detached; the cells were simply blanked
|
|
3373
|
+
and never repainted. The same fault, less dramatically, blanked four rows of the
|
|
3374
|
+
*Checkbox*, *CheckboxGroup* and *RadioGroup* panes whenever focus moved into
|
|
3375
|
+
them. A sweep comparing each pane's incremental repaint against a
|
|
3376
|
+
repaint-everything baseline is what found the other three.
|
|
3377
|
+
|
|
3378
|
+
**The mechanism, in one chain.** A focus change invalidates every component
|
|
3379
|
+
whose `active?` flipped — i.e. the whole new focus chain. One of those is a
|
|
3380
|
+
`Layout::Vertical(spacing: 1)`, whose children leave gaps, so the default
|
|
3381
|
+
{Tuile::Component#repaint} runs `clear_background` over **its whole rect** —
|
|
3382
|
+
which is every descendant's cells, not just the gaps — and then re-invalidates
|
|
3383
|
+
its *direct children*. That notice then has to travel the rest of the way down,
|
|
3384
|
+
and it didn't: the old default opened with
|
|
3385
|
+
|
|
3386
|
+
return if children.any? && children_tile_rect?
|
|
3387
|
+
|
|
3388
|
+
so a container whose children tile it perfectly painted nothing **and
|
|
3389
|
+
re-invalidated nothing**. A `TabSheet` (strip on row 0, pane below, exactly
|
|
3390
|
+
tiling) is such a container, and so is a `Layout` whose slot happens to fit its
|
|
3391
|
+
children. The cascade dead-ended there, the grandchildren never learned their
|
|
3392
|
+
cells had been wiped, and the blank stayed until some unrelated event invalidated
|
|
3393
|
+
them again. Nothing in the code says "this must forward", and no test went red —
|
|
3394
|
+
the invalidation set and the buffer were both self-consistent.
|
|
3395
|
+
|
|
3396
|
+
**Decision.** Make the *clear* conditional and the *invalidate* unconditional:
|
|
3397
|
+
|
|
3398
|
+
clear_background unless children.any? && children_tile_rect?
|
|
3399
|
+
children.each { |c| screen.invalidate(c) }
|
|
3400
|
+
|
|
3401
|
+
A container that paints nothing of its own can only redraw its area *through* its
|
|
3402
|
+
children, so being invalidated has to mean invalidating them. The tiling test
|
|
3403
|
+
keeps doing the one job it is good for — deciding whether there is a gap worth
|
|
3404
|
+
blanking, which is what `D_progress_bar`'s "never blank a cell you are about to
|
|
3405
|
+
paint over" cares about.
|
|
3406
|
+
|
|
3407
|
+
**Why the extra invalidation is not a cost.** It is a repaint of a subtree that
|
|
3408
|
+
was about to be wrong, and it reaches the terminal only if it changes something:
|
|
3409
|
+
`Buffer::Cell#set` flips the dirty flag on a real content change alone, so
|
|
3410
|
+
repainting identical glyphs emits nothing. The wire stays minimal; only CPU
|
|
3411
|
+
moves, and only on the frames where an ancestor cleared.
|
|
3412
|
+
|
|
3413
|
+
**Roads not taken.**
|
|
3414
|
+
|
|
3415
|
+
- **Clear only the gaps instead of the whole rect.** Strictly better in
|
|
3416
|
+
principle — no descendant's cells would be destroyed, so no cascade would be
|
|
3417
|
+
needed at all — but it means real rect-subtraction geometry (n children, holes,
|
|
3418
|
+
overlap) in the hottest path in the framework, to replace one `fill`. The
|
|
3419
|
+
cascade fix is three lines and needs no new geometry. Revisit only if clearing
|
|
3420
|
+
ever shows up in a profile.
|
|
3421
|
+
- **Fix it in `TabSheet` alone** (invalidate the strip and pane from its own
|
|
3422
|
+
`repaint`). Rejected on evidence: the sweep proves three other panes already
|
|
3423
|
+
had the bug, so the fault is the framework default, not the new component. A
|
|
3424
|
+
local fix would have left the trap armed for the next container that happens to
|
|
3425
|
+
tile.
|
|
3426
|
+
- **Make the clearing container invalidate the whole subtree** (`on_tree`) rather
|
|
3427
|
+
than its direct children. Same end state by a blunter route, and it moves the
|
|
3428
|
+
knowledge of "who might have been clobbered" into the clearing parent, where
|
|
3429
|
+
the tree below it is none of its business. Each container forwarding one hop is
|
|
3430
|
+
the local rule that composes.
|
|
3431
|
+
|
|
3432
|
+
## D_tabs — `Tabs` / `TabSheet`: a strip, and a strip that swaps panes (2026-08-23)
|
|
3433
|
+
|
|
3434
|
+
**Status:** Accepted; `Component::Tabs` (with `Tabs::Tab`) and
|
|
3435
|
+
`Component::TabSheet` implemented 2026-08-23, demoed in the sampler, taught in
|
|
3436
|
+
book ch7 ("Switching between views"). Brainstormed in `ideas/tabs.md`, now
|
|
3437
|
+
retired. Leans on `D_has_value` (the seam it declines), `D_progress_bar` (the
|
|
3438
|
+
precedent for a selection kept *out* of that seam), `D_list_items` (items vs.
|
|
3439
|
+
identities), `D_select` (claim the minimum), `D_ambiguous_width` (the separator
|
|
3440
|
+
glyph), `D_tree_api` (the slot-swap recipe) and `D_attach_hooks` (what
|
|
3441
|
+
detachment fires).
|
|
3442
|
+
|
|
3443
|
+
**Context.** Several views, one visible at a time, and a one-row strip of
|
|
3444
|
+
captions to pick between them. Two components, because the strip is useful
|
|
3445
|
+
alone — Vaadin documents that case explicitly ("content switching without Tab
|
|
3446
|
+
Sheet"), and an app whose strip lives structurally elsewhere on the screen
|
|
3447
|
+
needs it: `Tabs` is the selector, `TabSheet` is the selector plus the pane that
|
|
3448
|
+
goes with it.
|
|
3449
|
+
|
|
3450
|
+
**Decision — neither is `HasValue`, because a selection is not a value.** The
|
|
3451
|
+
test that decides it, and it generalizes: **would a form save it?** A
|
|
3452
|
+
`RadioGroup`'s selection *is* the datum being edited, so it is a value; a tab's
|
|
3453
|
+
selection is where the user is looking — nothing saves it, nothing validates it,
|
|
3454
|
+
and a forms layer iterating fields must never find it. `D_progress_bar` made the
|
|
3455
|
+
same call one step further out (a `value` that is a read-only report), and
|
|
3456
|
+
`List` has held a cursor and an `on_item_chosen` without being a field since it
|
|
3457
|
+
existed. External corroboration: **Vaadin's `Tabs` is not a field either** — it
|
|
3458
|
+
fires `SelectedChangeEvent`, exposes `setSelectedTab`/`setSelectedIndex`, and is
|
|
3459
|
+
grouped with Accordion and Details rather than with the fields. The cost is that
|
|
3460
|
+
`Tabs` gets no `empty?` / `clear` / `on_value_change` and no free `focusable?`,
|
|
3461
|
+
so it declares `focusable?` and `tab_stop?` itself, the way `Checkbox` and
|
|
3462
|
+
`Select` do. Someone will eventually ask for `tabs.value`; the answer is
|
|
3463
|
+
`selected` / `selected_index`, and this paragraph is why.
|
|
3464
|
+
|
|
3465
|
+
**Decision — `on_tab_selected` reports that the selection *changed*, not that
|
|
3466
|
+
the user pressed something.** Arrows, a click, `selected=` / `selected_index=`,
|
|
3467
|
+
the autoselect of the first `add_tab`, and the re-selection that follows removing
|
|
3468
|
+
the selected tab all fire it; re-selecting the tab already selected fires
|
|
3469
|
+
nothing. Removing the *last* tab fires `(nil, nil)`, both arguments nil. The
|
|
3470
|
+
alternative — notify only on user gestures — would make `Tabs` the one component
|
|
3471
|
+
where an app must re-derive the selection after a removal, and the empty case is
|
|
3472
|
+
exactly where a listener most needs to hear from the strip: an app that renders
|
|
3473
|
+
from the callback has to be told to render *nothing*, or the departed tab's
|
|
3474
|
+
content sits on screen with no tab pointing at it. One implementation
|
|
3475
|
+
consequence worth keeping: the notification decision cannot be made by comparing
|
|
3476
|
+
indices, because removing the selected middle tab of three leaves the index at 1
|
|
3477
|
+
with a *different* tab under it. `apply_selection` therefore takes the
|
|
3478
|
+
previously-selected tab as an argument.
|
|
3479
|
+
|
|
3480
|
+
**Decision — hiding a pane means *detaching* it; Tuile grows no visibility
|
|
3481
|
+
flag.** `TabSheet` keeps only the selected tab's pane in the tree. The
|
|
3482
|
+
alternative — n+1 children, unselected panes hidden by an empty rect — looks
|
|
3483
|
+
cheaper and is not, because the empty rect is a *paint* convention that gates
|
|
3484
|
+
nothing else. Five leaks, all silent: `cycle_focus` collects tab stops by tree
|
|
3485
|
+
walk, so every field in every hidden pane stays in the Tab cycle;
|
|
3486
|
+
`first_tab_stop_or_root` and `Layout#on_focus` cascade focus *into* hidden
|
|
3487
|
+
subtrees; `Screen` parks the hardware cursor at `focused.cursor_position`, so a
|
|
3488
|
+
hidden `TextField` puts the terminal cursor in the middle of the visible pane;
|
|
3489
|
+
`keyboard_hint` advertises the hidden widget in the status bar; and key delivery
|
|
3490
|
+
bubbles through it because it is on the focus chain. Only mouse hit-testing is
|
|
3491
|
+
safe. So option B needs a real seam gating at least four places plus a ruling on
|
|
3492
|
+
whether `Box` / `Absolute` skip invisible children when dividing space — a
|
|
3493
|
+
framework-wide change in the focus system, to buy one component what detachment
|
|
3494
|
+
already gives. Note the prior art: every framework that keeps hidden panes
|
|
3495
|
+
mounted (Textual, FTXUI) has a display/visibility flag in its *core* — Textual's
|
|
3496
|
+
`ContentSwitcher` is one `display` toggle. Tuile's honest options were detach or
|
|
3497
|
+
invent that flag. **Re-grow rule:** `Component#visible?` comes back only when a
|
|
3498
|
+
*second* consumer appears (a pane that must stay live while hidden, an app
|
|
3499
|
+
wanting hidden-but-laid-out widgets), and only argued as a focus-and-paint gate
|
|
3500
|
+
with an explicit ruling on layout arithmetic — never as a paint-time flag
|
|
3501
|
+
smuggled in under one component. AGENTS.md carries the one-line invariant.
|
|
3502
|
+
|
|
3503
|
+
**Decision — one tab stop for the whole strip, and arrows activate
|
|
3504
|
+
immediately.** Three arguments against a component per tab, in order of force:
|
|
3505
|
+
"exactly one stop per widget" (`D_has_value`), and n tabs would mean n Tab
|
|
3506
|
+
presses before the content is reachable; making the *Tab key* walk between
|
|
3507
|
+
*tabs* is the one thing the key ladder forbids by construction (Tab is claimed
|
|
3508
|
+
above everything and means "leave this widget"), so it would read as a feature
|
|
3509
|
+
and be a semantic inversion; and no prior art does it, including the frameworks
|
|
3510
|
+
where individual tabs are widgets (Textual's `Tab`s are children of a focusable
|
|
3511
|
+
`Tabs` and are not focus stops).
|
|
3512
|
+
|
|
3513
|
+
Activation is immediate — Textual, Terminal.Gui, FTXUI, Windows tab controls and
|
|
3514
|
+
the ARIA "automatic activation" pattern all agree, Vaadin being the lone
|
|
3515
|
+
counterexample with a manual variant motivated by expensive panels and
|
|
3516
|
+
screen-reader semantics a TTY doesn't have. The deciding reason is narrower than
|
|
3517
|
+
the prior art, though: **auto-activation means only one thing is ever
|
|
3518
|
+
highlighted.** Manual activation needs two states on one row — the selection and
|
|
3519
|
+
the roamed-to tab — and therefore two visual channels to separate them, on a
|
|
3520
|
+
strip that spends both on the selection alone (below). `RadioGroup` could afford
|
|
3521
|
+
that split vertically because each row has a glyph column of its own
|
|
3522
|
+
(`D_radio_group`); a one-row strip cannot, and two highlights side by side read
|
|
3523
|
+
as noise rather than as two kinds of state. Auto-activation deletes the
|
|
3524
|
+
distinction instead of styling it, and every code path — paint, hit test,
|
|
3525
|
+
callback — has one index to consult. Consequence, and it runs the opposite way
|
|
3526
|
+
to the brainstorm's guess: **lazy panes inherit this rather than reopening it.**
|
|
3527
|
+
Arrowing across five lazy tabs builds five panes; a sheet that can't afford
|
|
3528
|
+
that owes its own answer (a cheap placeholder, or building on a settle delay),
|
|
3529
|
+
not a return to Enter-to-activate.
|
|
3530
|
+
|
|
3531
|
+
**Decision — the strip claims LEFT / RIGHT and the mouse, and nothing else.**
|
|
3532
|
+
`D_select`'s contract restated: Enter and Space have nothing to do once arrows
|
|
3533
|
+
activate, and declining them keeps a form's default button and the app's keys
|
|
3534
|
+
alive. UP / DOWN are declined so a future arrow-navigating layout can move focus
|
|
3535
|
+
*out* of the strip on the axis the strip doesn't use. HOME / END are declined
|
|
3536
|
+
too — Terminal.Gui binds them on its tab row, but a key no widget claims stays
|
|
3537
|
+
available app-wide, which is worth more than a shortcut for a jump that is two
|
|
3538
|
+
Left presses away in the 3–5 tab normal case; an app that wants it assigns
|
|
3539
|
+
`selected_index`. Edges clamp and consume, no wrap (as `List` does): the
|
|
3540
|
+
arrow-nav rule about declining at the edge is about *focus motion*, and this is
|
|
3541
|
+
selection, with the vertical axis already the way out.
|
|
3542
|
+
|
|
3543
|
+
**Decision — bold marks the selected tab; it is not strip chrome.** The selected
|
|
3544
|
+
caption is bold *always*, and additionally sits on `Theme#active_bg_color` while
|
|
3545
|
+
the strip is on the focus chain; unselected captions are regular weight. Two
|
|
3546
|
+
channels, no new theme token. Bold is the one that survives an unfocused strip,
|
|
3547
|
+
which matters because the strip is the map of where you are in the app — unlike
|
|
3548
|
+
a `List` cursor, which is a transient pointer and has
|
|
3549
|
+
`show_cursor_when_inactive` for exactly this reason. **Bolding every caption is
|
|
3550
|
+
the tempting "fix" and it is wrong**: it spends the only unfocused-visible
|
|
3551
|
+
channel, leaving selection to the focus-gated background alone, so an unfocused
|
|
3552
|
+
strip would show no selection at all. Neither escape works — `input_bg_color` is
|
|
3553
|
+
the only other bg token and it means "resting input well" (`Select` uses it for
|
|
3554
|
+
exactly that), and dimming the *unselected* captions instead collides with the
|
|
3555
|
+
dim a *disabled* tab wants (see Deferred below), which would leave unselected
|
|
3556
|
+
and disabled indistinguishable.
|
|
3557
|
+
Rejected alternatives: bracketing the label (`[Payment]`) shifts every later
|
|
3558
|
+
segment by two columns whenever the selection moves, making hit-test geometry
|
|
3559
|
+
depend on the selection; an underline is `▁`, a fresh Ambiguous glyph. This
|
|
3560
|
+
ruling also needed one new primitive — `StyledString#with_bold`, since nothing
|
|
3561
|
+
in the gem had used bold and a caption is a `StyledString` that may carry its own
|
|
3562
|
+
colors.
|
|
3563
|
+
|
|
3564
|
+
**Decision — the separator is `│`, the glyph `Window` paints its borders with,
|
|
3565
|
+
not ASCII `|`.** This inverts `D_ambiguous_width`'s "a new component defaults to
|
|
3566
|
+
ASCII when the pretty glyph is Ambiguous", and the inversion is the point: that
|
|
3567
|
+
rule exists to keep the Ambiguous inventory small and enumerable, and `│` is
|
|
3568
|
+
already *in* the inventory — `window.rb` paints it on every window, and nothing
|
|
3569
|
+
in the gem is designed to survive it measuring 2. Reusing a glyph the framework
|
|
3570
|
+
has already bet on adds nothing to the audit list, and a strip inside a window
|
|
3571
|
+
lines up with the border around it. `separator=` remains, now as the opt-in for
|
|
3572
|
+
ASCII. A *fresh* Ambiguous glyph still defaults to ASCII.
|
|
3573
|
+
|
|
3574
|
+
**Decision — segment geometry: the padding is part of the segment, the separator
|
|
3575
|
+
column is chrome.** A segment is `" " + caption + " "`, segments joined by one
|
|
3576
|
+
separator column. Every segment has the same shape including the first and last
|
|
3577
|
+
(no trimmed outer padding, so no edge case in the arithmetic); the highlight
|
|
3578
|
+
covers the padding, because one that stopped at the glyphs would read as a
|
|
3579
|
+
ragged smear; and a click on a padding column selects that tab, while the
|
|
3580
|
+
separator column selects nothing — same rule as the blank tail past `extent`,
|
|
3581
|
+
which focuses without selecting (`D_boolean_fields`). One private `segments`
|
|
3582
|
+
method is the sole source of that arithmetic, read by *both* the paint and the
|
|
3583
|
+
hit test, and derived from the captions on each call rather than recorded during
|
|
3584
|
+
the last paint — so a hit test is correct before the first paint and after a
|
|
3585
|
+
caption change.
|
|
3586
|
+
|
|
3587
|
+
**Decision — a narrow strip scrolls to keep the selection whole in view**
|
|
3588
|
+
(2026-08-24; v1 clipped, and this replaces that ruling before either strip
|
|
3589
|
+
shipped). One private `left_column` — the strip column painted in the rect's
|
|
3590
|
+
leftmost cell, the name `TextField` uses — read by the paint, the hit test,
|
|
3591
|
+
`extent` and (on `MenuBar`) the segment rect a panel anchors to, so there is
|
|
3592
|
+
still exactly **one** source of segment arithmetic; two would let a click land on
|
|
3593
|
+
the tab beside the one drawn under it. One idempotent `adjust_left_column` is its
|
|
3594
|
+
sole writer, called from every mutation site (`ProgressBar#sync_ticker`'s shape,
|
|
3595
|
+
not a nudge per site), which is what makes the offset `0` in every situation the
|
|
3596
|
+
clipping version handled, scroll back on its own when the rect grows or the
|
|
3597
|
+
captions shrink, and never need a scroll-back branch in any mutator. `MenuBar`
|
|
3598
|
+
funnels its three highlight writers through one private `highlight=` for the same
|
|
3599
|
+
reason, and gets a guarantee out of it: the highlighted segment is on screen
|
|
3600
|
+
*before* `Cascade` anchors a panel to it.
|
|
3601
|
+
|
|
3602
|
+
Rejected — *segment-aligned scrolling* (the offset always a segment start): it
|
|
3603
|
+
buys clean edges and needs no glyph snapping, but wastes up to a segment of width
|
|
3604
|
+
at the right edge, and a strip this narrow is exactly where columns are scarce.
|
|
3605
|
+
Rejected — *reserved cue columns*: reserving two columns makes the window width a
|
|
3606
|
+
function of the scroll state that is computed from it, which is `D_select`'s
|
|
3607
|
+
`:auto`-scrollbar circularity, and shifts the whole strip sideways when a caption
|
|
3608
|
+
is edited. The cues are **overlaid** on the edge columns instead, keeping the
|
|
3609
|
+
style of the cell they cover so one landing on the selected segment doesn't punch
|
|
3610
|
+
a hole in its highlight, and they are ASCII `<` / `>` — `‹ ›` are Ambiguous-width
|
|
3611
|
+
(`D_ambiguous_width`), and a `cue_glyphs=` knob with no caller is a knob to argue
|
|
3612
|
+
about later. They stay chrome, not buttons: a click on a cue falls through to the
|
|
3613
|
+
half-visible segment under it, which selects it and reveals it — the direction the
|
|
3614
|
+
cue pointed anyway — where a clickable cue would need the column to hit-test
|
|
3615
|
+
differently from what it paints. And *free scrolling* (a wheel moving the window
|
|
3616
|
+
without moving the selection) is deliberately absent: the next sync would yank the
|
|
3617
|
+
view back to the selection, so supporting it means a second "user scrolled, stop
|
|
3618
|
+
following" state with a resume rule — `List#auto_scroll`'s machinery, for a
|
|
3619
|
+
one-row widget.
|
|
3620
|
+
|
|
3621
|
+
**The trap this design steps over.** `StyledString#slice` *drops* a grapheme
|
|
3622
|
+
cluster straddling the window edge rather than half-painting it, so an offset
|
|
3623
|
+
landing mid-cluster returns a row one column short and shifts everything past the
|
|
3624
|
+
hole one column left — paint and hit test then disagree, silently, only for wide
|
|
3625
|
+
glyphs. So the offset is snapped *forward* to a cluster boundary, as
|
|
3626
|
+
`TextField#snap_to_glyph_start` does; forward is the safe direction, giving up at
|
|
3627
|
+
most one column of the segment left of the window and never of the one being
|
|
3628
|
+
revealed. A caption wider than the whole rect can't be shown whole at all: its
|
|
3629
|
+
head wins, being the half that identifies it.
|
|
3630
|
+
|
|
3631
|
+
**Decision — `Tabs` owns mutable `Tab` handles; it does *not* get the
|
|
3632
|
+
`items=` / `item_label=` / `label_for` shell.** `add_tab("First")` mints and
|
|
3633
|
+
returns a `Tabs::Tab`, Vaadin-style. The test that separates the two is sharper
|
|
3634
|
+
than "items feel wrong": **an item is an element of a collection someone else
|
|
3635
|
+
owns** — assignment is whole-collection, and an item carries no per-element
|
|
3636
|
+
state, the renderer deriving everything from the object each paint (which is why
|
|
3637
|
+
`D_list_items` *removed* the appenders). **A tab is identity plus per-element
|
|
3638
|
+
mutable state**, minted by the widget and living as long as it, and re-assigning
|
|
3639
|
+
the whole set — the operation an items API is built around — is precisely what a
|
|
3640
|
+
strip must never offer: it would destroy tab identity and with it `TabSheet`'s
|
|
3641
|
+
pane mapping. Two corollaries make the ruling durable: the unbuilt half of
|
|
3642
|
+
`D_list_items` is a *data provider* behind `items`, and a provider cannot own
|
|
3643
|
+
per-tab state, so `HasItems` would arrive carrying a promise Tabs must refuse
|
|
3644
|
+
(paging tabs is meaningless — a million tabs is not a UI); and the growth path
|
|
3645
|
+
here is per-element *attributes* (hidden, disabled, closeable), which items have
|
|
3646
|
+
no notion of. So the `HasItems` question is closed for `Tabs`; it survives only
|
|
3647
|
+
for `ComboBox` / `Select` / `RadioGroup`, where the shell genuinely is three
|
|
3648
|
+
copies of one thing.
|
|
3649
|
+
|
|
3650
|
+
The synthesis worth keeping: **the `Tab` object is what keeps those attributes
|
|
3651
|
+
from becoming framework seams.** A hidden tab is a skipped segment, not
|
|
3652
|
+
`Component#visible?`; a disabled tab is painted dim and skipped when arrowing,
|
|
3653
|
+
not a framework enabled/disabled seam; a closeable tab is an `x` in the segment.
|
|
3654
|
+
None touches `Component`.
|
|
3655
|
+
|
|
3656
|
+
`Tab` is a small mutable object owned by the strip — not a frozen value type (it
|
|
3657
|
+
has settable attributes) and not a `Component` (it never paints itself; a
|
|
3658
|
+
component that never paints is a confusing new category). The contract is copied
|
|
3659
|
+
wholesale from `TextView::Region`: `private_class_method :new`, handed out by the
|
|
3660
|
+
owner, mutators invalidating the owner through a back-pointer, and **a removed
|
|
3661
|
+
handle raising on every mutator and on every reader that consults the strip** — a
|
|
3662
|
+
stale `Tab` is the same footgun as a stale `Region`. As there, the locally-held
|
|
3663
|
+
`caption` stays readable (so an error message can name it) and `remove` is an
|
|
3664
|
+
idempotent no-op. The back-pointer also closes the caption-refresh question:
|
|
3665
|
+
`Tab#caption=` invalidates the strip, so there is no `refresh_rows`-style
|
|
3666
|
+
question to answer.
|
|
3667
|
+
|
|
3668
|
+
**Decision — no `Tab#data`.** A tab carries a caption and its own display
|
|
3669
|
+
attributes, nothing of the app's. The rejected slot would have let
|
|
3670
|
+
`on_tab_selected` hand back a domain object, and it isn't needed: the pane
|
|
3671
|
+
component owns its data (COP's "a component does everything its one purpose
|
|
3672
|
+
needs", so the pane *is* the handle), or a future binder does — the tab is on
|
|
3673
|
+
neither path. Anything genuinely per-tab and app-owned lives in the `TabSheet`
|
|
3674
|
+
or the app component that built it, keyed the way `TabSheet` keys its panes.
|
|
3675
|
+
This is what keeps `Tab` from becoming the items API this entry just refused.
|
|
3676
|
+
|
|
3677
|
+
**Decision — `Tab` hand-rolls `caption` / `caption=` rather than including
|
|
3678
|
+
`HasCaption`.** The six duplicated lines look like exactly what a mixin is for,
|
|
3679
|
+
and the reason they aren't is the mixin's actual payoff: `HasCaption` earns its
|
|
3680
|
+
place as a **test-locator seam** — a locator walks the component tree matching
|
|
3681
|
+
`is_a?(HasCaption)` plus a caption compare, with no hardcoded class list. A
|
|
3682
|
+
`Tab` is not a `Component`, so it appears in no tree walk and that payoff is
|
|
3683
|
+
structurally unreachable; and there is exactly one `Tab` class, forever, so the
|
|
3684
|
+
"no hardcoded class list" benefit has nothing to range over either. Including it
|
|
3685
|
+
would be DRY-only, which is the bar the seam argument sets. The lookup debt is
|
|
3686
|
+
paid on the strip instead: **`Tabs#tabs`** returns the tab array (read-only by
|
|
3687
|
+
convention, like `Component#children`), so a test finds a tab through the widget
|
|
3688
|
+
that owns it — `tabs.find { |t| t.caption.to_s == "Payment" }`. That
|
|
3689
|
+
reader was needed anyway, since `TabSheet` keys panes by identity and nothing
|
|
3690
|
+
else can enumerate.
|
|
3691
|
+
|
|
3692
|
+
**Decision — `TabSheet` holds two children, not n+1, and is not
|
|
3693
|
+
`HasContent`.** `children == [strip, pane]` with the strip pinned at index 0, so
|
|
3694
|
+
pre-order traversal yields the browser's strip-then-pane Tab order for free.
|
|
3695
|
+
`HasContent` stays out even though the swap looks like a content slot, for three
|
|
3696
|
+
concrete reasons: `content=` would become public API meaning "the visible pane",
|
|
3697
|
+
which is misleading (the pane is *derived* from the selection, not assignable);
|
|
3698
|
+
`HasContent#handle_mouse` forwards only into `content`, so the strip would never
|
|
3699
|
+
see a click; and `HasContent#on_focus` forwards focus into the content, which is
|
|
3700
|
+
the behavior this design rejects (switching a tab must not move focus into the
|
|
3701
|
+
new pane — browser and Vaadin behavior). What *is* reused is the slot-swap
|
|
3702
|
+
recipe `D_tree_api` specifies for `Window`: detach without notifying, rewire,
|
|
3703
|
+
then `on_child_removed` last, so the focus repair cascades into the *new*
|
|
3704
|
+
occupant. `TabSheet` overrides that hook to land focus on **the strip** rather
|
|
3705
|
+
than on itself, which is not focusable; the other candidate (the new pane's
|
|
3706
|
+
first tab stop) loses because the user's last action was a tab switch.
|
|
3707
|
+
|
|
3708
|
+
Panes live in an identity-keyed `Tab => Component` map on the sheet. Licence:
|
|
3709
|
+
`Box`'s per-child constraint map, which AGENTS.md permits because it is "a
|
|
3710
|
+
per-child *attribute* map, not a second copy of ordering" — the strip's tab array
|
|
3711
|
+
stays the sole ordering authority. Rejected: a `component` slot on `Tabs::Tab`
|
|
3712
|
+
(the strip would then know about panes, which is the split this whole design
|
|
3713
|
+
rests on), and `TabSheet::Tab < Tabs::Tab` behind a protected factory hook (a
|
|
3714
|
+
framework hook existing for exactly one subclass, handing the minting decision
|
|
3715
|
+
to the subclass while `Tabs` still owns the array).
|
|
3716
|
+
|
|
3717
|
+
Two implementation rulings the map earned. **One idempotent `sync_pane` is the
|
|
3718
|
+
sole writer of the visible pane**, deriving it from `strip.selected` on every
|
|
3719
|
+
call — which is what lets `add_tab` register a pane *after* the strip has already
|
|
3720
|
+
autoselected its tab, with no suspend-the-listener dance; the event-driven
|
|
3721
|
+
alternative has an ordering problem on the very first tab. And the map's keys are
|
|
3722
|
+
kept honest by an invariant rather than by one code path:
|
|
3723
|
+
`forget_removed_tabs` drops every entry whose tab is detached, because
|
|
3724
|
+
`Tabs::Tab#remove` reaches the strip without passing through
|
|
3725
|
+
`TabSheet#remove_tab` — which stranded the entry, pinned the pane against GC, and
|
|
3726
|
+
made `add_tab` reject that pane as still in use. Rejected there: an
|
|
3727
|
+
`on_tab_removed` listener on `Tabs` for the sheet to subscribe to — the tidier
|
|
3728
|
+
data flow, but new app-facing API whose only consumer is internal.
|
|
3729
|
+
|
|
3730
|
+
Named `add_tab(caption, pane)`, not `add`: `Layout#add(component)` is the house
|
|
3731
|
+
`add`, and the explicit verb stops the two reading alike — the same reason
|
|
3732
|
+
`Tabs#add_tab` isn't `add`. (The brainstorm sketched `sheet.add`; its own
|
|
3733
|
+
argument overruled it.)
|
|
3734
|
+
|
|
3735
|
+
**Decision — no framework key switches tabs from *inside* a pane, and no
|
|
3736
|
+
`Keys::CTRL_PAGE_UP` / `CTRL_PAGE_DOWN` constants are added.** Not v1, not
|
|
3737
|
+
later. Four reasons, the first decisive: it is **a global shortcut in disguise**
|
|
3738
|
+
— "one key, anywhere in the app, meaning switch tab" is app policy, and Tuile
|
|
3739
|
+
already has two homes for app policy (the rung-2 registry and an ancestor's
|
|
3740
|
+
`handle_key`), so shipping it as component behavior smuggles an app-level binding
|
|
3741
|
+
into a widget. Nested sheets make it ambiguous *and* the failure is silent: the
|
|
3742
|
+
bubble delivers to the innermost `TabSheet` first, so an inner sheet swallows the
|
|
3743
|
+
key and the outer one becomes unreachable by keyboard with nothing on screen
|
|
3744
|
+
explaining why. Vaadin apps have never needed it — the strip plus Tab is enough.
|
|
3745
|
+
And the editors that do have it use their own scheme, which is the argument for
|
|
3746
|
+
leaving the binding to the app: no choice Tuile made here would match the app's
|
|
3747
|
+
other keys. What Tuile owes instead is the *verbs*: `select_next` /
|
|
3748
|
+
`select_previous` are public (they exist for Left/Right anyway), so an app that
|
|
3749
|
+
wants the habit writes two lines and owns both the key and the "which sheet"
|
|
3750
|
+
question that sank the framework version.
|
|
3751
|
+
|
|
3752
|
+
**Deferred, and why each lands additively.** v1 is captions, selection, mouse,
|
|
3753
|
+
keys and the pane swap. Nothing below is blocked, which is the payoff of the
|
|
3754
|
+
`Tab`-object ruling — each is a `Tab` attribute plus a branch in paint and in
|
|
3755
|
+
arrowing, needing no framework seam:
|
|
3756
|
+
|
|
3757
|
+
- **Hidden tabs** — skip the segment when painting and when arrowing. Pane
|
|
3758
|
+
hiding is already detachment, so this needs no `Component#visible?`.
|
|
3759
|
+
- **Disabled tabs** — paint dim, skip when arrowing, never select. A disabled
|
|
3760
|
+
*tab* is not a component, so no enabled/disabled seam is needed. (A disabled
|
|
3761
|
+
*pane* would be; still out of scope.)
|
|
3762
|
+
- **Closeable tabs** — an `x` in the segment, hit-tested, removing the tab.
|
|
3763
|
+
Nobody else in the gem needs it and the glyph is ASCII-cheap.
|
|
3764
|
+
- **Lazy panes** — `add_tab("Reports") { build_reports }`, built on first
|
|
3765
|
+
selection (Vaadin does it with an attach listener). Free to add: the swap has
|
|
3766
|
+
one call site. Inherits auto-activation, per the activation ruling above.
|
|
3767
|
+
- **Clickable cues, and free scrolling** — see the scrolling decision.
|
|
3768
|
+
|
|
3769
|
+
**Alternatives rejected** (beyond those argued inline). *Vertical orientation:*
|
|
3770
|
+
out of scope — Vaadin doesn't allow it in a TabSheet either, and a vertical strip
|
|
3771
|
+
is a `List` with a renderer (the Side Nav shape). *A border around the strip:*
|
|
3772
|
+
compose with `Window`; the Turbo Vision / Terminal.Gui look, with tabs notched
|
|
3773
|
+
into the top border, would couple `Tabs` to `Window` chrome. *Prefix/suffix
|
|
3774
|
+
slots* for icons and badges: unnecessary — a caption is a `StyledString`, so
|
|
3775
|
+
`Open [24]` is just text.
|
|
3776
|
+
|
|
3777
|
+
**Consequences.** Selection is view state, so nothing in a forms layer will ever
|
|
3778
|
+
enumerate a strip. Hiding a component means detaching it, framework-wide, and
|
|
3779
|
+
`TabSheet` is the worked example — which also means a pane's `on_attached` /
|
|
3780
|
+
`on_detached` fire on every switch, and a pane cannot own a resource that must
|
|
3781
|
+
outlive its visibility. A tab is a handle an app holds, so tab identity is stable
|
|
3782
|
+
across caption edits and reorderings of nothing else. And a starved strip stays
|
|
3783
|
+
wholly reachable, at the cost of a scroll offset that every future paint-time or
|
|
3784
|
+
hit-test change has to keep threading through one place.
|
|
3785
|
+
|
|
3786
|
+
## D_menu_bar — `MenuBar`: a focused strip driving a cascade of `ListDropdown`s (2026-08-24)
|
|
3787
|
+
|
|
3788
|
+
**Status:** Accepted; v1 (`Component::MenuBar` with `MenuBar::Item` and the
|
|
3789
|
+
private `MenuBar::Cascade`) implemented 2026-08-24, demoed in the sampler, taught
|
|
3790
|
+
in book ch7 ("Menus"); v2 (mnemonics) the same day. Designed in a since-retired
|
|
3791
|
+
`ideas/menu-bar.md`, whose prior-art survey (Vaadin 25.2, Turbo Vision,
|
|
3792
|
+
Terminal.Gui, notcurses, MC, and the frameworks that have no menu) this entry
|
|
3793
|
+
only summarizes.
|
|
3794
|
+
|
|
3795
|
+
**Update 2026-08-24: a narrow bar scrolls**, on `D_tabs`' scrolling decision,
|
|
3796
|
+
which both strips implement identically (one private `left_column`, one
|
|
3797
|
+
`adjust_left_column` as its sole writer, ASCII cues overlaid on the edge
|
|
3798
|
+
columns). `MenuBar`'s share of it: one private `highlight=` funnels the arrow,
|
|
3799
|
+
mnemonic and click paths, so a segment is on screen before `Cascade` anchors to
|
|
3800
|
+
it, and `rect=` still *closes* the cascade rather than re-anchoring it.
|
|
3801
|
+
|
|
3802
|
+
**Context.** `ideas/new-components.md` listed Menu Bar as blocked on extracting a
|
|
3803
|
+
`Popover` from `ListDropdown#anchor_to`. It isn't: the widget needs a *second
|
|
3804
|
+
placement*, not a second kind of overlay.
|
|
3805
|
+
|
|
3806
|
+
**Decision.** Focus never leaves the bar. The strip is the single tab stop, and
|
|
3807
|
+
the open menus are non-modal `ListDropdown`s mounted on the `ScreenPane` — owned
|
|
3808
|
+
by the bar, parented by nobody — so every key arrives at `MenuBar#handle_key`,
|
|
3809
|
+
which offers it to a `Cascade` first. That is `Select`'s architecture
|
|
3810
|
+
(`D_select`) extended to N levels, which is why **nothing in the key-dispatch
|
|
3811
|
+
ladder changes** and why the whole widget is additive: two new placement helpers
|
|
3812
|
+
on `ListDropdown`, one callback pass-through, and no change to `Popup`,
|
|
3813
|
+
`ScreenPane` or `Component`.
|
|
3814
|
+
|
|
3815
|
+
The keyboard map is copied from Vaadin's, which is also the ARIA menubar pattern
|
|
3816
|
+
and what every TUI lineage surveyed does — Left/Right along the strip,
|
|
3817
|
+
Down/Enter/Space to open, Up/Down inside, Right/Enter to drill, Left to go back,
|
|
3818
|
+
ESC to close one level, and Left-at-the-top / Right-on-a-leaf stepping to the
|
|
3819
|
+
neighbouring menu. There was nothing to invent, and inventing would have been the
|
|
3820
|
+
error.
|
|
3821
|
+
|
|
3822
|
+
**Alternatives rejected.**
|
|
3823
|
+
|
|
3824
|
+
- **A single drill-down frame** — one panel that re-renders as you descend
|
|
3825
|
+
(Terminal.Gui ships this as `UseSubMenusSingleFrame`). It needs no
|
|
3826
|
+
`anchor_beside` and no stack at all, and was rejected because it loses the
|
|
3827
|
+
"where am I in the hierarchy" readout that is the cascade's entire point — and
|
|
3828
|
+
because it cannot be the default with a cascade bolted on later: the cascade is
|
|
3829
|
+
the harder mechanism, and building it second means building it against a shape
|
|
3830
|
+
that assumed one panel.
|
|
3831
|
+
- **A modal level-0 popup**, which would give real modality — keys scoped, clicks
|
|
3832
|
+
outside blocked. Rejected on a mechanical fact, not a preference:
|
|
3833
|
+
`ScreenPane#add_popup` **centers** every modal popup and focuses it, so an
|
|
3834
|
+
anchored modal is impossible without changing `ScreenPane`. It would also
|
|
3835
|
+
invert ownership, moving key handling off the bar and into the popup.
|
|
3836
|
+
- **Focusable panels**, focus descending as you drill. Rejected: AGENTS.md's
|
|
3837
|
+
non-modal-overlay traps say a focus-taking non-modal overlay lands focus
|
|
3838
|
+
outside the key scope and kills *every* keystroke until Tab recovers. This
|
|
3839
|
+
would be that bug once per level.
|
|
3840
|
+
- **Extracting `Popover` now.** The roadmap's own trigger ("the second *kind* of
|
|
3841
|
+
anchoring") arguably fires here, but both callers still wrap a `List`, so a
|
|
3842
|
+
`Popover < Popup` would move code without a second kind of *content*. The
|
|
3843
|
+
trigger is the first non-`List` content wanting anchoring (Tooltip, a
|
|
3844
|
+
date-picker grid). It originally had a second half — a third placement method
|
|
3845
|
+
on `ListDropdown`, from `ContextMenu`'s `anchor_at(point)` — which went dormant
|
|
3846
|
+
when that widget was iced (`D_no_context_menu`).
|
|
3847
|
+
- **A command-code bus** (Turbo Vision's `cmOpen` + `handleEvent`) instead of
|
|
3848
|
+
per-item callables. Rejected: Ruby has closures, and Vaadin, Terminal.Gui and
|
|
3849
|
+
ratatui's `tui-menu` all landed on per-item listeners.
|
|
3850
|
+
- **`item.submenu` as a separate object** (Vaadin's `getSubMenu()`). It exists
|
|
3851
|
+
because a Vaadin `MenuItem` is a DOM component; a Tuile item is a handle, so
|
|
3852
|
+
`item.add_item` is one hop shorter and makes depth fall out for free.
|
|
3853
|
+
- **`Component::MenuItem` as a top-level constant.** Considered and reverted the
|
|
3854
|
+
same day: promoting it was priced against a breaking rename once `ContextMenu`
|
|
3855
|
+
names the type, and that price is zero, since no release ships in between. With
|
|
3856
|
+
the cost gone the house default (`Tabs::Tab`, `List::Cursor`) wins, and
|
|
3857
|
+
`MenuBar` gets to settle as one coherent component before unification is argued
|
|
3858
|
+
against a second implementation rather than a guess about one. **Now settled
|
|
3859
|
+
rather than deferred:** icing `ContextMenu` removed the counterparty, so
|
|
3860
|
+
`MenuBar::Item` is simply the name. A revival after 0.13.0 ships pays a
|
|
3861
|
+
**Breaking:** changelog line for the rename, or keeps `MenuBar::Item` as an
|
|
3862
|
+
alias — cheap, and only paid if it happens, which beats paying it now for a
|
|
3863
|
+
widget that may never exist.
|
|
3864
|
+
- **A `HasMenuItems` mixin** (Vaadin's shared `MenuBar` / `ContextMenu` /
|
|
3865
|
+
`SubMenu` interface). Not needed yet, and the shape keeps it cheap: `MenuBar`
|
|
3866
|
+
delegates `add_item` / `items` to a captionless root `Item`, so the method
|
|
3867
|
+
exists exactly *once* and a future sharing exercise starts from one
|
|
3868
|
+
implementation rather than two that drifted.
|
|
3869
|
+
- **Separators (`add_separator`).** Looks free, isn't: a `List` has no
|
|
3870
|
+
unselectable row, so the cursor would land on a separator and Enter would
|
|
3871
|
+
activate nothing. It needs a `Cursor` that hops non-selectable positions, which
|
|
3872
|
+
is a `List` decision, not this one.
|
|
3873
|
+
|
|
3874
|
+
**Deliberately not like `Tabs`.** The strip reuses `Tabs`' *hit testing* — an
|
|
3875
|
+
`extent`, one private `segments` method feeding both paint and click — and
|
|
3876
|
+
deliberately not its *look*: no separator column, no bold, and no highlight while
|
|
3877
|
+
unfocused. Both are one-row caption strips with one highlighted segment, so
|
|
3878
|
+
looking alike would leave a reader working out which control they are seeing. Two
|
|
3879
|
+
of the three divergences are forced anyway: bold is `Tabs`' *persistence* channel
|
|
3880
|
+
(the selection must survive focus moving on) and a menu bar has nothing to
|
|
3881
|
+
persist. The segments arithmetic is the second copy of that trio; per AGENTS.md's
|
|
3882
|
+
duplicate-a-shallow-shell rule a third caption strip is when to argue for
|
|
3883
|
+
extraction.
|
|
3884
|
+
|
|
3885
|
+
**Consequences a contributor would trip over.**
|
|
3886
|
+
|
|
3887
|
+
- **Activation is uniform.** Children win over a listener; a leaf closes the
|
|
3888
|
+
cascade *before* firing (so an action that opens a dialog doesn't paint it under
|
|
3889
|
+
a menu, as in `Select#commit`); and an item with **neither** children nor a
|
|
3890
|
+
listener is legal and inert. An item that looks live but does nothing is the
|
|
3891
|
+
app's error to fix, not the framework's to raise on.
|
|
3892
|
+
- **Stepping highlights; only Enter, Space or a click presses.** Left/Right
|
|
3893
|
+
moving to a neighbouring *top-level button* (a listener, no menu) closes the
|
|
3894
|
+
cascade and highlights it — it does not fire it, or walking the strip would
|
|
3895
|
+
trigger every button on the bar, each one behind a menu still standing over its
|
|
3896
|
+
output. Every path that *does* fire a top-level listener closes the cascade
|
|
3897
|
+
first, matching `Cascade`'s own leaf activation.
|
|
3898
|
+
- **A resize closes the menu**, from `MenuBar#rect=` — see the AGENTS.md
|
|
3899
|
+
non-modal-overlay trap for why that is the legal answer here rather than a
|
|
3900
|
+
`reposition` override. Only a *changed* rect closes it, since `Layout::Box`
|
|
3901
|
+
re-assigns an equal rect on any child mutation.
|
|
3902
|
+
- **So does detaching**, from `on_detached`: the panels are the pane's children,
|
|
3903
|
+
not the bar's, so nothing else would take them down.
|
|
3904
|
+
- **An open menu swallows keys; a closed strip does not.** The one deliberate
|
|
3905
|
+
divergence from `D_select`'s claim-the-minimum rule, and the honest reading of
|
|
3906
|
+
what a menu is — an app key firing behind a visible panel is worse than a dead
|
|
3907
|
+
keystroke.
|
|
3908
|
+
- **A click outside an open cascade is not blocked**, because non-modal overlays
|
|
3909
|
+
block nothing — but it does *dismiss*. The framework-level fix this entry
|
|
3910
|
+
called for (and declined to invent here) shipped as `D_outside_click`: the
|
|
3911
|
+
pane closes every popup a left click missed, and `Cascade` reconciles its level
|
|
3912
|
+
stack from each panel's `Popup#on_close`. The click itself still reaches
|
|
3913
|
+
whatever is beneath.
|
|
3914
|
+
- **`Cascade` is provisional.** It is split from the strip on cohesion, not reuse
|
|
3915
|
+
— otherwise `MenuBar` would both paint captions and manage an overlay stack —
|
|
3916
|
+
and the test for keeping it is *the size of the interface `MenuBar` needs*: at
|
|
3917
|
+
`open_below` / `handle_key` / `close` / `open?` it is a boundary; if it grows
|
|
3918
|
+
accessors that expose the level stack, the "class" was only ever a seam and it
|
|
3919
|
+
folds back in.
|
|
3920
|
+
- **Widths are measured per level, caller-side**, third repeat of the `D_select`
|
|
3921
|
+
pattern (`anchor_to` and `anchor_beside` measure nothing). The submenu arrows
|
|
3922
|
+
right-align against the level's *widest label*, a number the cascade already
|
|
3923
|
+
has, so they line up without asking the `List` how wide it ended up.
|
|
3924
|
+
- **The `▸` is Neutral, not Ambiguous** — verified, like `Select`'s `▾`. The
|
|
3925
|
+
obvious `▶` / `▼` are Ambiguous and would have needed an ASCII opt-in under
|
|
3926
|
+
`D_ambiguous_width`.
|
|
3927
|
+
|
|
3928
|
+
**Mnemonics (v2), and why they are legal.** `add_item(caption, mnemonic: "f")`
|
|
3929
|
+
at *every* depth. AGENTS.md deleted `Component#key_shortcut` and the capture
|
|
3930
|
+
phase that scanned a scope subtree, and forbids reintroducing them — but its
|
|
3931
|
+
re-grow rule sanctions exactly this: *sugar over an ancestor's `handle_key`,
|
|
3932
|
+
never a dispatch phase and never a gate*. A focused `MenuBar` consulting its own
|
|
3933
|
+
item tree inside its own rung-3 `handle_key` is unregistered, unscanned and
|
|
3934
|
+
invisible to every other component. This is the first thing a reviewer will
|
|
3935
|
+
(correctly) flag, hence the paragraph.
|
|
3936
|
+
|
|
3937
|
+
The rule is **one live set, no fallback**: the top-level items while the cascade
|
|
3938
|
+
is closed, the deepest open panel's items while it is open, nothing else ever
|
|
3939
|
+
consulted. Cross-level collision is therefore *structurally impossible* rather
|
|
3940
|
+
than tie-broken — `File > Export` and top-level `Edit` may both bind `e`, and
|
|
3941
|
+
with File open there is nothing to arbitrate — and `f`,`q` for File > Quit falls
|
|
3942
|
+
out with no chord, buffer or timeout. A duplicate *within one sibling set* raises
|
|
3943
|
+
at `add_item`, which is the only scope where two mnemonics can race. A miss
|
|
3944
|
+
swallows rather than falling back to a shallower level: a mistyped letter must
|
|
3945
|
+
not tear down the open menu and open another, and Left/Right and ESC are the
|
|
3946
|
+
routes to a different menu. All of this is what Windows/GTK/Qt do; macOS is the
|
|
3947
|
+
only lineage without menu mnemonics, for the historical reason that it never had
|
|
3948
|
+
an Alt-activates-the-menubar model.
|
|
3949
|
+
|
|
3950
|
+
Four consequences worth recording, each a road that looked open:
|
|
3951
|
+
|
|
3952
|
+
- **The match is hoisted above the cascade delegation.** v1's cascade swallows
|
|
3953
|
+
every unrecognized key while open, so a letter would never reach the strip
|
|
3954
|
+
otherwise. It is guarded to a single printable non-space character so Enter,
|
|
3955
|
+
Space, the arrows, ESC and `MOVE_KEYS` keep their v1 path.
|
|
3956
|
+
- **The cue is `Item#cued_caption`, computed once at construction.** There are
|
|
3957
|
+
two paint sites (the strip's segments, the cascade's row renderer) and
|
|
3958
|
+
`StyledString#slice` counts **columns** while a caption search yields a
|
|
3959
|
+
**character** index, so the conversion lives in exactly one place. Safe to
|
|
3960
|
+
precompute — unlike a theme value, it has no live input. Cues are **always
|
|
3961
|
+
drawn**, focused or not: Tuile has no Alt to reveal them with, so the choice is
|
|
3962
|
+
binary and discoverability wins.
|
|
3963
|
+
- **The bell is tied strictly to the swallow**, and guarded by `Keys.printable?`
|
|
3964
|
+
at the swallow site. Unguarded it would ring at HOME, function keys and the
|
|
3965
|
+
five-byte junk `Keys.getkey` returns for an unknown escape sequence. No bell
|
|
3966
|
+
while the strip is *closed* — a bubbled key is not a miss — and none for a
|
|
3967
|
+
matched-but-inert item or a clamped arrow, or "beep when nothing happened"
|
|
3968
|
+
would grow into an audit of every no-op path.
|
|
3969
|
+
- **`List#select(index)` was the one real gap.** The cascade must move a panel's
|
|
3970
|
+
highlight to the matched row *before* drilling, or a submenu anchors beside
|
|
3971
|
+
whatever row the cursor was on — and a row scrolled out of view has no rect to
|
|
3972
|
+
anchor against at all. `List` could move its cursor by key, by mouse and by
|
|
3973
|
+
search, but not by index; that hole is independent of menus.
|
|
3974
|
+
|
|
3975
|
+
**Type-ahead search is deliberately not built.** "Type `s` in an open menu to
|
|
3976
|
+
jump to the first item containing s" is nearly free — `List#select_next` already
|
|
3977
|
+
does substring, case-insensitive, cursor-ordered-with-wrap search — and that is
|
|
3978
|
+
the trap: it competes with explicit mnemonics for the same keystroke, so it owes
|
|
3979
|
+
a precedence rule *and* a ruling on whether a unique match fires or merely
|
|
3980
|
+
highlights. A separate feature, for a later session.
|
|
3981
|
+
|
|
3982
|
+
**Deferred, each additive:** checkable and disabled items, global-shortcut
|
|
3983
|
+
activation (which needs `Keys` to grow function keys first — and this is the
|
|
3984
|
+
deferral that costs something, since with no Alt the only way to *reach* the bar
|
|
3985
|
+
is Tab, which is what separates `Alt+F, X` from a Tab-hunt), removal and
|
|
3986
|
+
reordering, dynamically computed items, open-on-hover
|
|
3987
|
+
(needs mouse motion — Tuile runs X10 mode 1000, press-only), and Vaadin's
|
|
3988
|
+
collapse-into-an-overflow-menu.
|
|
3989
|
+
|
|
3990
|
+
**Update 2026-08-24: `ContextMenu` is iced indefinitely** — designed, priced and
|
|
3991
|
+
declined the same day, in `D_no_context_menu`. It would have reused `Cascade` and
|
|
3992
|
+
`Item` verbatim, which is why the two consequences above are worded the way they
|
|
3993
|
+
are: the nested `Item` name is *settled* rather than deferred, and the `Popover`
|
|
3994
|
+
extraction trigger keeps only its "first non-`List` content" half.
|
|
3995
|
+
|
|
3996
|
+
## D_outside_click — An outside click dismisses a popup, by flag not by notice (2026-08-24)
|
|
3997
|
+
|
|
3998
|
+
**Status:** Decided and implemented 2026-08-24. Designed in a since-retired
|
|
3999
|
+
`ideas/outside-click-dismiss.md`, itself split out of the declined `ContextMenu`
|
|
4000
|
+
(`D_no_context_menu`), so this entry is the whole record. Supersedes the wart
|
|
4001
|
+
`D_menu_bar` recorded without fixing.
|
|
4002
|
+
|
|
4003
|
+
**Context.** Whether an open overlay closed when you clicked elsewhere depended
|
|
4004
|
+
on what you happened to click *on*. A click on a focusable widget moved focus,
|
|
4005
|
+
and losing focus is what closed `Select`'s dropdown and `MenuBar`'s cascade — so
|
|
4006
|
+
it worked, by accident. A click on decoration (a `Label`, a `Window` border, a
|
|
4007
|
+
gap between fields, the status bar row) did nothing at all, and the overlay
|
|
4008
|
+
stayed open over content it no longer belonged to. Three customers felt it:
|
|
4009
|
+
`Select`, `MenuBar`'s whole cascade, and the sampler's slash-menu demo.
|
|
4010
|
+
|
|
4011
|
+
It could not be fixed inside the widgets. `ScreenPane#handle_mouse` routes a
|
|
4012
|
+
click to the topmost popup containing it, else the tiled content, else (with a
|
|
4013
|
+
modal open) nobody — so a click that misses every popup is never reported to the
|
|
4014
|
+
open overlay, no driver can poll for it, and nothing below can forward it. A
|
|
4015
|
+
`ScreenPane` change or nothing.
|
|
4016
|
+
|
|
4017
|
+
**Decision.** `Component::Popup#close_on_outside_click?` (default `true`, modal
|
|
4018
|
+
or not), read by `ScreenPane#handle_mouse`: a left click that misses an open
|
|
4019
|
+
popup closes it. Beside it, `Popup#on_close`, a driver-facing callback fired from
|
|
4020
|
+
`on_detached`.
|
|
4021
|
+
|
|
4022
|
+
**Why a flag and not `on_outside_click(event)`.** The rejected alternative was
|
|
4023
|
+
notice-shaped: every missed popup gets the event, default no-op, with a
|
|
4024
|
+
driver-facing proc beside it. It works, and it is more expressive. It was
|
|
4025
|
+
rejected because it hands a `MouseEvent` to a component that is *not* on the
|
|
4026
|
+
chain the event was delivered to — structurally the same second delivery this
|
|
4027
|
+
project already rejects for a `Screen`-level click broadcast, just with a shorter
|
|
4028
|
+
subscriber list. Under the flag, `ScreenPane` never delivers anything twice: it
|
|
4029
|
+
closes popups that asked in advance to be closed. **The popup receives a fate,
|
|
4030
|
+
not an event**, and "a click is delivered exactly once, down one chain" stays
|
|
4031
|
+
literally true.
|
|
4032
|
+
|
|
4033
|
+
The price is expressiveness: the popup answers with a stored `true`/`false`, not
|
|
4034
|
+
with an opinion about the click. Paid once, by `ComboBox` — its field is tiled,
|
|
4035
|
+
so clicking your own input to reposition the caret closes the list you are
|
|
4036
|
+
filtering. Transient, because `TextField#on_change` is wired to `refill`, which
|
|
4037
|
+
reopens it on the next keystroke, and Vaadin's ComboBox behaves the same way. If
|
|
4038
|
+
per-click nuance is ever genuinely needed, widen the reader to
|
|
4039
|
+
`close_on_outside_click?(event)` — a pure widening, no migration — rather than
|
|
4040
|
+
reaching for a notice or a veto.
|
|
4041
|
+
|
|
4042
|
+
**The ordering rule, both halves load-bearing.** Snapshot the open popups
|
|
4043
|
+
*before* routing, close the opted-in misses *after*.
|
|
4044
|
+
|
|
4045
|
+
- *Snapshot before*, or a popup the delivered click **opened** is in the set and
|
|
4046
|
+
dismisses itself instantly — every `Select` would be unopenable by mouse.
|
|
4047
|
+
- *Close after*, or a widget toggling its own overlay from a click on its face
|
|
4048
|
+
sees a shut overlay and **reopens** it — a `Select`'s dropdown could then never
|
|
4049
|
+
be dismissed by clicking the Select.
|
|
4050
|
+
|
|
4051
|
+
Both are specced, and both mutations also break *pre-existing* `Select` specs.
|
|
4052
|
+
The snapshot is a fresh array for a third reason: a handler may close further
|
|
4053
|
+
popups, and `@popups` must not be mutated mid-iteration.
|
|
4054
|
+
|
|
4055
|
+
**"Outside" spans the owner chain, and stacking order plays no part.**
|
|
4056
|
+
`Popup#owner` names the component an overlay is *part of* (`nil` = an overlay in
|
|
4057
|
+
its own right). A click keeps the popup it hit *and* every popup that one
|
|
4058
|
+
belongs to, transitively; everything else dismissable closes. The owner is any
|
|
4059
|
+
`Component` — a driver hands its dropdown `self` — and the pane resolves it to
|
|
4060
|
+
the enclosing popup at click time, a `Popup` resolving to itself.
|
|
4061
|
+
|
|
4062
|
+
Two bugs forced this, both found by clicking rather than by reasoning, and both
|
|
4063
|
+
after the naive "closed if it missed my rect" rule had shipped:
|
|
4064
|
+
|
|
4065
|
+
- **A cascade panel is beside its parent, not inside it.** Drilling by mouse
|
|
4066
|
+
(File → Open Recent → Archive) dismissed every shallower panel, so the File
|
|
4067
|
+
menu vanished the moment you clicked into its own submenu.
|
|
4068
|
+
- **A dropdown routinely hangs past its dialog's border.** A `ComboBox` or
|
|
4069
|
+
`Select` on a dialog's lower rows drops a panel outside the dialog's rect, so
|
|
4070
|
+
clicking a row dismissed the dialog. The most common form layout there is.
|
|
4071
|
+
|
|
4072
|
+
Neither is reachable by a widget-local fix: the panels and the dialog are
|
|
4073
|
+
different popups with no way to speak for each other.
|
|
4074
|
+
|
|
4075
|
+
**Two rejected rules, and why order is the wrong axis.** *Dismiss the popups
|
|
4076
|
+
stacked above the one you clicked* (standard light-dismiss layering) fixes both
|
|
4077
|
+
bugs with no new API, and was rejected because `@popups` is insertion order and
|
|
4078
|
+
Tuile has no click-to-raise: the same click would produce different outcomes
|
|
4079
|
+
depending on which overlay opened first. *A click on any overlay dismisses
|
|
4080
|
+
nothing* also fixes both, needs no API at all, and was rejected because it
|
|
4081
|
+
declares unrelated overlays related — it leaves a dropdown open when you click
|
|
4082
|
+
the dialog beneath it, and stops two window-like overlays from dismissing each
|
|
4083
|
+
other, which is exactly what they should do.
|
|
4084
|
+
|
|
4085
|
+
Order is only ever the *shadow* of ownership: a child overlay cannot exist
|
|
4086
|
+
before its host, so it is always later in the stack. Reading the shadow works
|
|
4087
|
+
for related popups and is meaningless for unrelated ones, which is why the
|
|
4088
|
+
relationship is declared instead.
|
|
4089
|
+
|
|
4090
|
+
Consequences kept: every dismissable popup closes, not just the topmost — a
|
|
4091
|
+
cascade must vanish whole on one background click, not peel one panel per click
|
|
4092
|
+
— and two *unrelated* stacked modals both close on one outside click, where
|
|
4093
|
+
Vaadin's curtain would close only the top. Arguably Vaadin-consistent anyway:
|
|
4094
|
+
the Flow Dialog docs say closing a modal Dialog also closes the dialogs opened
|
|
4095
|
+
after it.
|
|
4096
|
+
|
|
4097
|
+
**The cost, stated plainly.** `owner` is a declaration you can forget, and
|
|
4098
|
+
forgetting it silently reproduces the two bugs above. It is the third entry in
|
|
4099
|
+
AGENTS.md's non-modal-overlay traps for that reason. Three sites wire it today:
|
|
4100
|
+
`ComboBox` and `Select` hand their dropdown `self` at construction (not per
|
|
4101
|
+
open, so there is nothing to forget on reopen), and `Cascade#push` chains each
|
|
4102
|
+
panel to the one it dropped out of. Level 0 owns nothing on purpose — a click on
|
|
4103
|
+
a dialog hosting the bar *should* close the whole menu and keep the dialog. A
|
|
4104
|
+
mis-wired cycle terminates rather than hanging, guarded by the walk.
|
|
4105
|
+
|
|
4106
|
+
**Why `on_close` hangs off `on_detached`, never `#close`.** A popup leaves the
|
|
4107
|
+
screen three ways — `Popup#close`, a direct `Screen#remove_popup`, and
|
|
4108
|
+
`Screen#close` → `detach_all`. Hang the proc off `#close` and two of those vanish
|
|
4109
|
+
silently, which is the desync the mechanism exists to kill, reintroduced one
|
|
4110
|
+
level up. `parent=` is already the sole firing site for the lifecycle hooks, so a
|
|
4111
|
+
proc over `on_detached` keeps that true and makes the notice unconditional. The
|
|
4112
|
+
subclass trap that follows: `Notification#on_detached` already existed and now
|
|
4113
|
+
calls `super`.
|
|
4114
|
+
|
|
4115
|
+
`MenuBar::Cascade` is the worked example and the reason the callback exists. It
|
|
4116
|
+
keeps `@levels` as the sole authority on depth, so a panel closing behind its
|
|
4117
|
+
back would leave `depth` / `deepest` / `highlighted` all lying. It wires an
|
|
4118
|
+
identity-keyed, idempotent delete — idempotent because the same notice also
|
|
4119
|
+
arrives from its own `truncate` (which has already popped the entry) and from
|
|
4120
|
+
teardown, in no guaranteed order. That is the shape the house rules ask for:
|
|
4121
|
+
`@levels` is a `D_tree_api`-style second copy of a list slot, and hook-owned
|
|
4122
|
+
state is *synced from an invariant*, not toggled by the hooks (`D_progress_bar`'s
|
|
4123
|
+
`sync_ticker`). Per-level truncate closures wired at `push` are the toggle
|
|
4124
|
+
version.
|
|
4125
|
+
|
|
4126
|
+
**Left button only.** `MouseEvent` is X10 press-only (no release, no motion), so
|
|
4127
|
+
there is no drag case. Excluding scroll is `D_notification`'s stray-spin lesson;
|
|
4128
|
+
excluding `:right` keeps a future context action from nuking an open dropdown.
|
|
4129
|
+
|
|
4130
|
+
**Vaadin, verified against the 24 docs.** "Modal dialogs are closable in three
|
|
4131
|
+
ways: by pressing Esc; clicking outside the Dialog; or programmatically", and
|
|
4132
|
+
"Dialogs are modal by default" — so default-`true`-even-for-modals is the Vaadin
|
|
4133
|
+
behavior, and that is why the default is what it is. What does *not* port: in
|
|
4134
|
+
Vaadin the thing catching the outside click is the modality curtain, part of the
|
|
4135
|
+
overlay, so light dismiss is nearly free because modality is a DOM element.
|
|
4136
|
+
Tuile's modality is a routing rule with nothing to click on, so the notice must
|
|
4137
|
+
be manufactured. Which also means Vaadin's *non-modal* behavior is no precedent
|
|
4138
|
+
here — a non-modal Vaadin Dialog does not light-dismiss; its ComboBox overlay
|
|
4139
|
+
does.
|
|
4140
|
+
|
|
4141
|
+
**The modal/non-modal split dissolves.** The design was framed as two halves,
|
|
4142
|
+
only one with a customer: non-modal overlays needing a notice, and modals unable
|
|
4143
|
+
to hear a click at all. The flag applies identically to both, and no
|
|
4144
|
+
`clicked ||= modal_popup` routing change is needed, because nothing is
|
|
4145
|
+
*delivered* to the modal — it is just closed. An outside click on a modal both
|
|
4146
|
+
dismisses it and is swallowed (click once to dismiss, again to act), same as
|
|
4147
|
+
Vaadin's curtain.
|
|
4148
|
+
|
|
4149
|
+
**Roads not taken.** A veto — `on_close` (or a new hook) returning false to
|
|
4150
|
+
refuse the close: rejected mechanically, since `on_close` fires from
|
|
4151
|
+
`on_detached`, after the popup is off the screen, and you cannot un-detach. Any
|
|
4152
|
+
veto therefore needs a *new*, earlier hook, which is the notice again with a
|
|
4153
|
+
return channel, and it makes every grouped overlay re-implement the geometry
|
|
4154
|
+
test the pane just did. A `Screen`-level "a click landed at P" broadcast any
|
|
4155
|
+
component can subscribe to: rejected on sight, a second mouse-dispatch path
|
|
4156
|
+
beside the one-chain rule. Making `ListDropdown` modal so it hears every click:
|
|
4157
|
+
`ComboBox` and `Select` would lose the events their own faces need. A generation
|
|
4158
|
+
counter to make close-and-reopen-within-one-click safe: over-engineering for a
|
|
4159
|
+
case nothing hits — reopening the *same* popup object during delivery of one
|
|
4160
|
+
click is out of contract (the snapshot holds it), and the answer is a fresh popup
|
|
4161
|
+
or a cleared flag.
|
|
4162
|
+
|
|
4163
|
+
**Per-widget settings.** `Popup` defaults `true`; `ListDropdown` inherits it, so
|
|
4164
|
+
`Select`, `ComboBox` and every cascade panel are fixed with zero wiring;
|
|
4165
|
+
`Notification` sets `false`, since a toast is timed and an unrelated click is not
|
|
4166
|
+
about it; app modals keep `true` and opt out per dialog. The one accepted risk is
|
|
4167
|
+
a stray click discarding a half-filled form dialog.
|
|
4168
|
+
|
|
4169
|
+
## D_no_context_menu — No `ContextMenu`: designed, priced and declined (2026-08-24)
|
|
4170
|
+
|
|
4171
|
+
**Status:** Decided 2026-08-24 — **not building it**, indefinitely. Designed in a
|
|
4172
|
+
since-retired `ideas/context-menu.md` (opened and graduated the same day), so
|
|
4173
|
+
this entry is the whole record. `Context Menu` was *dropped* from
|
|
4174
|
+
`ideas/new-components.md` rather than demoted to its Tier 3, and nothing else
|
|
4175
|
+
tracks it.
|
|
4176
|
+
|
|
4177
|
+
**Context.** The roadmap listed it as a Tier 1 near-freebie — "same as Menu Bar;
|
|
4178
|
+
`:right` already parses" — and after `MenuBar` shipped that looked right: the item
|
|
4179
|
+
tree, the mnemonics, the cascading submenus and the per-level width measurement
|
|
4180
|
+
all exist and would have been reused as they stand. The design confirmed it. The
|
|
4181
|
+
widget then failed on its *inputs*, not on its machinery, which is why this entry
|
|
4182
|
+
is a rejection rather than a deferral.
|
|
4183
|
+
|
|
4184
|
+
**Decision, and the three reasons in order of weight.**
|
|
4185
|
+
|
|
4186
|
+
1. **The gesture that defines the widget is the least reliable input Tuile has.**
|
|
4187
|
+
A context menu *is* right-click, and terminal emulators routinely keep that
|
|
4188
|
+
button for their own menu (some pass it through only with Shift) — on top of
|
|
4189
|
+
mouse reporting being optional in the first place. The keyboard route then has
|
|
4190
|
+
to be invented from nothing: no terminal sends a context-menu event, where a
|
|
4191
|
+
browser hands Vaadin `contextmenu` from Shift+F10 *and* the Menu key, so
|
|
4192
|
+
Vaadin's `ContextMenu` needs no keyboard code at all. **And Shift+F10 is not
|
|
4193
|
+
readable today:** `Keys.getkey` gulps at most 5 bytes after `\e` — deliberately,
|
|
4194
|
+
since 6 would over-read the next event on a mouse burst — while xterm sends
|
|
4195
|
+
`\e[21;2~`, 6 tail bytes, so the `~` would surface as a printable keypress.
|
|
4196
|
+
`\e[29~` (Menu/Apps) and plain F1–F12 *do* fit. That constraint binds anything
|
|
4197
|
+
wanting an exotic key, not just menus.
|
|
4198
|
+
2. **No host wants one.** Not the sampler, not `file_commander`, and the TUI
|
|
4199
|
+
lineages are thin: mc spends F9 on a menu bar instead, Turbo Vision and LazyGit
|
|
4200
|
+
have none. LazyVim is the counterexample — it does ship one — which is an
|
|
4201
|
+
argument for revisiting when a host asks, not for building on spec.
|
|
4202
|
+
3. **It would cost two new framework concepts to serve nobody** — an invisible
|
|
4203
|
+
modal popup as a focus grab, and a `ScreenPane` notice for modality-blocked
|
|
4204
|
+
clicks (the second outlived it; see below).
|
|
4205
|
+
|
|
4206
|
+
**The design that would have been built,** recorded so a revival starts here. One
|
|
4207
|
+
structural fact drives all of it: **a popup can only hold focus if it is modal.**
|
|
4208
|
+
`ScreenPane#handle_key` scopes delivery to `modal_popup || content`, so a *focused
|
|
4209
|
+
non-modal* popup sits outside the key scope and every keystroke goes dead —
|
|
4210
|
+
AGENTS.md's non-modal-overlay trap. There is no third option, and unlike a menu
|
|
4211
|
+
bar a context menu has no strip to park focus on.
|
|
4212
|
+
|
|
4213
|
+
So: `ContextMenu < Popup(modal: true)` with a **zero-size rect that paints
|
|
4214
|
+
nothing** — not a picture but a *grab*, playing exactly the role `MenuBar`'s strip
|
|
4215
|
+
plays (focus holder, key scope, lifecycle owner, outside-click sink). Every
|
|
4216
|
+
visible panel, level 0 included, is a `Cascade` level, so `Cascade` and `Item` are
|
|
4217
|
+
reused verbatim and mnemonics work with no new code at all. Modality then hands
|
|
4218
|
+
over focus save/restore (`@popup_prior_focus`), an inert Tab (`cycle_focus` scopes
|
|
4219
|
+
stops to `modal_popup`, and a grab has none) and click-blocking for free. Two
|
|
4220
|
+
openers, because the desktop lineages agree these are different placements:
|
|
4221
|
+
`open_at(point)` for the mouse, `open_below(rect)` for the keyboard — pointer
|
|
4222
|
+
versus selection. Framework growth: `ListDropdown#anchor_at(point)`, a second
|
|
4223
|
+
level-0 entry point on `Cascade`, the blocked-click notice, and overrides for
|
|
4224
|
+
`reposition` (or close-on-resize, as `MenuBar#rect=` does), for `q`/ESC — `q` has
|
|
4225
|
+
to stay available as a mnemonic — and for `keyboard_hint`.
|
|
4226
|
+
|
|
4227
|
+
**Alternatives rejected.**
|
|
4228
|
+
|
|
4229
|
+
- **Host-driven, no new machinery** — a plain object the host wires from its own
|
|
4230
|
+
`handle_mouse` / `handle_key`, i.e. `MenuBar`'s architecture minus the
|
|
4231
|
+
component. It costs the framework nothing, and that is the trap: `MenuBar`
|
|
4232
|
+
encodes five invariants *once* because it is a component — close on focus loss,
|
|
4233
|
+
on detach, on resize, swallow keys while open, forward the mouse — and every
|
|
4234
|
+
host would re-encode all five. Forgetting `on_detached` strands panels on the
|
|
4235
|
+
pane with nothing to take them down, the exact bug class AGENTS.md's
|
|
4236
|
+
non-modal-overlay section exists to prevent.
|
|
4237
|
+
- **The level-0 panel *as* the modal popup**, which deletes the invisible
|
|
4238
|
+
component. Rejected because level 0 then becomes structurally unlike every
|
|
4239
|
+
deeper level, so the panel-driving logic — `MOVE_KEYS` to the highlight, Enter
|
|
4240
|
+
to drill-or-fire, mnemonic match, truncate-on-cursor-move — exists twice for
|
|
4241
|
+
panels that are identical on screen. It buys only the deletion of a zero-size
|
|
4242
|
+
rect.
|
|
4243
|
+
- **Recursive modal popups, one per level, no `Cascade`** — each level an ordinary
|
|
4244
|
+
modal `Popup` over a *focusable* `List`, with `Popup`'s own ESC/`q` closing a
|
|
4245
|
+
level. Genuinely tiny and free of every non-modal trap, and rejected on the
|
|
4246
|
+
smell: it is a *second* menu mechanism, so item trees, mnemonics, submenu
|
|
4247
|
+
arrows, width measurement and the key map would all get a second
|
|
4248
|
+
implementation. If it is right, `MenuBar` is wrong — a much larger argument
|
|
4249
|
+
than this widget.
|
|
4250
|
+
- **A `Component#context_menu=` slot** checked inside `Component#handle_key`, so
|
|
4251
|
+
any component gets one by assignment. Half a feature: almost no widget calls
|
|
4252
|
+
`super` from its own `handle_key` (`List` doesn't), so it would work for
|
|
4253
|
+
ancestors that don't override and silently not for focused leaves.
|
|
4254
|
+
- **Vaadin's `setTarget(component)`** — attach the menu to a target and let the
|
|
4255
|
+
framework route the right-click to it. Tuile has nothing to build that on:
|
|
4256
|
+
`handle_mouse` returns `void`, and a right-click already reaches *every*
|
|
4257
|
+
component along the rect chain, ancestor first and deepest last, so "which
|
|
4258
|
+
target owns this click" has no answer. (What that ordering *would* give free is
|
|
4259
|
+
deepest-wins, if a revival adds "opening one closes any other open context
|
|
4260
|
+
menu" — the `D_notification` shape, found by scanning the popups stack rather
|
|
4261
|
+
than a class ivar.)
|
|
4262
|
+
- **Type-ahead search inside an open menu**, which `List#select_next` makes nearly
|
|
4263
|
+
free. Same rejection as in `D_menu_bar`: it competes with explicit mnemonics for
|
|
4264
|
+
the same keystroke and owes a precedence rule.
|
|
4265
|
+
|
|
4266
|
+
**Two gaps it surfaced that outlive it.**
|
|
4267
|
+
|
|
4268
|
+
- **An outside click on an open overlay notified nobody.** `Select`, `MenuBar`
|
|
4269
|
+
and the sampler's slash menu all lingered on a click that landed on decoration,
|
|
4270
|
+
and a modal popup could not dismiss on an outside click at all. **Closed**
|
|
4271
|
+
2026-08-24 by `D_outside_click`, which also dissolved the modal/non-modal split
|
|
4272
|
+
the gap was framed around.
|
|
4273
|
+
- **A right-click does not move a `List` cursor.** `List::Cursor#handle_mouse`
|
|
4274
|
+
acts on `:left` only (specced), and there is no public `item_index_at(point)`,
|
|
4275
|
+
so "act on the row I clicked" is unsayable unless the app computes
|
|
4276
|
+
`event.y - rect.top + scroll_top_row` itself. Nothing needs it today; it is the
|
|
4277
|
+
same shape of hole as the `List#select(index)` gap `D_menu_bar` had to fill.
|
|
4278
|
+
|
|
4279
|
+
---
|
|
4280
|
+
|
|
4281
|
+
## D_status_bar — Delete the framework status bar; the app owns its bottom row (2026-08-25)
|
|
4282
|
+
|
|
4283
|
+
**Status:** Accepted 2026-08-25; unimplemented. Supersedes the shipped
|
|
4284
|
+
`ScreenPane#status_bar` slot and the `Component#keyboard_hint` channel that fed
|
|
4285
|
+
it — see *the scar* at the end. Retires `ideas/status-bar-ownership.md`.
|
|
4286
|
+
|
|
4287
|
+
**Context.** `ScreenPane` has always reserved the bottom terminal row for a
|
|
4288
|
+
framework-owned `Label`, and `Screen#refresh_status_bar` filled it on every
|
|
4289
|
+
focus change from three sources: a hardcoded `"q quit"`, the `hint:` strings on
|
|
4290
|
+
registered global shortcuts, and one component's `keyboard_hint` — the innermost
|
|
4291
|
+
active `Window` (found by an `is_a?` scan) when tiled, the top popup's *direct*
|
|
4292
|
+
content when not.
|
|
4293
|
+
|
|
4294
|
+
That last source barely worked. Of the seven `keyboard_hint` implementations,
|
|
4295
|
+
only `Window`, `Popup` and `PickerWindow` (a `Window`) were reachable in any
|
|
4296
|
+
configuration; `MenuBar`, `Tabs`, `Select` and `ComboBox` were dead
|
|
4297
|
+
**everywhere**, tiled and popup alike, because nothing walked down to the
|
|
4298
|
+
focused component and the popup path forwarded only to its direct child. The
|
|
4299
|
+
obvious fix — ask `screen.focused` and walk up, matching the delivery bubble —
|
|
4300
|
+
was drafted, and a survey of the two real consumers was run to choose between
|
|
4301
|
+
it and two variants. The survey concluded the channel should be **deleted**.
|
|
4302
|
+
|
|
4303
|
+
**Decision.** Delete the status bar and the hint channel. `ScreenPane` no longer
|
|
4304
|
+
owns a `Label`, no longer reserves `height - 1`, and `Component#keyboard_hint`
|
|
4305
|
+
ceases to exist. In its place `Screen` gains one notification —
|
|
4306
|
+
`on_focus_changed=`, a plain proc fired from `focused=`, matching the
|
|
4307
|
+
`on_theme_changed=` style stock assemblies already use. An app that wants a
|
|
4308
|
+
status bar builds one:
|
|
4309
|
+
|
|
4310
|
+
```ruby
|
|
4311
|
+
bar = Tuile::Component::Label.new
|
|
4312
|
+
root = Tuile::Component::Layout::Vertical.new
|
|
4313
|
+
root.add(main, Expand)
|
|
4314
|
+
root.add(bar, Fixed[1])
|
|
4315
|
+
screen.on_focus_changed = -> { bar.text = hint_for(screen.focused) }
|
|
4316
|
+
```
|
|
4317
|
+
|
|
4318
|
+
**Why deletion beat a better hint source.**
|
|
4319
|
+
|
|
4320
|
+
- **No app has ever wanted a *widget's* hint.** Across four apps, virtui
|
|
4321
|
+
advertises window-level app keys (`"p Power v run Viewer m Memory d toggle
|
|
4322
|
+
Disk stat / Search"`) and pikuri-tui advertises global app keys (`"^K menu"`,
|
|
4323
|
+
`"^C cancel"`). Neither has ever advertised a `Select`'s or `ComboBox`'s keys.
|
|
4324
|
+
The channel was not merely unused by four of its seven implementors — the
|
|
4325
|
+
thing it was designed to carry is something nobody wants carried.
|
|
4326
|
+
- **An app was routing presentation through dispatch.** pikuri re-registers a
|
|
4327
|
+
global keybinding to change a status-bar string, and documents the technique
|
|
4328
|
+
in rdoc: "`Screen` replaces the binding in place on re-register, so this is
|
|
4329
|
+
also how the hint stays in sync with the counter." The bar was write-only from
|
|
4330
|
+
the app's side, so a *text* change had to be expressed as a *binding* change.
|
|
4331
|
+
That is the design inverted, not a missing feature — and it is the single
|
|
4332
|
+
finding that settled this.
|
|
4333
|
+
- **The one reachable widget hint was also stale.** `MenuBar#keyboard_hint`
|
|
4334
|
+
switched to `"↑↓ move ⏎ select"` with the cascade open, but the cascade is a
|
|
4335
|
+
non-focusable `ListDropdown`, so focus never changed and `refresh_status_bar`
|
|
4336
|
+
never ran (it fired from `focused=`, `theme=` and the two registry mutators —
|
|
4337
|
+
never from `add_popup`). Opening a menu did not update the bar; *closing* it
|
|
4338
|
+
did, via focus repair. Dead twice over.
|
|
4339
|
+
- **The bar is a layout special case that `Box` layouts obsoleted.** The
|
|
4340
|
+
`height - 1` reservation is v0.1-era, from before `Vertical`/`Fixed` existed.
|
|
4341
|
+
An app-owned bar is now three lines, and buys what the framework can never
|
|
4342
|
+
offer: two rows, a bar at the top, its own styling, a file-commander
|
|
4343
|
+
function-key strip, or nothing at all.
|
|
4344
|
+
- **It is the shape the top-down re-grow rule already governs.** That rule says
|
|
4345
|
+
a deleted bottom-up channel may return only as an *optional, read-only,
|
|
4346
|
+
caller-side query*, never as an automatic channel the framework consults.
|
|
4347
|
+
`keyboard_hint` was an automatic channel; deleting it applies the rule Tuile
|
|
4348
|
+
already lives by.
|
|
4349
|
+
- **The framework baked an app policy.** The `"q quit"` prefix was
|
|
4350
|
+
unconditional: pikuri's three apps quit via `^K → q`, and their bar read
|
|
4351
|
+
`q quit ^K menu` while `q` typed into the focused input just typed a `q`.
|
|
4352
|
+
|
|
4353
|
+
**Alternatives rejected.**
|
|
4354
|
+
|
|
4355
|
+
- *Walk the focus chain and concatenate (the drafted fix).* Correct as far as it
|
|
4356
|
+
went — it matched the delivery bubble, subsumed the popup special case, and
|
|
4357
|
+
would have deleted `active_window`. Rejected because it fixes *reachability*
|
|
4358
|
+
while leaving ownership where it hurts: pikuri's re-registration hack survives
|
|
4359
|
+
it untouched, and MenuBar's flickering, redundant `←→ menu ⏎ open` becomes
|
|
4360
|
+
*visible* rather than merely dead. It also forced a ruling on hint ordering
|
|
4361
|
+
that is really a truncation policy, since `Label` ellipsizes and the rightmost
|
|
4362
|
+
hint silently vanishes on a narrow terminal.
|
|
4363
|
+
- *Ask `active_window` and forward down the active chain.* Keeps `Window` as the
|
|
4364
|
+
unit of "what am I looking at" but re-implements the focus walk, and preserves
|
|
4365
|
+
the framework's only place where a *class* is special-cased for behavior.
|
|
4366
|
+
- *Keep the bar, make it optional.* A `status_bar: false` flag leaves every
|
|
4367
|
+
defect in place for whoever leaves it on, and adds framework surface in the
|
|
4368
|
+
middle of an argument for less of it.
|
|
4369
|
+
- *Drop only `MenuBar#keyboard_hint`.* Treats the symptom. Three other widget
|
|
4370
|
+
hints stay dead, and the ownership inversion is untouched.
|
|
4371
|
+
- *Keep `Component#keyboard_hint` as a documented seam, delete only the
|
|
4372
|
+
renderer.* Tempting — it preserves a common vocabulary for a future component
|
|
4373
|
+
ecosystem. Rejected for now because a seam with no framework consumer is
|
|
4374
|
+
precisely the automatic-channel-with-no-caller the re-grow rule exists to
|
|
4375
|
+
prevent, and because the built-in hints it would preserve are the four nobody
|
|
4376
|
+
wants. See the re-grow shape below.
|
|
4377
|
+
|
|
4378
|
+
**Consequences — what was given up, honestly.**
|
|
4379
|
+
|
|
4380
|
+
- **Zero-config batteries are gone.** `book/01-first-app.md` said "you never
|
|
4381
|
+
created a status bar, yet the app has one", and `hello_world_spec` asserted on
|
|
4382
|
+
`q quit`. A first app now shows an empty bottom row until it builds one. Ruled
|
|
4383
|
+
acceptable: a bar the app cannot drive is not a battery, and ch1 gains a
|
|
4384
|
+
better story once the bar is three lines of `Vertical`.
|
|
4385
|
+
- **A widget's keys are no longer self-describing.** An app that *does* want to
|
|
4386
|
+
advertise a `ComboBox`'s keys must hardcode `"↑↓ select ⏎ accept"` itself,
|
|
4387
|
+
duplicating knowledge that lived in the widget. No app has ever done this, but
|
|
4388
|
+
the duplication is real if one starts.
|
|
4389
|
+
- **`book/05-focus.md`'s "The status bar writes itself" section goes.** It
|
|
4390
|
+
claimed the bar was "driven by focus" and showed "the focused context's own
|
|
4391
|
+
advertised hint" — behavior that never existed; focus only triggered the
|
|
4392
|
+
rebuild. Deleting it removes a documented promise the code never kept.
|
|
4393
|
+
- **`D_boolean_fields`' aside is retired**, not overruled: "hints are a
|
|
4394
|
+
window/popup-level affordance; per-field hints would drown the status bar" was
|
|
4395
|
+
an argument about where a hint belongs, and there is no longer a framework
|
|
4396
|
+
hint to place.
|
|
4397
|
+
- **A modal {Component::Popup} no longer shows how to close itself.**
|
|
4398
|
+
`Popup#keyboard_hint`'s `q Close` was the only affordance, and — unlike
|
|
4399
|
+
`PickerWindow`'s hint, which merely repeated the option keys its own `List`
|
|
4400
|
+
rows already paint — nothing else on screen carries it. `popup.rb`'s `q`/ESC
|
|
4401
|
+
handler is untouched, so the behavior remains; only the advertisement is gone.
|
|
4402
|
+
**Ruled acceptable 2026-08-25 on the Vaadin precedent:** a Vaadin `Dialog`
|
|
4403
|
+
closes on ESC and no Vaadin *app* documents that anywhere — it lives in the
|
|
4404
|
+
framework's own docs and javadoc, which end users never read. ESC-dismisses-an
|
|
4405
|
+
-overlay is a convention the user brings with them, not something each app has
|
|
4406
|
+
to teach. An app that wants it spelled out writes it into its own row.
|
|
4407
|
+
|
|
4408
|
+
**The `q`/ESC quit fallback stays** (`Screen#event_loop`:
|
|
4409
|
+
`@event_queue.stop if !handled && ["q", Keys::ESC].include?(key)`). It is the
|
|
4410
|
+
same baked app policy as the `"q quit"` string, but it is *dispatch*, not
|
|
4411
|
+
presentation, and it is separable — deleting it would make every example and
|
|
4412
|
+
both downstream apps grow a quit handler in the same breath as an unrelated
|
|
4413
|
+
change. **Ruled 2026-08-25 by `D_quit_key`: it stays, unadvertised**, on the
|
|
4414
|
+
same convention argument as the popup's lost `q Close` above.
|
|
4415
|
+
|
|
4416
|
+
**There is no app-facing `keyboard_hint` convention, and the book must not
|
|
4417
|
+
teach one.** The first cut of `examples/file_commander.rb` kept a
|
|
4418
|
+
`PaneWindow#keyboard_hint` and walked up the focus chain via
|
|
4419
|
+
`respond_to?(:keyboard_hint)` to find it — which re-created the deleted seam by
|
|
4420
|
+
convention, in three places at once (the example, the book, virtui), with a
|
|
4421
|
+
duck-type where a declared method used to be. It was also *dead*: both panes
|
|
4422
|
+
were `PaneWindow`s returning the same constant, so the focus hook, the walk and
|
|
4423
|
+
the duck-type together computed a value that never changed. The example is now
|
|
4424
|
+
a static `Label` and `PaneWindow` is gone; book ch5 leads with "a status line is
|
|
4425
|
+
a `Label` in your layout", and treats {Screen#on_focus_changed=} as the
|
|
4426
|
+
*exception* for a row that genuinely varies. The walk survives only in virtui,
|
|
4427
|
+
where three windows really do advertise different keys — as one app's design
|
|
4428
|
+
decision, named as such.
|
|
4429
|
+
|
|
4430
|
+
**Re-grow rule.** A hint channel may come back only as **a query the app pulls,
|
|
4431
|
+
never a channel the framework pushes** — and specifically not as a
|
|
4432
|
+
framework-owned row. Textual is the shape to copy if it does: its `Footer` is a
|
|
4433
|
+
widget the app mounts in `compose()`, reading from the `BINDINGS` table the
|
|
4434
|
+
framework owns ⚠. That splits ownership at the right seam — the app decides
|
|
4435
|
+
whether a bar exists and where, the widget declares its keys — and it is already
|
|
4436
|
+
on record as steal-candidate #1 in `D_key_dispatch`. Bringing back a bar the
|
|
4437
|
+
framework *places* reopens this entry.
|
|
4438
|
+
|
|
4439
|
+
**Prior art** (surveyed 2026-08-25; ⚠ marks memory-based claims worth checking
|
|
4440
|
+
before acting). The honest reading is that a framework-owned status *row* is a
|
|
4441
|
+
minority position, and the one framework that does it well does not own the row:
|
|
4442
|
+
|
|
4443
|
+
| | Owns a status row? | Where the text comes from |
|
|
4444
|
+
|---|---|---|
|
|
4445
|
+
| **Turbo Vision** | yes — `TStatusLine`, always present | declarative `TStatusDef` tables keyed by help context ⚠ |
|
|
4446
|
+
| **Textual** | no — `Footer` is a widget you mount | the framework's `BINDINGS` tables ⚠ |
|
|
4447
|
+
| **Swing** | no | app-written `JLabel` in `BorderLayout.SOUTH` |
|
|
4448
|
+
| **ncurses / Bubbletea / Ratatui** | no | app draws every cell |
|
|
4449
|
+
| **Tuile (before)** | yes — `ScreenPane#status_bar` | `active_window&.keyboard_hint` + registry hints + `"q quit"` |
|
|
4450
|
+
| **Tuile (after)** | no | app-drawn, from `on_focus_changed` |
|
|
4451
|
+
|
|
4452
|
+
Turbo Vision is the only real precedent for the shipped design, and it paired
|
|
4453
|
+
the row with a declarative binding table — the half Tuile never had, which is
|
|
4454
|
+
why its bar could only be fed by an inverted registration hack.
|
|
4455
|
+
|
|
4456
|
+
**The scar.** The status bar was never designed for Tuile. It arrived whole in
|
|
4457
|
+
`4491a77`, the 0.1.0 commit that ported virtui's `lib/ttyui/` under the `Tuile`
|
|
4458
|
+
namespace — it was *virtui's* status bar, generalized by accident of extraction,
|
|
4459
|
+
and virtui is to this day the only app using the `keyboard_hint` half. It then
|
|
4460
|
+
survived every later overhaul (the top-down layout rewrite, the key-ladder
|
|
4461
|
+
deletion, the tree-first split) without anyone asking who it was for, while each
|
|
4462
|
+
new widget dutifully grew a hint nobody could see. The tell sat in the code the
|
|
4463
|
+
whole time: `Screen#active_window` was public API with exactly one caller —
|
|
4464
|
+
this one — and no app ever invoked it.
|
|
4465
|
+
|
|
4466
|
+
---
|
|
4467
|
+
|
|
4468
|
+
## D_quit_key — `q` / ESC quit the loop, unadvertised, as a Tuile quirk (2026-08-25)
|
|
4469
|
+
|
|
4470
|
+
**Status:** Accepted 2026-08-25; no code change — this records a decision to
|
|
4471
|
+
*keep* what ships. Closes the question `D_status_bar` deferred.
|
|
4472
|
+
|
|
4473
|
+
**Context.** `Screen#event_loop` ends with
|
|
4474
|
+
`@event_queue.stop if !handled && ["q", Keys::ESC].include?(key)` — after the
|
|
4475
|
+
three-rung ladder has declined a key, bare `q` or ESC stops the loop and the
|
|
4476
|
+
app exits. It is app policy the framework enforces, and no app opted into it.
|
|
4477
|
+
|
|
4478
|
+
`D_status_bar` deleted the framework status bar and with it the hardcoded
|
|
4479
|
+
`"q quit"` prefix that was this fallback's only advertisement, deliberately
|
|
4480
|
+
leaving the behavior alone as a separate question. That left the least coherent
|
|
4481
|
+
state of the three: a hardcoded quit key with nothing anywhere surfacing it.
|
|
4482
|
+
|
|
4483
|
+
**Decision.** Keep it exactly as it is, unadvertised, and stop treating it as an
|
|
4484
|
+
open question.
|
|
4485
|
+
|
|
4486
|
+
- **It is a convention, not an invention.** `q` quits `less`, `man`, `top`,
|
|
4487
|
+
`htop` and every pager git shells out to; ESC dismisses. A user arriving at a
|
|
4488
|
+
full-screen terminal app already tries both. That is the same argument that
|
|
4489
|
+
settled the popup's lost `q Close` hint in `D_status_bar` — a convention the
|
|
4490
|
+
user brings is not something each app must teach.
|
|
4491
|
+
- **The escape hatch already exists and needs no new surface.** A component
|
|
4492
|
+
keeps `q` by consuming it, which is the whole of `D_key_dispatch`'s
|
|
4493
|
+
delivery rung: a focused {Component::TextField} does it for free (`q` is
|
|
4494
|
+
printable — this is why pikuri-tui's shells never quit on a typed `q`), and an
|
|
4495
|
+
app wanting `q` as a command binds it in the scope root's `handle_key`. ESC
|
|
4496
|
+
likewise never reaches the loop while a {Component::Popup} is open, because
|
|
4497
|
+
the popup consumes it first.
|
|
4498
|
+
- **It is genuinely useful for the small app.** `examples/hello_world.rb` is
|
|
4499
|
+
eleven lines and needs no quit handler. Deleting the fallback would make every
|
|
4500
|
+
example and both downstream apps grow one, buying nothing.
|
|
4501
|
+
|
|
4502
|
+
**Alternatives rejected.**
|
|
4503
|
+
|
|
4504
|
+
- *Delete it; apps handle their own quit.* The clean-architecture answer, and
|
|
4505
|
+
the one consistent with deleting the status bar. Rejected because the two are
|
|
4506
|
+
not the same shape: the status bar was a *row the app could not write to* —
|
|
4507
|
+
it actively blocked apps (pikuri had to re-register a keybinding to change
|
|
4508
|
+
text) — whereas this fallback blocks nothing. Any component can take the key.
|
|
4509
|
+
A rule the app can override on the spot is a default, not a policy.
|
|
4510
|
+
- *Make it opt-in (`Screen#quit_on_q=`).* Adds framework surface for a knob
|
|
4511
|
+
nobody has asked for, in the middle of an argument for less of it, and the
|
|
4512
|
+
override it provides is one the key ladder already gives for free.
|
|
4513
|
+
- *Re-advertise it somehow.* That is the framework-owned status row again.
|
|
4514
|
+
|
|
4515
|
+
**Consequences.**
|
|
4516
|
+
|
|
4517
|
+
- **It is undiscoverable from inside the app**, and that is accepted. An app
|
|
4518
|
+
that wants it spelled out writes `q quit` into its own status line —
|
|
4519
|
+
`examples/hello_world.rb`, `examples/file_commander.rb` and virtui all do;
|
|
4520
|
+
pikuri-tui deliberately does not, because its focused input eats `q` and the
|
|
4521
|
+
hint would be a lie.
|
|
4522
|
+
- **`q` is reserved-ish for a scope root.** An app binding bare `q` in
|
|
4523
|
+
`handle_key` must return `true`, or the key falls through and quits the app —
|
|
4524
|
+
a surprising bug the book calls out (ch5) and this entry pins.
|
|
4525
|
+
- **What would reopen it:** a real app that needs bare `q` at the scope root and
|
|
4526
|
+
finds consuming it awkward, or a second key wanting the same treatment (which
|
|
4527
|
+
would make this a *list*, and a list wants a knob).
|
|
4528
|
+
|
|
4529
|
+
---
|
|
4530
|
+
|
|
4531
|
+
## D_hook_visibility — A framework-invoked hook is protected, reached with `__send__` (2026-08-30)
|
|
4532
|
+
|
|
4533
|
+
**Status:** Accepted and implemented for {Component#on_theme_changed}
|
|
4534
|
+
(protected; `Screen#theme=` fans it out through `__send__`). `Component#on_focus`
|
|
4535
|
+
is the one framework-invoked hook still public — see *Consequences*.
|
|
4536
|
+
|
|
4537
|
+
**Context — the field report.** virtui crashed on an OS appearance flip:
|
|
4538
|
+
|
|
4539
|
+
```
|
|
4540
|
+
NoMethodError: protected method `on_theme_changed' called for an instance of UI::VMWindow
|
|
4541
|
+
```
|
|
4542
|
+
|
|
4543
|
+
Three of its `Window` subclasses group their overrides together —
|
|
4544
|
+
`on_width_changed`, `on_theme_changed`, `repaint_border` — under one `protected`
|
|
4545
|
+
keyword. Two of those three are protected in Tuile; the third was **public**,
|
|
4546
|
+
because `Screen#theme=` fanned it out as `@pane&.on_tree(&:on_theme_changed)`, an
|
|
4547
|
+
explicit-receiver send. Ruby lets a subclass *narrow* an inherited method, so the
|
|
4548
|
+
natural grouping silently broke the walk.
|
|
4549
|
+
|
|
4550
|
+
The failure is worse than one exception. `theme=` assigns `@theme` *before* the
|
|
4551
|
+
walk, so the theme really does swap; the walk then dies at the first offender in
|
|
4552
|
+
pre-order, every component after it never hears the hook, and the closing
|
|
4553
|
+
`needs_full_repaint` never runs — the new theme is live under content painted
|
|
4554
|
+
for the old one, until something unrelated invalidates. And nothing catches it in
|
|
4555
|
+
a test suite that never flips the theme.
|
|
4556
|
+
|
|
4557
|
+
**Decision.** A hook the *framework* calls on a component is plumbing an app
|
|
4558
|
+
overrides and never invokes, so it is `protected`, and the framework reaches it
|
|
4559
|
+
with `__send__`:
|
|
4560
|
+
|
|
4561
|
+
```ruby
|
|
4562
|
+
@pane&.on_tree { _1.__send__(:on_theme_changed) }
|
|
4563
|
+
```
|
|
4564
|
+
|
|
4565
|
+
`__send__` is the point, not a workaround for the visibility change: it ignores
|
|
4566
|
+
visibility, so an override may be public, protected or private and the walk can
|
|
4567
|
+
no longer be broken from an app at all. The public half of the seam is the
|
|
4568
|
+
*listener* (`on_theme_changed=`), which is what an app assembling stock
|
|
4569
|
+
components actually calls.
|
|
4570
|
+
|
|
4571
|
+
**Why the visibility can't just be finessed by *who* does the walking.** Ruby
|
|
4572
|
+
checks a protected call against the class the method is *defined in*, relative to
|
|
4573
|
+
the caller's `self` — so an override defined in a subclass is unreachable by
|
|
4574
|
+
explicit receiver from anywhere else, including a sibling component and even the
|
|
4575
|
+
base class:
|
|
4576
|
+
|
|
4577
|
+
```ruby
|
|
4578
|
+
class Base; def fan(o) = o.hook; protected; def hook = "base"; end
|
|
4579
|
+
class Sub < Base; protected; def hook = "sub"; end
|
|
4580
|
+
class Other < Base; end
|
|
4581
|
+
Other.new.fan(Sub.new) # NoMethodError
|
|
4582
|
+
Base.new.fan(Sub.new) # NoMethodError — same reason
|
|
4583
|
+
```
|
|
4584
|
+
|
|
4585
|
+
That leaves exactly two workable shapes: `__send__`, or an *implicit* receiver.
|
|
4586
|
+
{Component#fire_lifecycle} is the implicit-receiver one — it recurses through a
|
|
4587
|
+
`Component`-defined method and calls `on_attached` / `on_detached` on `self`,
|
|
4588
|
+
which is why those two have been quietly protected all along and why this class
|
|
4589
|
+
of bug never reached them.
|
|
4590
|
+
|
|
4591
|
+
**Alternatives rejected.**
|
|
4592
|
+
|
|
4593
|
+
- *Keep the hook public and document "don't narrow it".* The documentation
|
|
4594
|
+
nobody reads, guarding a trap the natural code layout walks straight into —
|
|
4595
|
+
virtui's three sites are the proof, and grouping hooks under one `protected` is
|
|
4596
|
+
good Ruby, not a mistake to correct. A rule that fires an exception in
|
|
4597
|
+
production, at OS-flip time, in a path no suite exercises, is not a rule; it is
|
|
4598
|
+
a landmine.
|
|
4599
|
+
- *A public `Component#fire_theme_changed` walker* — the `fire_lifecycle` shape,
|
|
4600
|
+
hoisted to public so `Screen` can start it, calling the protected hook on
|
|
4601
|
+
`self` at each node. It genuinely works and needs no `__send__`. Rejected on
|
|
4602
|
+
surface: it puts a second public method on every component (in the rdoc, in
|
|
4603
|
+
`sig/tuile.rbs`, callable by apps) and duplicates {Component#on_tree}, to avoid
|
|
4604
|
+
one `__send__` at one call site. It also only relocates the hazard — the
|
|
4605
|
+
*walker* becomes the method that must not be narrowed.
|
|
4606
|
+
- *Rescue `NoMethodError` around the walk.* Swallows real bugs inside app hooks
|
|
4607
|
+
and leaves the restyle half-applied, which is the symptom being fixed.
|
|
4608
|
+
- *Make the call tolerant but leave the hook public.* Fixes today's crash and
|
|
4609
|
+
keeps the trap armed for the next contributor who writes `&:some_hook`; the
|
|
4610
|
+
hook's visibility is what states the intent.
|
|
4611
|
+
|
|
4612
|
+
**Consequences.**
|
|
4613
|
+
|
|
4614
|
+
- **The `attr_writer` stays public**, and the reader is now protected — an
|
|
4615
|
+
asymmetric accessor pair, deliberately: assigning a listener is app-facing,
|
|
4616
|
+
firing it is not.
|
|
4617
|
+
- **A new framework-invoked hook copies this shape**: protected, `__send__` at
|
|
4618
|
+
the fan-out, listener writer public if it has one. Never `&:hook`.
|
|
4619
|
+
- **`on_focus` stays public, deliberately** — it is not plumbing in the same
|
|
4620
|
+
sense. {Component::HasContent} / {Component::Layout} / {Component::TabSheet}
|
|
4621
|
+
each override it to forward focus into their content, so it reads as part of
|
|
4622
|
+
the composition seam a mixin publishes rather than as a private notification.
|
|
4623
|
+
It carries the same theoretical hazard (an app narrowing its override would
|
|
4624
|
+
break `Screen#focused=`), and that is accepted: nothing has hit it, and
|
|
4625
|
+
protecting a method three mixins present as interface would cost more clarity
|
|
4626
|
+
than it buys.
|
|
4627
|
+
- **Specs call the hook with `send`**, and two guards exist: `component_spec`
|
|
4628
|
+
asserts the visibility pair, `screen_spec` asserts that a subclass declaring a
|
|
4629
|
+
`protected` override is still fired *and* that the walk continues past it.
|
|
4630
|
+
|
|
4631
|
+
## D_overlay — Extract `Overlay`; `Popup` becomes unconditionally modal (2026-08-30)
|
|
4632
|
+
|
|
4633
|
+
**Decision.** Split {Component::Popup} in two. `Overlay < Component` is the bare
|
|
4634
|
+
floating layer — the mount/dismiss lifecycle, `owner`, `on_close`,
|
|
4635
|
+
`close_on_outside_click`, a no-op `reposition`, and the full-repaint escalation
|
|
4636
|
+
in `rect=` — and `Popup < Overlay` adds the modal dialog on top: a declared
|
|
4637
|
+
`size`, self-centering, `focusable?`, and ESC/`q`. `Popup.new(modal: false)` is
|
|
4638
|
+
gone; the `@modal` ivar with it, since `modal?` is now a constant on each class.
|
|
4639
|
+
`Notification` and `ListDropdown` both reparent onto `Overlay`.
|
|
4640
|
+
|
|
4641
|
+
**Why.** The cut line was not invented — it is exactly what `ScreenPane` calls on
|
|
4642
|
+
a member of `@popups` (`rect`, `modal?`, `reposition`, `owner`,
|
|
4643
|
+
`close_on_outside_click?`, `close`, `on_tree`), so `Overlay` makes an interface
|
|
4644
|
+
that already existed implicitly into a class. What forced it was the tally: both
|
|
4645
|
+
non-modal subclasses *rejected* most of `Popup`. `Notification` overrode
|
|
4646
|
+
`focusable?`, `tab_stop?`, `reposition` and `handle_mouse`, and had to **raise**
|
|
4647
|
+
from `size=` to fight off an inherited feature; `ListDropdown` called `self.size
|
|
4648
|
+
=` only to stop `Popup#reposition` stomping its anchored placement. A base whose
|
|
4649
|
+
contract is "remember to switch four inherited behaviours off" fails the `cop`
|
|
4650
|
+
skill's *the base must earn its place — it permits, it doesn't mandate*, and this
|
|
4651
|
+
file's own AGENTS.md section "Non-modal overlays — two traps a new one will hit"
|
|
4652
|
+
was that fragile-base-class tax written down in prose because it could not be
|
|
4653
|
+
written in types.
|
|
4654
|
+
|
|
4655
|
+
**What it bought, beyond tidiness.**
|
|
4656
|
+
- *A latent bug, fixed by construction.* `ListDropdown` declared
|
|
4657
|
+
`focusable?`/`tab_stop?` on its inner `Menu` and never on itself, so it
|
|
4658
|
+
inherited `Popup#focusable? == true`. It survived only because `layout` makes
|
|
4659
|
+
the content cover the whole rect, so `HasContent#handle_mouse` forwarded every
|
|
4660
|
+
in-rect click before `Component#handle_mouse` could assign focus — safe by
|
|
4661
|
+
*geometry*, not by declaration. A border or an inset would have landed focus
|
|
4662
|
+
outside the key scope and killed every keystroke until Tab.
|
|
4663
|
+
- *`Notification`'s raising `size=` is deleted, not renamed.* An `Overlay` has no
|
|
4664
|
+
declared box, so there is nothing to refuse.
|
|
4665
|
+
- *`anchor_to` stops double-assigning `rect`.* The `self.size =` call ran through
|
|
4666
|
+
`Popup#reposition`, which assigned an intermediate rect at the old origin —
|
|
4667
|
+
reintroducing exactly what `reposition`'s own rdoc says it avoids, on
|
|
4668
|
+
`ComboBox`'s per-keystroke re-anchor path.
|
|
4669
|
+
|
|
4670
|
+
**Alternatives rejected.**
|
|
4671
|
+
- *The inverse cut — base stays `Popup`, the modal one becomes `Dialog`.* It
|
|
4672
|
+
would have kept `ScreenPane#popups` accurate at zero renaming cost, but every
|
|
4673
|
+
existing `Popup.new(content: w).open` would **silently** become non-modal,
|
|
4674
|
+
uncentered and un-ESC-able. A loud `ArgumentError` on a removed `modal: false`
|
|
4675
|
+
beats a silent behaviour change.
|
|
4676
|
+
- *Renaming the pane/screen vocabulary to `overlays` / `add_overlay`.* Considered
|
|
4677
|
+
and declined: the break reaches `Screen#add_popup`, which apps call, and
|
|
4678
|
+
"popup stack" remains a defensible name for the stack. The `@param` types and
|
|
4679
|
+
rdoc say `Overlay` instead, which is what a caller actually needs to know.
|
|
4680
|
+
- *Keeping `modal:` as a kwarg on `Popup` alongside `Overlay`.* Two ways to build
|
|
4681
|
+
the same thing, with the trap-laden one still reachable. The whole point is
|
|
4682
|
+
that the inert defaults are the ones you get by default.
|
|
4683
|
+
|
|
4684
|
+
**Consequences.**
|
|
4685
|
+
- `focusable?` and `modal?` are now coupled: flip both or neither. A *focusable
|
|
4686
|
+
non-modal* overlay is the one combination that must never ship — it holds focus
|
|
4687
|
+
outside the key scope, where `bubble_key` reaches nobody. The `Overlay` rdoc
|
|
4688
|
+
states this as the coupling rather than as a ban on overriding, because `Popup`
|
|
4689
|
+
overrides both.
|
|
4690
|
+
- `Overlay#reposition` is a no-op, so a subclass with a *derived* position owns
|
|
4691
|
+
its own override (`Notification`), and one placed by a driver simply keeps the
|
|
4692
|
+
rect it was given (`ListDropdown`, re-anchored from its driver's `rect=`).
|
|
4693
|
+
- **Still open:** where `anchor_to` / `anchor_beside` belong. They stay on
|
|
4694
|
+
`ListDropdown` for now. The `Popover` extraction (`D_select`, `D_menu_bar`) is
|
|
4695
|
+
*cheaper* after this change, since `Overlay` — not the modality-carrying
|
|
4696
|
+
`Popup` — is the right parent for a generically anchored layer; the trigger is
|
|
4697
|
+
unchanged, the first non-`List` content wanting anchoring.
|
|
4698
|
+
|
|
4699
|
+
## D_declared_size — `Popup#size` is `#declared_size` (2026-08-30)
|
|
4700
|
+
|
|
4701
|
+
**Decision.** Rename `Popup#size` / `#size=` — and the `Popup.new` and
|
|
4702
|
+
`InfoWindow.open` keyword — to `declared_size`. Nothing else changes: it is still
|
|
4703
|
+
a `Size | Fraction`, still re-resolved against the screen on every layout pass,
|
|
4704
|
+
still authoritative.
|
|
4705
|
+
|
|
4706
|
+
**Why.** `size` on a component is reasonably expected to mean `rect.size`, and
|
|
4707
|
+
the name was already spoken for by a different concept: an *input* the popup is
|
|
4708
|
+
re-read from on every layout pass, not a *report* of current geometry. Leaving it
|
|
4709
|
+
would have created tension the first time anyone added `Component#size`, and the
|
|
4710
|
+
types disagree too — `rect.size` is a `Size`, this is a `Size | Fraction`, so a
|
|
4711
|
+
`Component#size` reader would have been a Liskov break on the one class most
|
|
4712
|
+
likely to be handled polymorphically. `declared_size` is the word `popup.rb`'s
|
|
4713
|
+
own rdoc already used ("its box is *declared* by"), and it contrasts correctly
|
|
4714
|
+
with `preferred` / `requested`, which the same rdoc explicitly disclaims: the
|
|
4715
|
+
screen applies exactly what you ask for, with no negotiation.
|
|
4716
|
+
|
|
4717
|
+
**Alternatives rejected.**
|
|
4718
|
+
- *`auto_size`* (the first proposal). Two problems. "Auto-size" conventionally
|
|
4719
|
+
means shrink-to-fit-content — Swing's `pack()`, WPF's `SizeToContent` — which
|
|
4720
|
+
is precisely the eager bottom-up `content_size` channel deleted in 0.9.0, and
|
|
4721
|
+
which AGENTS.md already spends a rule keeping out of the vocabulary
|
|
4722
|
+
(`D_box_layouts`: "there is no `Auto`"). And it read as a contradiction on the
|
|
4723
|
+
one subclass that genuinely does size itself: `Notification#auto_size=` raising
|
|
4724
|
+
"sizes itself from its messages". (That override is gone under `D_overlay`, but
|
|
4725
|
+
the naming argument stands for the next such subclass.)
|
|
4726
|
+
- *`auto_center_with_size(x)`*, a command rather than a property. It has real
|
|
4727
|
+
merit — Tuile already has imperative geometry methods (`center`, `reposition`,
|
|
4728
|
+
`anchor_to`) and a command is honest about the side effect that a bare setter
|
|
4729
|
+
hides. Rejected on three counts: it collides with the existing `Popup#center`,
|
|
4730
|
+
giving two near-synonymous centering verbs; after `D_overlay` made `Popup`
|
|
4731
|
+
unconditionally modal, "auto center" names the class *invariant* rather than
|
|
4732
|
+
the varying member, so it carries no information at the call site; and the
|
|
4733
|
+
member is *state*, not an action — `reposition` re-reads it forever, so a
|
|
4734
|
+
`Fraction` means "stay half the screen through every SIGWINCH", which a command
|
|
4735
|
+
name hides. A rejected refinement, folding it into `center(size = nil)`, keeps
|
|
4736
|
+
one verb but still hides the persistence.
|
|
4737
|
+
- *Keeping `size` and never adding `Component#size`* — settling that a
|
|
4738
|
+
component's size is spelled `component.rect.size` forever. Cheapest (no
|
|
4739
|
+
breaking change), and declined because it preserves the trap rather than
|
|
4740
|
+
removing it.
|
|
4741
|
+
|
|
4742
|
+
**Consequence, taken up the same day.** `size` being free on `Component` was the
|
|
4743
|
+
point, and `Component#size` / `#width` / `#height` were added straight after as
|
|
4744
|
+
pure readers of `rect` — partly to *squat* the names, so no component can later
|
|
4745
|
+
claim `size` for a content-derived measurement. They are reports, never requests:
|
|
4746
|
+
no writer, and no container consults them when dividing space. The top-down
|
|
4747
|
+
re-grow rule still governs, and a second component wanting a *declared* box
|
|
4748
|
+
copies `Popup`'s naming rather than overloading `size`.
|
|
4749
|
+
|
|
4750
|
+
## D_extent — `extent` on `Component`: the rect is what you were given, not what you paint (2026-08-30)
|
|
4751
|
+
|
|
4752
|
+
**Decision.** Promote `extent` from a per-widget convention to a `Component`
|
|
4753
|
+
member defaulting to `rect`, and give it a paired `clear_outside_extent`. A
|
|
4754
|
+
widget that paints less than its rect narrows the extent and then uses it in the
|
|
4755
|
+
three places that care — clearing, hit-testing, anchoring. **`rect` keeps meaning
|
|
4756
|
+
exactly what the parent assigned**; nothing about `extent` flows upward.
|
|
4757
|
+
|
|
4758
|
+
**Why now.** The concept had leaked six times (`Button`, `Checkbox`, `Tabs`,
|
|
4759
|
+
`MenuBar`, `Select`, `ComboBox`) and produced two shipped bugs in one session: a
|
|
4760
|
+
`Select` in a single-slot container opened its dropdown from a click 20 rows
|
|
4761
|
+
below its face, and `ListDropdown#anchor_to` placed the panel using a rect the
|
|
4762
|
+
driver did not occupy. Both were "the widget forgot to consult `extent`", in
|
|
4763
|
+
different places. It was one concept with no name and no home.
|
|
4764
|
+
|
|
4765
|
+
**Rejected: the component clamps its own `rect`.** The tempting inverse — layout
|
|
4766
|
+
offers 10 rows, the widget writes back 1, the parent's arithmetic unaffected and
|
|
4767
|
+
the gap simply showing through. It is *not* the deleted bottom-up `content_size`
|
|
4768
|
+
channel (no parent consults anything, no re-layout is triggered), and
|
|
4769
|
+
`children_tile_rect?` already handles the resulting gap, so it would have worked.
|
|
4770
|
+
Rejected on the invariant it costs: **`rect=` would lie.** `c.rect = r; c.rect ==
|
|
4771
|
+
r` becomes false, and `f.add(checkbox, Fixed[10])` silently yields a one-row
|
|
4772
|
+
checkbox, so the declared *constraint* is a lie too, invisible at the call site.
|
|
4773
|
+
In a top-down system the highest-value property is that a container's arithmetic
|
|
4774
|
+
can be verified by reading the container alone; once any component may rewrite
|
|
4775
|
+
its rect, you cannot reason about a `Box` without knowing which of its children
|
|
4776
|
+
clamp. Two lesser counts: clamping spreads the surprise to every reader of any
|
|
4777
|
+
`rect` (with `extent` only the six widgets that *have* a quirk carry it), and it
|
|
4778
|
+
solves only the height axis — every width clamp here is content-derived
|
|
4779
|
+
(`caption.display_width + 4`), so it would need the parent told to re-lay-out on
|
|
4780
|
+
`caption=`, which is `on_child_content_size_changed`, deleted in 0.9.0. Vaadin 8's
|
|
4781
|
+
slot negotiation is the prior art, and Vaadin 10 dropped it for CSS.
|
|
4782
|
+
|
|
4783
|
+
**The default is `nil`, not `rect.size`, and that is what lets `repaint` decide.**
|
|
4784
|
+
The first cut defaulted to `rect.size` and had the base branch on `extent ==
|
|
4785
|
+
rect`; it was implemented and backed out, because a one-row `Select` in a one-row
|
|
4786
|
+
rect satisfies that test while genuinely painting its extent in full — the base
|
|
4787
|
+
would blank the row and `Select` would repaint it, every cell dirty, the row
|
|
4788
|
+
re-emitted (`D_progress_bar`). The two states the base must tell apart are "no
|
|
4789
|
+
declaration, so clear everything" (a `Label` with short text) and "declared, so
|
|
4790
|
+
leave it alone", and they are *not* distinguishable from the value: they are
|
|
4791
|
+
distinguishable by whether there is a value. Hence `nil`.
|
|
4792
|
+
|
|
4793
|
+
The payoff is that widgets keep the ordinary `super`-then-paint shape — no widget
|
|
4794
|
+
has to remember to call a helper, and forgetting to declare an extent degrades to
|
|
4795
|
+
today's behaviour rather than to stale glyphs.
|
|
4796
|
+
|
|
4797
|
+
**`extent` is a `Size | nil`, not a `Rect`.** It always sits at the rect's top-left, so
|
|
4798
|
+
a `Rect` would carry two fields that must equal `rect.left` / `rect.top` and
|
|
4799
|
+
could be set not to — the invariant would live in a doc sentence rather than in
|
|
4800
|
+
the type. `Component#extent_rect` places it for the two consumers that need
|
|
4801
|
+
coordinates (`handle_mouse` hit-testing, `ListDropdown#anchor_to`). Member count
|
|
4802
|
+
is a wash; what is bought is that an offset extent cannot be written.
|
|
4803
|
+
|
|
4804
|
+
**Consequences.**
|
|
4805
|
+
- Four widgets that called `super` now clear only outside the extent, which is
|
|
4806
|
+
strictly less blanking: an unchanged `Checkbox` repaint went from 48 to 22
|
|
4807
|
+
emitted bytes and stopped re-emitting its caption.
|
|
4808
|
+
- `Select`'s hand-rolled tail arithmetic is deleted; its `repaint` is the same
|
|
4809
|
+
shape as the other four.
|
|
4810
|
+
- Hit-testing stays a per-widget one-liner, because the *action* differs
|
|
4811
|
+
(toggle / open / click) and click-to-focus is deliberately ungated by geometry.
|
|
4812
|
+
- **Not** a licence for a parent to consult `extent`. If a container ever wants
|
|
4813
|
+
to, that is the bottom-up channel again and needs its own argument.
|
|
4814
|
+
|
|
4815
|
+
## D_final_tree — `children` and `parent` are final; no shadow tree (2026-08-30)
|
|
4816
|
+
|
|
4817
|
+
**Decision.** `children`, `parent`, `parent=`, `add_child`, `remove_child` and
|
|
4818
|
+
`detach_child` may not be overridden. `Component.verify_final!` resolves each
|
|
4819
|
+
one and compares its `owner`, raising `Tuile::Error` from `Component#initialize`
|
|
4820
|
+
when a subclass has taken any of them. Checked once per class and memoized.
|
|
4821
|
+
|
|
4822
|
+
**Why a runtime check rather than the existing prose.** `D_tree_api` already
|
|
4823
|
+
said "never override `children`" and `component_spec` already walked a tree of
|
|
4824
|
+
every container kind asserting the array and the pointers agree. Both are
|
|
4825
|
+
in-repo guards; neither reaches an *app* subclassing `Component`, which is where
|
|
4826
|
+
the mistake is most likely and least visible. The failure mode is nasty and
|
|
4827
|
+
silent in a specific way: `attached?` walks the **parent chain** while every
|
|
4828
|
+
subtree walk uses **`children`**, so a derived `children` yields a component that
|
|
4829
|
+
is attached but never painted, a lifecycle hook fired for the wrong set, and a
|
|
4830
|
+
click that never reaches a widget the tree still lists. Nothing raises; the
|
|
4831
|
+
widget is just dead.
|
|
4832
|
+
|
|
4833
|
+
The check earned itself immediately: `component_spec`'s own `container_with`
|
|
4834
|
+
helper built its fixtures with `define_method(:children) { kids }`, i.e. the gem's
|
|
4835
|
+
test suite was faking the tree it was asserting about. That is now real
|
|
4836
|
+
`add_child` wiring.
|
|
4837
|
+
|
|
4838
|
+
**Why the check is at instantiation, not at definition.** A
|
|
4839
|
+
`Component.method_added` hook fires at load time, which is nicer, but only sees
|
|
4840
|
+
a literal `def` in the subclass body — it misses an override arriving through an
|
|
4841
|
+
`include` or a `prepend`. Resolving `instance_method(...).owner` at the first
|
|
4842
|
+
`new` catches all four routes uniformly, in three lines, at the cost of firing a
|
|
4843
|
+
moment later. It does not stop `instance_variable_set(:@children, …)`, and it
|
|
4844
|
+
isn't meant to: the goal is catching the accident, not defeating an adversary.
|
|
4845
|
+
|
|
4846
|
+
**Rejected: `Screen` holds a private tree of its own**, authoritative regardless
|
|
4847
|
+
of what `parent` / `children` say. Two sources of truth that can drift is
|
|
4848
|
+
strictly worse than one that can be lied about, and this is `D_tree_api`'s
|
|
4849
|
+
slot-desync rule raised to framework scale — `ScreenPane#popups` needed an
|
|
4850
|
+
explicit carve-out and a drift assertion just to duplicate *ordering* for one
|
|
4851
|
+
list. Every operation would also have to pick a tree, and the right pick differs
|
|
4852
|
+
per operation (paint and focus want the physical one, a named slot wants the
|
|
4853
|
+
logical one), so each choice becomes a new bug surface. And it would not even
|
|
4854
|
+
fix the case that prompted this: routing is per-component methods, so a shadow
|
|
4855
|
+
tree does nothing about a `handle_mouse` that ignores half its children.
|
|
4856
|
+
|
|
4857
|
+
**The logical/physical axis is real, and it is served by composition instead.**
|
|
4858
|
+
A container whose regions are app-swappable holds a {Component::Slot} per region
|
|
4859
|
+
— a logical view implemented *over* the physical tree, never beside it. See
|
|
4860
|
+
`D_slots`.
|
|
4861
|
+
|
|
4862
|
+
## D_slots — `Slot`, a one-child region; `HasContent` stops meaning "one child" (2026-08-30)
|
|
4863
|
+
|
|
4864
|
+
**Decision.** Add `Component::Slot`: a `Component` that includes `HasContent` and
|
|
4865
|
+
sizes its occupant to its own rect. A container with several regions gives each
|
|
4866
|
+
one a `Slot`, wired once at construction. `HasContent` keeps its implementation
|
|
4867
|
+
but is re-scoped to mean *I own exactly one child directly*, and loses
|
|
4868
|
+
`handle_mouse` to `Component`.
|
|
4869
|
+
|
|
4870
|
+
**The problem.** `HasContent`'s rdoc said "a component with one child tops",
|
|
4871
|
+
which `Window` has falsified since it grew a footer — and `Window` paid for it:
|
|
4872
|
+
`footer=` was a 20-line hand-copy of `content=` including the notify-last
|
|
4873
|
+
ordering rule, plus a `handle_mouse` and a `rect=` patch. That is O(slots)
|
|
4874
|
+
boilerplate, each copy a chance to get the order wrong. A three-region dialog
|
|
4875
|
+
would have been a third copy, or — worse, and the shape actually proposed — would
|
|
4876
|
+
have included `HasContent` and silently mis-routed: `HasContent#handle_mouse`
|
|
4877
|
+
forwarded only to `content`, so its buttons would have been unclickable.
|
|
4878
|
+
|
|
4879
|
+
**Why a component and not a slot mechanism.** The alternative designed first was
|
|
4880
|
+
a declared slot order (`SLOTS = %i[content footer]`) plus a `swap_slot` helper
|
|
4881
|
+
computing the insert index as "populated slots declared before me". It works, and
|
|
4882
|
+
it makes the ordering rule executable. It was dropped because it answers "why not
|
|
4883
|
+
on `Component`, then?" badly: it is a new framework mechanism — a class-constant
|
|
4884
|
+
convention, ivar reflection to write the backing store in the right order, a
|
|
4885
|
+
doc entry — solving a problem composition already solves. `Slot` adds one small
|
|
4886
|
+
class and no new concepts, and the nil case that motivated the whole thing stops
|
|
4887
|
+
existing rather than being computed: inside a `Slot` the only insert index is 0.
|
|
4888
|
+
|
|
4889
|
+
**Rejected: placeholder components.** Keeping a fixed-arity `@children` by
|
|
4890
|
+
parking an inert object in every empty slot. This is a shadow tree at 1/10 scale
|
|
4891
|
+
(`D_final_tree`): `children.size` stops meaning what it says, `on_tree` visits
|
|
4892
|
+
things that aren't UI, and every generic walk must tolerate ghosts forever — all
|
|
4893
|
+
to buy index arithmetic. A `Slot` is not a placeholder: it has a rect, it clears
|
|
4894
|
+
it, and it routes.
|
|
4895
|
+
|
|
4896
|
+
**Rejected: holder sub-containers built from `Layout`.** Wrapping each region in
|
|
4897
|
+
a `Layout::Absolute` needs no framework change at all, which is exactly the tell
|
|
4898
|
+
— an app can already do it. It costs a tree level *and* rect plumbing per region,
|
|
4899
|
+
and it is a placeholder with geometry.
|
|
4900
|
+
|
|
4901
|
+
**An empty slot does not collapse.** It keeps the rect it was assigned and clears
|
|
4902
|
+
it, so a dialog with no message shows the hole — consistent with the same dialog
|
|
4903
|
+
given an empty message string. Closing the gap is the *parent's* arithmetic (a
|
|
4904
|
+
zero extent), which is what top-down layout already demands; detaching the slot
|
|
4905
|
+
instead would put the index problem straight back, and neither `Layout#add` nor
|
|
4906
|
+
`Box#add` takes an index to re-insert at. `Window` uses the degenerate form: an
|
|
4907
|
+
absent footer gets an empty rect, because a `Slot` clears what it is given and
|
|
4908
|
+
would otherwise blank the bottom border.
|
|
4909
|
+
|
|
4910
|
+
**A slot is transparent, in all three channels.** Not `focusable?`, so the focus
|
|
4911
|
+
cascades walk past it; `Component#handle_mouse` descends through it; and
|
|
4912
|
+
`on_child_removed` is *forwarded to the parent*, because the default repair moves
|
|
4913
|
+
focus to `self` and a slot is inert — no cursor, no keys, nothing to bubble from.
|
|
4914
|
+
That last one is not theoretical: it broke `window_spec`'s "repairs focus when a
|
|
4915
|
+
focused footer is removed" the moment the footer moved into a slot.
|
|
4916
|
+
|
|
4917
|
+
**The cost, paid knowingly.** `Window#children` now always holds the footer slot,
|
|
4918
|
+
and a footer's `parent` is that slot rather than the window. Both are honest —
|
|
4919
|
+
the region genuinely exists whether or not it is occupied — and one thing gets
|
|
4920
|
+
*stronger*: content-then-footer ordering used to depend on `content=` inserting
|
|
4921
|
+
at 0 and `footer=` appending, and now holds structurally because the slot is
|
|
4922
|
+
wired at construction.
|
|
4923
|
+
|
|
4924
|
+
**`HasContent` survives, re-scoped.** A marker mixin with no implementation was
|
|
4925
|
+
considered and rejected twice over: the swap dance has to live somewhere or every
|
|
4926
|
+
includer hand-writes it again (the duplication this deletes), and a `content=`
|
|
4927
|
+
meaning "put this in my Slot" is circular, since `Slot` *is* a `HasContent`. So
|
|
4928
|
+
it keeps its body and gains a rule for which shape to use — permanent, integral
|
|
4929
|
+
content includes it (a typed field's inner `TextField`, `Slot` itself);
|
|
4930
|
+
app-swappable regions hold a `Slot`. It stays a mixin rather than per-class
|
|
4931
|
+
accessors so a tree walk can find content via `is_a?(HasContent)`, the same
|
|
4932
|
+
reason `HasCaption` is one.
|
|
4933
|
+
|
|
4934
|
+
**`handle_mouse` folds into `Component`.** The child-walk existed three times —
|
|
4935
|
+
`Layout`, `TabSheet` (verbatim) and, narrowed to one child, `HasContent`, with
|
|
4936
|
+
`Window` patching a footer branch on top. It is now the base implementation:
|
|
4937
|
+
focus self if focusable, then hand the event to every child whose rect contains
|
|
4938
|
+
the point. Widgets that resolve clicks inside their own rect (`List`, `Select`,
|
|
4939
|
+
`MenuBar`, `Notification`) already override without `super` and are unaffected.
|
|
4940
|
+
One behavior change falls out and is a fix: a click on `Window` chrome now lands
|
|
4941
|
+
focus on the window, which `AGENTS.md` has described all along.
|
|
4942
|
+
|
|
4943
|
+
**Not folded: `on_focus`.** The obvious symmetry — promote `Layout#on_focus`'s
|
|
4944
|
+
first-tab-stop walk to `Component` and delete `HasContent#on_focus` — was
|
|
4945
|
+
implemented in design and dropped. Unlike the mouse walk these are not
|
|
4946
|
+
duplicates: `HasContent` forwards to *its content*, `Layout` searches for the
|
|
4947
|
+
first `tab_stop?` descendant, and they disagree whenever content is focusable but
|
|
4948
|
+
not a tab stop (a `Popup` wrapping a `Window`). More decisively, `on_focus` is a
|
|
4949
|
+
*public app-facing hook* whose default is deliberately "do nothing"
|
|
4950
|
+
(`D_hook_visibility`); giving it default behavior changes what `super` means in
|
|
4951
|
+
every app override. The mouse walk carries no such risk — its default already
|
|
4952
|
+
does something.
|
|
4953
|
+
|
|
4954
|
+
## D_confirm_window — `ConfirmWindow`: the component is the builder; every button dismisses (2026-08-31)
|
|
4955
|
+
|
|
4956
|
+
**Decision.** Add `Component::ConfirmWindow < Window`: a caption, a prose
|
|
4957
|
+
message and a centered row of `Button`s, opened as a content-measured modal
|
|
4958
|
+
popup. The component itself is the builder — `#button(caption, mnemonic:, &action)`
|
|
4959
|
+
declares any button set; three class factories (`alert`, `confirm`, `yes_no`)
|
|
4960
|
+
cover the common shapes — and **every button closes the dialog**. Book ch7
|
|
4961
|
+
("The confirm dialog") carries the user-facing story.
|
|
4962
|
+
|
|
4963
|
+
**Callback-only, because blocking is impossible.** The shape everyone reaches
|
|
4964
|
+
for first — `JOptionPane`, tkinter `askyesno`, `tty-prompt`'s `yes?`, GTK3
|
|
4965
|
+
`dialog.run` — *blocks* and returns the answer. Tuile is single-threaded:
|
|
4966
|
+
`Screen#run_event_loop` is `$stdin.raw { event_loop }` with the key thread
|
|
4967
|
+
already running, so a value-returning modal would need a nested loop
|
|
4968
|
+
re-entering raw mode. This is the first thing a contributor will try to "fix";
|
|
4969
|
+
it cannot work here.
|
|
4970
|
+
|
|
4971
|
+
**One dismissal channel, N action channels.** A button with a block fires it; a
|
|
4972
|
+
button without one is a Cancel. ESC, `q`, an outside click and a Cancel button
|
|
4973
|
+
are all one event — `on_dismiss`, fired exactly once and only when no action
|
|
4974
|
+
button was chosen. `Overlay#on_close` (which fires on *every* departure) is the
|
|
4975
|
+
hook, gated by one bool. This is Vaadin's "ESC triggers the Cancel action"
|
|
4976
|
+
minus its `cancelable`/`rejectable` booleans: Ruby can say *absent argument =
|
|
4977
|
+
absent button*, which deletes the boolean surface a Java API pays for.
|
|
4978
|
+
|
|
4979
|
+
**Every button dismisses, unconditionally — no keep-open knob.** The
|
|
4980
|
+
counter-case was hunted and doesn't exist: a dialog staying open after a press
|
|
4981
|
+
is either collecting input (excluded below) or chaining — "Copy files" → a
|
|
4982
|
+
copy-progress window — and chaining is the callback's job: it opens the *next*
|
|
4983
|
+
window. Activation order is **mark chosen → close → fire**: the bool set before
|
|
4984
|
+
`close` is what makes `on_close` skip the dismissal, and the block firing after
|
|
4985
|
+
close sees clean focus-repair state (the dialog is already out of `@popups`),
|
|
4986
|
+
so a callback opening a follow-up popup snapshots the right prior focus and a
|
|
4987
|
+
raising block can't strand a half-open dialog.
|
|
4988
|
+
|
|
4989
|
+
**The component is the builder — roads not taken.** Prior art: ~20 blocking
|
|
4990
|
+
overloads (Swing), setters plus booleans (Vaadin), a builder object (Android),
|
|
4991
|
+
flags plus an `addButton` escape hatch (Qt), buttons-as-data with a result
|
|
4992
|
+
index (Electron, Turbo Vision, prompt_toolkit), or no dialog component at all
|
|
4993
|
+
(Textual). Against `X.new.tap { … }` already being the house idiom, three
|
|
4994
|
+
candidates lost:
|
|
4995
|
+
|
|
4996
|
+
- *Kwargs only* dies at button 4, and each new knob is a constructor parameter
|
|
4997
|
+
forever. It survives as the **factory** shape, pinned at one or two buttons,
|
|
4998
|
+
where those costs never fire.
|
|
4999
|
+
- *Buttons as data + one `case` callback* dissolves once the component is the
|
|
5000
|
+
builder: its one advantage (N buttons with zero API growth) is the
|
|
5001
|
+
mechanism's job now, and it invents a `[symbol, label]` vocabulary next to
|
|
5002
|
+
`Button.new("Save") { save! }`, which already is caption-plus-action.
|
|
5003
|
+
- *A separate builder object* is a second class whose only job is to be a
|
|
5004
|
+
half-built dialog, while Tuile components are already mutable.
|
|
5005
|
+
|
|
5006
|
+
New capability lands as a `#button` kwarg or a method, never a constructor
|
|
5007
|
+
parameter — the exponential growth never starts. And `#button` takes a caption
|
|
5008
|
+
plus kwargs plus block, never a prebuilt `Button`: the dialog must restyle the
|
|
5009
|
+
caption (the mnemonic underline) and wrap the action (close-then-fire), and
|
|
5010
|
+
doing either to a caller's `Button` is spooky mutation.
|
|
5011
|
+
|
|
5012
|
+
**Three factories, and no more.** The sets toolkits ship: OK · OK/Cancel ·
|
|
5013
|
+
Yes/No · Yes/No/Cancel · Retry/Cancel · Abort/Retry/Ignore. `alert`
|
|
5014
|
+
(acknowledge), `confirm` (its labels are kwargs, so it *is* OK/Cancel and
|
|
5015
|
+
Delete/Cancel) and `yes_no` (one line over `confirm`) cover them; everything
|
|
5016
|
+
further is a label respelling or five lines of the mechanism. Windows'
|
|
5017
|
+
six-value `MessageBoxButtons` enum is the tripwire this rule exists to avoid.
|
|
5018
|
+
|
|
5019
|
+
**No content slot; `message=` changes kind and stores as given.** The body is
|
|
5020
|
+
prose rendered by a `TextView` the dialog owns. Three buys: owning the body is
|
|
5021
|
+
what makes scrolling *reachable* (`TextView#handle_key` acts on the key alone,
|
|
5022
|
+
so the dialog hand-feeds scroll keys while a button keeps focus); the sizing
|
|
5023
|
+
rule has one mode (the dialog always measures content it owns,
|
|
5024
|
+
`Notification`-style, rather than "measure unless the caller assigned
|
|
5025
|
+
content"); and `StyledString` already covers icons, color and emphasis on a
|
|
5026
|
+
TTY. The casualty, priced: exactly the case Vaadin's docs carve out — the
|
|
5027
|
+
don't-ask-again checkbox. Everything else people put in a dialog body isn't a
|
|
5028
|
+
confirm dialog, and `Popup.new(content: your_layout)` remains the escape
|
|
5029
|
+
hatch. **Re-grow rule:** don't-ask-again returns as a named `remember:` seam
|
|
5030
|
+
whose state reaches the callback, never as a reopened content slot.
|
|
5031
|
+
|
|
5032
|
+
The accessor rule worth keeping: a setter that **normalizes within a kind**
|
|
5033
|
+
stores the normalized form (`Label#text`: `String` → `StyledString`, text
|
|
5034
|
+
stays text, round-trip pinned); a setter that **changes the kind** stores the
|
|
5035
|
+
input and derives the rendering (`String` → a component — handing back the
|
|
5036
|
+
`TextView` would hand back machinery). Hence `#message` returns what was
|
|
5037
|
+
assigned. The slot occupant is *derived* from the raw value through a single
|
|
5038
|
+
write path, so the two stored values cannot disagree (`D_tree_api`'s desync
|
|
5039
|
+
rule is about a second copy of tree *structure*). Coercion lives on the
|
|
5040
|
+
dialog, not on `Slot#content=`: the right wrapper differs per region — a
|
|
5041
|
+
caption-ish line wants a `Label` (ellipsizes), a message wants a `TextView`
|
|
5042
|
+
(wraps) — so one `Slot`-level answer would be wrong half the time in this very
|
|
5043
|
+
component. A `Component` message is mounted as-is, and the dialog — which may
|
|
5044
|
+
measure only content it *owns* (the v0.9.0 re-grow rule's caller-side query,
|
|
5045
|
+
here `#measured_size`) — then takes the full half-screen box.
|
|
5046
|
+
|
|
5047
|
+
**Mnemonics: MenuBar's shape; `q`, `g`, `G` reserved.** Local sugar over the
|
|
5048
|
+
window's own `handle_key` per `D_key_dispatch`'s re-grow rule — never a
|
|
5049
|
+
dispatch phase — with the letter underlined in the caption: Tuile has no
|
|
5050
|
+
status bar to advertise keys in (`D_status_bar`), so an unadvertised mnemonic
|
|
5051
|
+
is a hidden feature. `mnemonic: :auto` (the default) derives the caption's
|
|
5052
|
+
first letter and is *silently skipped* when reserved, taken or unusable; an
|
|
5053
|
+
explicit letter raises at registration, exactly where `MenuBar#add_item` puts
|
|
5054
|
+
every rule that has no sane answer at keypress time. Two-tier on purpose:
|
|
5055
|
+
best-effort for a derivation the caller never chose, strict for a promise they
|
|
5056
|
+
spelled out. Reserved: `q` — the do-nothing route out of *any* confirm dialog,
|
|
5057
|
+
including one that thinks it forces a choice (the user can always Ctrl+C, and
|
|
5058
|
+
pretending there is no escape route just trains them to reach for it); `g`/`G`
|
|
5059
|
+
— message scrolling; Space — it presses the focused button. The hand-fed
|
|
5060
|
+
scroll set (`BODY_SCROLL_KEYS`) is deliberately **not**
|
|
5061
|
+
`Keys::UP_ARROWS`/`DOWN_ARROWS`: their vi aliases `j`/`k` stay available as
|
|
5062
|
+
mnemonics ("Keep"), and the body still honors them when focused itself.
|
|
5063
|
+
Reservation-at-registration is what keeps keypress time free of shadowing
|
|
5064
|
+
rules.
|
|
5065
|
+
|
|
5066
|
+
**The body is a tab stop; focus opens on the first button.** A plain
|
|
5067
|
+
`TextView`, no `tab_stop?`-suppressing subclass: the arrows reach the prose
|
|
5068
|
+
either way, but the stop makes overflowing prose *visibly* reachable
|
|
5069
|
+
(Shift+Tab) rather than secretly scrollable, and it deletes a nested class.
|
|
5070
|
+
`on_focus` lands on the first-declared button — which, since Enter presses the
|
|
5071
|
+
focused button, is the default button. A safe-default knob (destructive
|
|
5072
|
+
confirms focusing Cancel) can land later as a `#button` kwarg.
|
|
5073
|
+
|
|
5074
|
+
**Buttons live inside the window, not in `Window#footer`.** A `Horizontal` as
|
|
5075
|
+
the bottom row of the inner `Vertical` (body `Expand[1]`, row `Fixed[1]`,
|
|
5076
|
+
spacing 1): the footer paints *over the bottom border row*, and `[ Delete ]`
|
|
5077
|
+
glyphs embedded in the border look wrong — the border stays clean chrome.
|
|
5078
|
+
Centering rides `cross: Fixed[row width], align: :center`, re-declared on each
|
|
5079
|
+
`#button` call, because a `Box` constraint changes only by remove-and-re-add.
|
|
5080
|
+
|
|
5081
|
+
**Sizing: measured, capped at half the screen.** The private `MeasuredPopup`
|
|
5082
|
+
derives `declared_size` from `#measured_size` on every `reposition`, so a
|
|
5083
|
+
message change and a SIGWINCH both re-measure against the current screen — the
|
|
5084
|
+
`Overlay` "derived position needs its own `reposition`" rule applied to a
|
|
5085
|
+
derived *size*. The cap is `Fraction::HALF`, `Popup`'s own default, so the
|
|
5086
|
+
dialog only ever *shrinks below* the default popup box. It re-measures freely
|
|
5087
|
+
rather than grow-only like `Notification` — a dialog's text changes far less
|
|
5088
|
+
often than a toast's. No floor for now; the risk a floor would hedge (a tiny
|
|
5089
|
+
yes/no box going unnoticed over a busy screen) is really a backdrop problem —
|
|
5090
|
+
`ideas/modal-backdrop.md`.
|
|
5091
|
+
|
|
5092
|
+
**Named `ConfirmWindow`, not `ConfirmDialog`.** It sits in the `*Window`
|
|
5093
|
+
family — a `Window` subclass, tiled-or-popup for free — and obeys the
|
|
5094
|
+
widget-suffix rule. `ConfirmDialog` is what a searcher will type, but Tuile
|
|
5095
|
+
already calls `Popup` "the modal dialog", so that name would imply `< Popup`;
|
|
5096
|
+
the rdoc and README say "the confirm dialog" in prose, so the search still
|
|
5097
|
+
lands.
|
|
5098
|
+
|
|
5099
|
+
**Two seams deliberately not added.** No `header=`: it would be the title, and
|
|
5100
|
+
`HasCaption#caption` already is — two accessors for one thing, one storing
|
|
5101
|
+
as-given and one coercing, is exactly the wart the store-as-given rule above
|
|
5102
|
+
exists to prevent. A *rich* header (an icon beside the text) would come back
|
|
5103
|
+
as a region distinct from the title, never as a second name for it. And not a
|
|
5104
|
+
`HasValue`: a dialog outcome is not a field value — the same reasoning that
|
|
5105
|
+
keeps `ProgressBar` out of the mixin (`D_progress_bar`).
|
|
5106
|
+
|
|
5107
|
+
**An alert keeps its OK button** even though ESC/`q`/outside-click already
|
|
5108
|
+
close it: Tuile deliberately advertises no quit key (`D_quit_key`,
|
|
5109
|
+
`D_status_bar`), so the button *is* the discoverability affordance — and it is
|
|
5110
|
+
clickable, which the keys are not.
|
|
5111
|
+
|
|
5112
|
+
**Rejected: folding `PickerWindow` in.** A keystroke-addressed, scrollable
|
|
5113
|
+
`List` of options with a cursor vs. a short focusable row of buttons with a
|
|
5114
|
+
default and a dismissal: one widget with a mode flag would disagree with
|
|
5115
|
+
itself on every question that matters — does the cursor roam, is there a
|
|
5116
|
+
default, what does ESC mean, does a pick close. What the two share is API
|
|
5117
|
+
*shape* — a caption, a set of choices, one callback — not code.
|
|
5118
|
+
|
|
5119
|
+
## D_info_window_body — `InfoWindow`: two body presentations, prose wraps and rows don't (2026-08-31)
|
|
5120
|
+
|
|
5121
|
+
**Decision.** `InfoWindow` gains `ConfirmWindow`'s body seam — `message=`
|
|
5122
|
+
accepting `Component | String | StyledString | nil`, text rendered by a
|
|
5123
|
+
wrapping, scrollable `TextView`, the reader returning what was assigned
|
|
5124
|
+
(`D_confirm_window`'s store-as-given rule) — and **keeps `lines=`**, rebuilt
|
|
5125
|
+
as sugar that mounts a `List` through the same slot (delegating to
|
|
5126
|
+
`List#lines=`, so the coerce/split/rstrip semantics stay one implementation).
|
|
5127
|
+
The constructor and `.open` take one body positional and dispatch by type:
|
|
5128
|
+
`Array` → rows, anything else → `message=`. Last writer wins; after `lines=`,
|
|
5129
|
+
`message` reads the mounted `List` back — store-as-given holds, because the
|
|
5130
|
+
sugar *assigns a Component*.
|
|
5131
|
+
|
|
5132
|
+
**Truncation is a presentation, not a bug.** The itch was that `InfoWindow`
|
|
5133
|
+
(born before `TextView` existed) truncated long lines while
|
|
5134
|
+
`ConfirmWindow.alert` wrapped them — two "here's some information" paths
|
|
5135
|
+
diverging on a long sentence. But the fix is not to kill the `List` body:
|
|
5136
|
+
`TextView` has no truncate mode, so a `List` is the only way to show columnar
|
|
5137
|
+
output (a file listing, aligned key-value rows) where a wrap destroys the
|
|
5138
|
+
alignment. The actual bug was that wrap-vs-truncate was **accidental** —
|
|
5139
|
+
decided by which class you reached for. Two named setters on one class make it
|
|
5140
|
+
chosen: `message=` is *prose*, `lines=` is *rows*. The docs demote `lines` to
|
|
5141
|
+
second billing on purpose — book ch7 and the README row lead with `message=`,
|
|
5142
|
+
or everyone keeps reaching for the truncating path out of habit.
|
|
5143
|
+
|
|
5144
|
+
**Non-breaking by choice, not necessity.** Neither virtui nor pikuri-tui used
|
|
5145
|
+
`InfoWindow` (or `ConfirmWindow`) at all when this shipped, so the breaking
|
|
5146
|
+
design first sketched — delete the lines API outright — would have cost
|
|
5147
|
+
nothing downstream. `lines=` survives because the rows presentation earns it,
|
|
5148
|
+
not for compatibility. Which seam downstream apps actually reach for remains
|
|
5149
|
+
worth watching; if `lines=` goes unused for a few releases, *that* is the
|
|
5150
|
+
evidence for retiring it. One observable change shipped anyway: a bare
|
|
5151
|
+
`InfoWindow.new` now has no body (`content` is `nil`) where it used to mount
|
|
5152
|
+
an empty `List`.
|
|
5153
|
+
|
|
5154
|
+
**`ConfirmWindow#message=` deliberately does not learn Array→List.** A
|
|
5155
|
+
confirm dialog's body is prose by nature; the asymmetry is a decision, not an
|
|
5156
|
+
oversight. If a caller ever appears: a `List` of lines is measurable —
|
|
5157
|
+
widest-line × row-count, no wrap pass — so it would not fall into
|
|
5158
|
+
`#measured_size`'s "Component body ⇒ full half-screen box" hole.
|
|
5159
|
+
|
|
5160
|
+
**The coercion is a duplicate, on purpose.** This is copy two of
|
|
5161
|
+
`ConfirmWindow`'s `message=` case (per `D_float_field`'s shallow-shell rule —
|
|
5162
|
+
fold at four, not two): the classes want different wrappers around the same
|
|
5163
|
+
five-line dispatch, and `InfoWindow`'s has no popup to re-measure.
|
|
5164
|
+
|
|
5165
|
+
**`InfoWindow` keeps its place next to `ConfirmWindow.alert`:** tiled use, a
|
|
5166
|
+
buttonless popup, `declared_size:` control, and the rows presentation — none
|
|
5167
|
+
of which the alert offers.
|
|
5168
|
+
|
|
5169
|
+
## D_inverse — `Style#inverse`: model SGR 7, don't fake it with colors (2026-08-31)
|
|
5170
|
+
|
|
5171
|
+
**Decision.** `StyledString::Style` gains a seventh attribute, `inverse`
|
|
5172
|
+
(SGR 7 on / 27 off), plumbed everywhere a style attribute lives: the
|
|
5173
|
+
`Data` member, `sgr_to`'s minimal diff, strict `parse`, and a whole-string
|
|
5174
|
+
`StyledString#with_inverse` beside `with_bold` / `with_underline`. One
|
|
5175
|
+
special ruling rides along: **`under_bg` treats an inverse span as already
|
|
5176
|
+
backgrounded** and skips it, exactly like a span with an explicit bg.
|
|
5177
|
+
|
|
5178
|
+
**Why an attribute, not a color pair.** The motivating use is the
|
|
5179
|
+
inverted focus chip (`[1]-VMs`, LazyVim-mode-segment style). Inverse swaps
|
|
5180
|
+
whatever fg/bg are *actually in effect* at the cell — terminal defaults
|
|
5181
|
+
included, which `fg:`/`bg:` cannot name — so a chip built with it is
|
|
5182
|
+
legible on any terminal palette with zero color decisions. The faked
|
|
5183
|
+
version (explicit `fg: :black, bg: <accent>` per theme variant) works but
|
|
5184
|
+
re-litigates contrast per theme and still guesses wrong on user-customized
|
|
5185
|
+
palettes. That asymmetry — the terminal knows its own default pair, the
|
|
5186
|
+
app never does — is the whole case for modeling the attribute.
|
|
5187
|
+
|
|
5188
|
+
**The `under_bg` ruling.** `under_bg` fills bg only into spans that have
|
|
5189
|
+
none; an inverse span's bg member is nil, but filling it would backfire —
|
|
5190
|
+
SGR 7 swaps the effective pair, so the filled tint becomes the chip's
|
|
5191
|
+
*glyph* color while its visual background stays the terminal's default fg.
|
|
5192
|
+
Skipping keeps an inverted chip looking identical on a plain and a tinted
|
|
5193
|
+
panel, which is what "terminal-theme-proof" has to mean. `with_bg` is
|
|
5194
|
+
untouched: it is override-all by contract, and a caller explicitly
|
|
5195
|
+
assigning a bg to an inverse span gets exactly that (the swap then applies
|
|
5196
|
+
to the explicit pair).
|
|
5197
|
+
|
|
5198
|
+
**Alternatives rejected.**
|
|
5199
|
+
- *The theme-token workaround* (a `fg`+`bg` pair per variant) — see above;
|
|
5200
|
+
it also puts a per-widget contrast decision into every app theme.
|
|
5201
|
+
- *Also modeling blink/conceal/dim while in there.* Declined: each
|
|
5202
|
+
attribute costs a code pair in three places plus round-trip and lenient
|
|
5203
|
+
surface, and none has a component waiting. The strict parser keeps
|
|
5204
|
+
raising on them, which is the round-trip contract doing its job — model
|
|
5205
|
+
an attribute when a consumer appears, not for SGR completeness.
|
|
5206
|
+
- *Naming it `reverse`* (ECMA-48 says "negative image", terminfo says
|
|
5207
|
+
`rev`). `inverse` is what CSS (`filter`), xterm docs and most modern
|
|
5208
|
+
terminal emulators call it, and `reverse` collides with Ruby's
|
|
5209
|
+
`String#reverse` / `Array#reverse` at the reference site.
|
|
5210
|
+
|
|
5211
|
+
## D_background_rgb — `detect` yields a `Result`; the flip re-probes for the RGB (2026-08-31)
|
|
5212
|
+
|
|
5213
|
+
**Decision.** `TerminalBackground.detect` returns a `Result(scheme:, color:)`
|
|
5214
|
+
instead of a bare `Symbol`, and `Screen#background_color` exposes the color half
|
|
5215
|
+
as a `Color` (nil when nothing reported one). It stays current across OS
|
|
5216
|
+
appearance flips: `Screen#on_color_scheme` writes `TerminalBackground::QUERY`
|
|
5217
|
+
from the event-loop thread, the key thread reads the reply back through
|
|
5218
|
+
`Keys.getkey`'s new `\e]` drain, and `EventQueue::BackgroundColorEvent` carries
|
|
5219
|
+
it up to `Screen#on_background_color`. A changed color fires
|
|
5220
|
+
`Component#on_theme_changed` across the tree, the same fan-out a theme swap uses.
|
|
5221
|
+
|
|
5222
|
+
**Why expose it at all.** A theme picks colors to sit *against* the background;
|
|
5223
|
+
the borderless-panes idiom (LazyVim's editor-vs-explorer split, virtui's ask)
|
|
5224
|
+
derives one *from* it — a secondary pane at ±4–5% luminance, same hue, with the
|
|
5225
|
+
primary pane left at the terminal default. That needs the actual RGB, and the
|
|
5226
|
+
OSC 11 reply already carried it: `REPLY` captured three components and the
|
|
5227
|
+
private `classify` collapsed them to `:light`/`:dark` and dropped the rest. The
|
|
5228
|
+
workaround is a fixed near-neutral per variant, which looks right only near the
|
|
5229
|
+
background it was tuned on.
|
|
5230
|
+
|
|
5231
|
+
**Why a `Result`, not a second entry point.** One OSC 11 exchange yields both
|
|
5232
|
+
facts, so one method returns both. `detect` + a sibling `detect_color` would
|
|
5233
|
+
mean either a second round trip on a probe that is already timing-constrained,
|
|
5234
|
+
or a stashed module ivar that lies the moment `detect` is called twice with
|
|
5235
|
+
different IOs — the issue's own second suggestion, declined for that. The
|
|
5236
|
+
breaking return type costs exactly one internal caller.
|
|
5237
|
+
|
|
5238
|
+
**Why the live re-probe, rather than a documented startup snapshot.** Mode 2031
|
|
5239
|
+
reports light/dark and no RGB, so an unrefreshed value survives a flip pointing
|
|
5240
|
+
at the *old* background — a dark-derived tint on a now-light terminal, silently,
|
|
5241
|
+
in the one situation the theme machinery otherwise handles perfectly. The
|
|
5242
|
+
startup-only timing constraint (the reply lands on stdin, which the key thread
|
|
5243
|
+
owns once the loop runs) dissolves once the key thread is the one reading it.
|
|
5244
|
+
|
|
5245
|
+
Three placements make that safe, and each is load-bearing:
|
|
5246
|
+
|
|
5247
|
+
- **The query is written from the event-loop thread**, not the key thread. That
|
|
5248
|
+
thread also owns `emit`, so the query's bytes can never land inside a frame's
|
|
5249
|
+
synchronized-output batch. A key thread writing its own query would race
|
|
5250
|
+
every repaint.
|
|
5251
|
+
- **The reply is drained a byte at a time** in `getkey`, like `read_paste` and
|
|
5252
|
+
for a sharper reason: an OSC reply may end in ST, which *is* `\e\\` — a
|
|
5253
|
+
gulping read would swallow the terminator plus whatever was typed behind it.
|
|
5254
|
+
`\e]` can't collide with a keyboard sequence, so the drain eats no real key.
|
|
5255
|
+
- **`print` now flushes.** It never did, and every existing caller got away with
|
|
5256
|
+
it because a frame's `emit` flushed moments later. A *query* whose reply the
|
|
5257
|
+
app is waiting on cannot rely on that.
|
|
5258
|
+
|
|
5259
|
+
**The stale value is kept, never blanked.** Nil-ing `@background_color` on the
|
|
5260
|
+
flip and refilling it on the reply is the honest-looking option and is wrong: a
|
|
5261
|
+
terminal that reports 2031 flips but not OSC 11 would lose, permanently, the
|
|
5262
|
+
color it gave us at startup. Holding the old value costs one frame of a slightly
|
|
5263
|
+
wrong tint on terminals that *do* answer, and costs nothing on those that don't.
|
|
5264
|
+
|
|
5265
|
+
**Why `on_theme_changed` and not a new callback.** The hook's contract is
|
|
5266
|
+
"rebuild the colors you derived from the theme"; a background-derived tint is one
|
|
5267
|
+
of those, and its inputs just moved. A dedicated `on_background_color_changed=`
|
|
5268
|
+
would be a second channel firing microseconds after the first, for an app that
|
|
5269
|
+
must handle both identically. The cost is one extra full repaint per OS
|
|
5270
|
+
appearance flip, which is a rare event with a full repaint already in it.
|
|
5271
|
+
|
|
5272
|
+
**Alternatives rejected.**
|
|
5273
|
+
- *A module-level `TerminalBackground.background_color` accessor* — mutable
|
|
5274
|
+
module state, stale by construction. See above.
|
|
5275
|
+
- *Tuile computing the tint* (a `Theme#tinted` or a `Color#lighten`). Out of
|
|
5276
|
+
scope: how far to step, in which direction, and whether to step at all is the
|
|
5277
|
+
app's design decision, and Tuile has no component that wants it. Exposing the
|
|
5278
|
+
fact is the framework's job.
|
|
5279
|
+
- *Deriving the scheme from the re-probe's RGB* instead of trusting the 2031
|
|
5280
|
+
report. They agree unless the terminal is buggy, and the report is the thing
|
|
5281
|
+
that actually said "the user flipped their OS appearance" — so the event
|
|
5282
|
+
carries the color alone.
|
|
5283
|
+
- *A `FakeScreen` pinning a plausible dark RGB* rather than nil. Nil is what a
|
|
5284
|
+
non-answering terminal reports, which is the branch app code most needs
|
|
5285
|
+
exercised; a spec that wants a color assigns one through
|
|
5286
|
+
`FakeScreen#background_color=`, which takes the same path a real reply does.
|
|
5287
|
+
|
|
5288
|
+
## D_color_depth — Detect the depth; degrade RGB at the wire (2026-08-31)
|
|
5289
|
+
|
|
5290
|
+
**Decision.** `ColorDepth.detect` reads the terminal's color depth from the
|
|
5291
|
+
environment (`:truecolor` / `:palette256` / `:ansi16`), `Screen#color_depth`
|
|
5292
|
+
holds it for the session, `Color#quantize(depth)` maps a color to the nearest
|
|
5293
|
+
one that depth can show, and `Buffer#flush` applies that to every color on its
|
|
5294
|
+
way to the wire. Upstream issue #8.
|
|
5295
|
+
|
|
5296
|
+
**Why it became necessary.** `Color#sgr_codes` emits `48;2;R;G;B` for every RGB
|
|
5297
|
+
color unconditionally, which was fine while RGB only ever came from a
|
|
5298
|
+
declaration site — a human picking a theme constant for a terminal they were
|
|
5299
|
+
looking at. `D_background_rgb` changed that: an app can now *read* the
|
|
5300
|
+
background and *derive* a color from it (virtui's borderless-pane tint steps the
|
|
5301
|
+
reported background toward its own pole), so Tuile hands out an RGB value the
|
|
5302
|
+
app has no safe way to write back out. Under a 256-color terminal, or tmux
|
|
5303
|
+
without `terminal-features "*:RGB"`, that computed `48;2;…` is mangled or
|
|
5304
|
+
silently approximated.
|
|
5305
|
+
|
|
5306
|
+
**Why the downgrade is automatic, and why it lives in `Buffer#flush`.** The
|
|
5307
|
+
deciding argument is that *not all RGB has a call site to opt in at*. RGB enters
|
|
5308
|
+
an app three ways: **declared** (a `Color.hex` theme token), **computed** (the
|
|
5309
|
+
tint), and **parsed** — `StyledString.parse` ingests ANSI produced by other
|
|
5310
|
+
programs, e.g. a `Component::LogTextView` fed a tool's colored output. Parsed
|
|
5311
|
+
colors arrive as *data*, with no declaration site an app could quantize at, and
|
|
5312
|
+
`StyledString` must stay depth-unaware (it is a frozen value type with a
|
|
5313
|
+
`parse(to_ansi(x)) == x` round-trip and no `Screen` dependency — the same rule
|
|
5314
|
+
that keeps it theme-unaware). Only a choke point on the wire catches that case.
|
|
5315
|
+
|
|
5316
|
+
`flush` is that choke point: it is where logical cells become bytes, the same
|
|
5317
|
+
role `draw_text` plays for backgrounds. Quantization happens *before* the
|
|
5318
|
+
`Style#sgr_to` diff, so two RGBs landing on one palette cell emit a single SGR
|
|
5319
|
+
instead of two. The "but then `sgr_codes` is dishonest" objection dissolves at
|
|
5320
|
+
this placement — `Color` still emits exactly what it was given, and `flush`
|
|
5321
|
+
already doesn't emit what you wrote (it skips unchanged cells and wraps frames
|
|
5322
|
+
in sync batches). Adapting a logical frame to a physical terminal *is* the
|
|
5323
|
+
buffer's job. Nor does this reopen the framework's allergy to automatic
|
|
5324
|
+
channels: the deleted ones (`content_size`, `keyboard_hint`) were semantic
|
|
5325
|
+
queries the framework made *of components*; this consults nobody, adds no
|
|
5326
|
+
`Component` API, and is one field on `Buffer`.
|
|
5327
|
+
|
|
5328
|
+
**Roads not taken:**
|
|
5329
|
+
|
|
5330
|
+
- *Opt-in `quantize` only*, leaving apps to call it. Serves the computed case
|
|
5331
|
+
and nothing else — see the parsed case above. `quantize` stays public anyway,
|
|
5332
|
+
for an app that wants to *know* what a color becomes on the wire (checking a
|
|
5333
|
+
computed tint still contrasts with the background after both round to palette
|
|
5334
|
+
cells) without changing what it stores.
|
|
5335
|
+
- *Pre-quantizing at theme definition or at tint derivation.* Redundant (flush
|
|
5336
|
+
catches those anyway) and actively harmful: it bakes depth into stored state,
|
|
5337
|
+
the cache-in-an-ivar failure the theme and `bg_color` rules forbid. A stored
|
|
5338
|
+
`Color.palette(237)` has forgotten it was `#3a3a3a`, so any later contrast
|
|
5339
|
+
check or derivation works from the lossy copy. `ThemeDef.default` is built at
|
|
5340
|
+
load time anyway, before a `Screen` exists to supply a depth. The model is:
|
|
5341
|
+
**logical layer always truecolor, wire layer always terminal-native, one
|
|
5342
|
+
conversion at the boundary.**
|
|
5343
|
+
- *Raising at render on an unrepresentable color*, leaving apps to supply only
|
|
5344
|
+
representable ones. The fail-fast instinct matches the house "raise at
|
|
5345
|
+
registration, not gate at runtime" pattern, but that pattern works because it
|
|
5346
|
+
raises *at the write site, deterministically, on the developer's machine*.
|
|
5347
|
+
This inverts both: it fires at the read site, far from the assignment, and
|
|
5348
|
+
only on the *end user's* terminal — a developer's truecolor terminal and
|
|
5349
|
+
`FakeScreen`'s pinned `:truecolor` never see it, so it ships and crashes on
|
|
5350
|
+
tmux. Breaks-at-a-distance and silent-under-test, by design. It also converts
|
|
5351
|
+
"coarser shade" — which the terminal already approximates on its own — into
|
|
5352
|
+
"app dies mid-repaint", and no peer framework does it (Rich, Textual, tcell,
|
|
5353
|
+
chalk and notcurses all degrade).
|
|
5354
|
+
- *A keyed cache — memo or LRU — in front of `quantize`.* Measured and
|
|
5355
|
+
rejected (`benchmark/quantize.rb`, 1M calls, ruby 3.3.8): compute ~360
|
|
5356
|
+
ns/call on both workloads; an unbounded memo 158 ns (typical) / 217 ns
|
|
5357
|
+
(gradient, having grown to 100k entries); a 256-entry LRU 183 ns (typical)
|
|
5358
|
+
but **648 ns** on the gradient — 1.8× *slower* than just computing, because
|
|
5359
|
+
every miss pays lookup + compute + eviction. The bounded cache only wins the
|
|
5360
|
+
workload that needed no help, and the unbounded one is keyed on a 16.7M-entry
|
|
5361
|
+
input space with the parsed-ANSI case as its adversary.
|
|
5362
|
+
`Buffer::WIDTH_CACHE` is unbounded for reasons that don't transfer: distinct
|
|
5363
|
+
graphemes are bounded by fonts and languages, and each avoided gem call costs
|
|
5364
|
+
~20×. What is exploited instead is the *output* space — 240 palette cells and
|
|
5365
|
+
16 names — so frozen tables let `quantize` be pure arithmetic returning a
|
|
5366
|
+
shared instance, allocating nothing.
|
|
5367
|
+
|
|
5368
|
+
- *A `Screen#color_depth=` setter.* Detection runs once and the depth cannot
|
|
5369
|
+
change mid-session, `ColorDepth::OVERRIDE_ENV` already covers a terminal that
|
|
5370
|
+
misreports, and a setter drags in a real bug: `Buffer::Cell#set` flips dirty
|
|
5371
|
+
only on a *style* change, but a depth change alters the *bytes* an unchanged
|
|
5372
|
+
style emits — the minimal diff has nothing to notice, so the setter would owe
|
|
5373
|
+
a buffer-wide `mark_all_dirty`. Deleting the setter deletes the wrinkle.
|
|
5374
|
+
- *Consulting terminfo* (`RGB`, `colors#0x1000000`) as issue #8 suggested. Tuile
|
|
5375
|
+
has no terminfo access — tty-screen does geometry, not capabilities — so this
|
|
5376
|
+
means shelling out to `tput`/`infocmp` at every startup. The env ladder plus
|
|
5377
|
+
the override covers the real matrix, and its failure mode is *conservative*:
|
|
5378
|
+
tmux and ssh (which drops `COLORTERM`, not being in the default `SendEnv`
|
|
5379
|
+
set) under-report, which renders coarser but never mangled.
|
|
5380
|
+
- *Two-stepping RGB → 256 → 16* under `:ansi16`, reusing the palette quantizer.
|
|
5381
|
+
Compounds the rounding: `rgb(0, 0, 195)` is nearer bright blue than blue, but
|
|
5382
|
+
rounds to cube cell 19 `(0,0,175)` first and then picks blue off *that*.
|
|
5383
|
+
Direct nearest-of-16 costs one more table and is pinned by a spec.
|
|
5384
|
+
- *A "needs translation" predicate or form sugar beside `quantize`.* The need
|
|
5385
|
+
is a function of *(form, depth)* — a bare `from_palette?` is ambiguous
|
|
5386
|
+
between the 256 and 16 targets, and `full_rgb?` misses the palette→16 case —
|
|
5387
|
+
which is exactly the case analysis `quantize` already performs, so a separate
|
|
5388
|
+
`needs_quantize?(depth)` would restate it as a boolean and drift from it.
|
|
5389
|
+
Identity-return makes the predicate free instead:
|
|
5390
|
+
`color.quantize(depth).equal?(color)`. Form introspection (`named?` /
|
|
5391
|
+
`palette?` / `rgb?`, matching the factory names) is likewise left out until
|
|
5392
|
+
an app asks — `color.value` answers it. Same shape one level up: a
|
|
5393
|
+
`Screen#truecolor?` boolean instead of the three-valued symbol would leave
|
|
5394
|
+
`:ansi16` inexpressible.
|
|
5395
|
+
- *A perceptual distance metric.* Plain squared-Euclidean RGB. The place a
|
|
5396
|
+
perceptual weight would show is the cube-vs-grey-ramp tiebreak on near-greys
|
|
5397
|
+
— exactly what a background-derived tint produces — so `color_spec` pins a
|
|
5398
|
+
real stepped tint (`rgb(30,30,34)` → `palette(234)`), and the metric gets
|
|
5399
|
+
revisited only if that cell ever looks wrong on an actual screen.
|
|
5400
|
+
|
|
5401
|
+
**One memo *is* needed, and finding that took measuring the right thing.**
|
|
5402
|
+
`Buffer#quantized_style` runs per dirty **cell**, not per style transition —
|
|
5403
|
+
easy to get wrong from the design sketch, since `sgr_to` is what runs per
|
|
5404
|
+
transition. So a full-screen repaint of RGB-styled content paid the arithmetic
|
|
5405
|
+
8000 times for what was one span, and measured **51 ms against 15 ms** at
|
|
5406
|
+
`:truecolor`: a 3.4× regression landing squarely on the app the feature exists
|
|
5407
|
+
for, whose panes are painted in a computed tint. The fix is a *one-slot* memo —
|
|
5408
|
+
remember the last `(style → quantized style)` answer and compare by identity,
|
|
5409
|
+
sound because a `Style` is frozen. That collapses the work back onto genuine
|
|
5410
|
+
transitions and makes depth nearly free (10.7 ms vs 9.9 ms at `:truecolor`;
|
|
5411
|
+
`:ansi16` likewise). It is not the rejected cache in miniature: no key space, no
|
|
5412
|
+
eviction, two ivars, and it exploits run locality within a row rather than value
|
|
5413
|
+
recurrence across a session. Two supporting micro-optimizations in
|
|
5414
|
+
`nearest_palette` came out of the same pass — destructuring rather than
|
|
5415
|
+
splatting the triple, and `x * x` rather than `x**2`, together ~2× — which is
|
|
5416
|
+
why that method is written flat instead of tidy.
|
|
5417
|
+
|
|
5418
|
+
**A known-lossy corner, accepted.** `:ansi16` matching uses xterm's default RGBs
|
|
5419
|
+
for the 16 named colors, which a terminal's own scheme may redefine — the one
|
|
5420
|
+
mapping here that can be honestly wrong. It is documented on `Color#quantize`,
|
|
5421
|
+
and the result is a *named* color (SGR `30..37`/`90..97`), so the user's scheme
|
|
5422
|
+
still decides what is finally drawn. `TERM=linux` is about the only consumer.
|