tuile 0.13.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 +81 -37
- data/COMPARISON.md +101 -0
- data/DECISIONS.md +1095 -195
- data/README.md +19 -19
- data/TERMINOLOGY.md +6 -5
- data/book/03-layout.md +17 -10
- data/book/05-focus.md +4 -1
- data/book/06-theming.md +98 -0
- data/book/07-components.md +169 -19
- data/book/08-testing.md +16 -0
- data/book/09-styled-text.md +3 -3
- data/examples/file_commander.rb +1 -1
- data/examples/sampler.rb +143 -43
- data/ideas/arrow-key-navigation.md +2 -2
- data/ideas/modal-backdrop.md +24 -0
- data/ideas/new-components.md +24 -24
- 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/button.rb +3 -3
- data/lib/tuile/component/checkbox.rb +3 -3
- data/lib/tuile/component/combo_box.rb +9 -2
- 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_dropdown.rb +18 -10
- 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 +3 -3
- data/lib/tuile/component/menu_bar.rb +5 -5
- data/lib/tuile/component/notification.rb +16 -34
- data/lib/tuile/component/overlay.rb +192 -0
- data/lib/tuile/component/popup.rb +59 -187
- data/lib/tuile/component/progress_bar.rb +1 -1
- data/lib/tuile/component/select.rb +14 -6
- data/lib/tuile/component/slot.rb +54 -0
- data/lib/tuile/component/tab_sheet.rb +0 -11
- data/lib/tuile/component/tabs.rb +5 -5
- data/lib/tuile/component/window.rb +22 -46
- data/lib/tuile/component.rb +149 -19
- data/lib/tuile/event_queue.rb +21 -1
- data/lib/tuile/fake_screen.rb +26 -2
- data/lib/tuile/keys.rb +7 -0
- data/lib/tuile/screen.rb +120 -38
- data/lib/tuile/screen_pane.rb +37 -35
- data/lib/tuile/styled_string.rb +40 -7
- data/lib/tuile/terminal_background.rb +74 -16
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +1157 -368
- metadata +8 -1
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`
|
|
@@ -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`
|
|
@@ -2308,7 +2313,7 @@ are ~10-line concretes. That is the sanctioned cohesive base
|
|
|
2308
2313
|
|
|
2309
2314
|
---
|
|
2310
2315
|
|
|
2311
|
-
##
|
|
2316
|
+
## D_wrap_leading_space — An indent is content; no flag, and no hanging indent (2026-08-12)
|
|
2312
2317
|
|
|
2313
2318
|
**Status:** Accepted; implemented 2026-08-12 (`StyledString#wrap_one`). Fixes
|
|
2314
2319
|
[issue #2](https://github.com/mvysny/tuile/issues/2). The continuation half —
|
|
@@ -2377,17 +2382,17 @@ so it is a no-op there, and the indented case is the only one with an opinion.
|
|
|
2377
2382
|
|
|
2378
2383
|
---
|
|
2379
2384
|
|
|
2380
|
-
##
|
|
2385
|
+
## D_select — `Select`: the enum field, claiming no printable key but Space (2026-08-12)
|
|
2381
2386
|
|
|
2382
2387
|
**Status:** Accepted; `Component::Select` implemented 2026-08-12, demoed in the
|
|
2383
|
-
sampler. Builds on `
|
|
2384
|
-
resolve-don't-store-an-index rule, both adopted verbatim), `
|
|
2385
|
-
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`.
|
|
2386
2391
|
|
|
2387
2392
|
**Context.** A one-row closed-choice field: a face showing the selected item's
|
|
2388
|
-
label plus a `▾`, dropping open a `ListDropdown` of the options. `
|
|
2393
|
+
label plus a `▾`, dropping open a `ListDropdown` of the options. `D_combobox`
|
|
2389
2394
|
deferred it once ("filterable first"), on the assumption that it needed the
|
|
2390
|
-
read-only-field axis `
|
|
2395
|
+
read-only-field axis `D_has_value` parked for the forms layer. That assumption
|
|
2391
2396
|
was an artifact of picturing a read-only `TextField` as the face; nothing gates
|
|
2392
2397
|
this component.
|
|
2393
2398
|
|
|
@@ -2448,18 +2453,18 @@ dropdown as an overlay. Two consequences worth naming:
|
|
|
2448
2453
|
having the child removes the wrinkle instead of documenting it.
|
|
2449
2454
|
|
|
2450
2455
|
**Decision — promote `ComboBox#anchor` to `ListDropdown#anchor_to`.** Select needs
|
|
2451
|
-
byte-identical vertical geometry, and `
|
|
2456
|
+
byte-identical vertical geometry, and `D_float_field`'s duplicate-don't-DRY rule
|
|
2452
2457
|
**does not apply**: that licensed copying a *shell* around three genuine
|
|
2453
2458
|
differences, whereas this is the same computation with zero differences, so a
|
|
2454
2459
|
later fix to the flip rule would land in one copy and silently not the other —
|
|
2455
2460
|
and the symptom appears only near a screen edge, which is invisible under test.
|
|
2456
|
-
The promotion threshold is the project's existing one (`
|
|
2461
|
+
The promotion threshold is the project's existing one (`D_color_slots`: "a
|
|
2457
2462
|
*second* built-in needing the same thing"). Two rulings ride along:
|
|
2458
2463
|
|
|
2459
2464
|
- **Width stays a caller-supplied parameter** (defaulting to the anchor's), so
|
|
2460
2465
|
`ComboBox` keeps its lines-up-with-the-field policy and Select keeps its
|
|
2461
2466
|
measured one, and `anchor_to` never measures content itself. Same shape as
|
|
2462
|
-
`
|
|
2467
|
+
`D_box_layouts`' "`align:` is legal only because the cross extent is
|
|
2463
2468
|
caller-supplied", and it keeps Select's measuring within the top-down re-grow
|
|
2464
2469
|
rule: an optional, caller-side query feeding a rect the caller then assigns.
|
|
2465
2470
|
- **Horizontally we slide, vertically we flip.** Covering the driver would hide
|
|
@@ -2507,7 +2512,7 @@ two callers that already know the answer.
|
|
|
2507
2512
|
discover them one at a time by cycling, with no way to see the set or know how
|
|
2508
2513
|
many there are. The dropdown is better at every item count, so the
|
|
2509
2514
|
`ListDropdown` face is the only face, and the vocabulary does not grow a fourth
|
|
2510
|
-
closed-choice widget (cf. `
|
|
2515
|
+
closed-choice widget (cf. `D_box_layouts`' "there is no `Auto`"). *Re-grow
|
|
2511
2516
|
rule:* if it returns it is a **face** on this component (a `dropdown: false`
|
|
2512
2517
|
knob over the identical value seam), never a separate component, and it needs a
|
|
2513
2518
|
real argument about visibility rather than a row-budget one.
|
|
@@ -2518,7 +2523,7 @@ two callers that already know the answer.
|
|
|
2518
2523
|
one row itself.
|
|
2519
2524
|
- *A shared base with `RadioGroup`* (`AbstractClosedChoiceField`): the ~15-line
|
|
2520
2525
|
`items=` / `item_label=` / `label_for` shell is duplicated instead, per
|
|
2521
|
-
`
|
|
2526
|
+
`D_float_field`. The test is whether the commonality is a *shell around genuine
|
|
2522
2527
|
differences* or the *same computation* — `anchor_to` is the latter (extract), the
|
|
2523
2528
|
items shell is the former (duplicate). The three differences a base would have
|
|
2524
2529
|
to paper over with hooks: row rendering (`(*) label` glyphs vs. a bare label,
|
|
@@ -2526,7 +2531,7 @@ two callers that already know the answer.
|
|
|
2526
2531
|
Space commits the row it's on, vs. the highlight *being* the pending selection),
|
|
2527
2532
|
and where the rows live (always, in the component's own rect, vs. only while
|
|
2528
2533
|
open, in a `Popup`'s). Three hooks over fifteen lines, reached through
|
|
2529
|
-
inheritance, is the converter-strategy-by-inheritance shape `
|
|
2534
|
+
inheritance, is the converter-strategy-by-inheritance shape `D_float_field`
|
|
2530
2535
|
rejected — and it would couple two widgets that should stay free to diverge.
|
|
2531
2536
|
This is the third copy of that shell, the same count `IntegerField` /
|
|
2532
2537
|
`FloatField` / `BigDecimalField` reached; a *fourth* is when to re-argue it.
|
|
@@ -2544,7 +2549,7 @@ two callers that already know the answer.
|
|
|
2544
2549
|
attempt is keystroke-driven, so it would flood a host's log on autorepeat, and
|
|
2545
2550
|
an app may legitimately pass through item-less while loading. Enter/Space/Down
|
|
2546
2551
|
are claimed either way — one rule, no branch. (An item-less Select is arguably
|
|
2547
|
-
a *disabled* field, which touches the read-only/disabled axis `
|
|
2552
|
+
a *disabled* field, which touches the read-only/disabled axis `D_has_value`
|
|
2548
2553
|
parked for the forms layer. Not designed here, not foreclosed either.)
|
|
2549
2554
|
- The dropdown is measured to the widest label plus `List`'s **two** row gutters
|
|
2550
2555
|
(`pad_to_row` ellipsizes to `content_width - 2`, one leading and one trailing
|
|
@@ -2564,13 +2569,13 @@ two callers that already know the answer.
|
|
|
2564
2569
|
`ComboBox#active=` leans on, and filtering / row rendering / the commit action
|
|
2565
2570
|
really do vary.
|
|
2566
2571
|
|
|
2567
|
-
##
|
|
2572
|
+
## D_list_items — `List` takes items + a renderer, rendered lazily (2026-08-14)
|
|
2568
2573
|
|
|
2569
2574
|
**Status:** Accepted; implemented 2026-08-14, with the five composers folded onto
|
|
2570
|
-
it in the same series. Builds on `
|
|
2571
|
-
`
|
|
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
|
|
2572
2577
|
rather than fold a shallow commonality) and the top-down layout rule
|
|
2573
|
-
(`
|
|
2578
|
+
(`D_box_layouts`). Delivers the first half of the "typed items + data provider on
|
|
2574
2579
|
`List`" item that gated List Box, Grid and Virtual List.
|
|
2575
2580
|
|
|
2576
2581
|
**Context.** `List` took pre-rendered rows: `lines=` stored `Array<StyledString>`
|
|
@@ -2582,7 +2587,7 @@ seam:
|
|
|
2582
2587
|
because the framework handed back a string.
|
|
2583
2588
|
- Four components (`ComboBox`, `Select`, `RadioGroup`, `CheckboxGroup`) kept a
|
|
2584
2589
|
private copy of the `@items` / `@item_label` / `label_for` / `rebuild_rows`
|
|
2585
|
-
shell. `
|
|
2590
|
+
shell. `D_select` set the trigger for re-arguing a shared base at the *fourth*
|
|
2586
2591
|
copy; this is it.
|
|
2587
2592
|
|
|
2588
2593
|
**Decision — externalize rendering on the generic component.** `List` holds
|
|
@@ -2593,7 +2598,7 @@ follows elsewhere — a domain component takes data, a generic one takes strateg
|
|
|
2593
2598
|
|
|
2594
2599
|
**Not a shared base class.** The alternative reading of four duplicated shells is
|
|
2595
2600
|
"extract `AbstractItemsComponent`". That is exactly the `parse`/`format`-hook base
|
|
2596
|
-
`
|
|
2601
|
+
`D_float_field` rejected, one level up: it would need a render hook, a
|
|
2597
2602
|
commit-gesture hook and a where-do-rows-live hook to span a dropdown driver and a
|
|
2598
2603
|
row-per-item group. The duplication was a symptom of a missing *seam*, not of a
|
|
2599
2604
|
missing *ancestor*, and adding the seam deleted the duplication that actually
|
|
@@ -2703,12 +2708,12 @@ having `value=` rebuild every row is the O(n) pass this decision just deleted.
|
|
|
2703
2708
|
rendering — is the shape that makes the bug unsayable, and the PTY test now
|
|
2704
2709
|
presses Enter.
|
|
2705
2710
|
|
|
2706
|
-
##
|
|
2711
|
+
## D_scroll_nomenclature — `row` is the grid, `line` is `\n`, `items` are domain objects (2026-08-14)
|
|
2707
2712
|
|
|
2708
|
-
**Status:** Accepted; implemented 2026-08-14. Builds on `
|
|
2709
|
-
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`
|
|
2710
2715
|
(which named the index-vs-column axes inside the inputs) and
|
|
2711
|
-
`
|
|
2716
|
+
`D_ambiguous_width` (whose `display_width` is the column authority).
|
|
2712
2717
|
|
|
2713
2718
|
**Context.** Three scrolling components had grown three vocabularies for the
|
|
2714
2719
|
same four concepts — a content unit, a wrapped unit, a viewport-relative row,
|
|
@@ -2786,7 +2791,7 @@ which this scheme makes unwriteable — `line` is never a coordinate.
|
|
|
2786
2791
|
stop using row-words loosely. Naming no row is not an exception to "row
|
|
2787
2792
|
everywhere".
|
|
2788
2793
|
- **`List#items` → `List#rows`,** which a List item arguably is. Rejected:
|
|
2789
|
-
`items` is where `cop` wants the domain-object noun (`
|
|
2794
|
+
`items` is where `cop` wants the domain-object noun (`D_list_items` had just
|
|
2790
2795
|
landed it), and it is the word the enum widgets above `List` already use.
|
|
2791
2796
|
- **`scroll_top`** (CSS's `scrollTop`, shorter). Rejected for `scroll_top_row`:
|
|
2792
2797
|
it names no unit, and `list.scroll_top` reads as an imperative — *scroll to
|
|
@@ -2814,7 +2819,7 @@ which this scheme makes unwriteable — `line` is never a coordinate.
|
|
|
2814
2819
|
wrong.
|
|
2815
2820
|
- **`row_count` was reserved here, then created separately.** Making it a public
|
|
2816
2821
|
reader was held to be a behavioural addition needing its own argument; that
|
|
2817
|
-
argument is `
|
|
2822
|
+
argument is `D_text_area_rows`, which granted it on `TextArea` only. The point
|
|
2818
2823
|
this entry settled — that the *name* is already taken, so the addition need not
|
|
2819
2824
|
re-litigate its spelling — held.
|
|
2820
2825
|
- **`CHANGELOG.md` was not swept.** Its 0.4.0 entry announcing the `set_line` /
|
|
@@ -2822,10 +2827,10 @@ which this scheme makes unwriteable — `line` is never a coordinate.
|
|
|
2822
2827
|
and describes what shipped *then*, so retro-editing it would make a released
|
|
2823
2828
|
migration note reference a method that release did not have.
|
|
2824
2829
|
|
|
2825
|
-
##
|
|
2830
|
+
## D_text_area_rows — `TextArea#caret_row` / `#row_count`, not a hook and not the wrap (2026-08-15)
|
|
2826
2831
|
|
|
2827
2832
|
**Status:** Accepted; implemented 2026-08-15. Grants the reader
|
|
2828
|
-
`
|
|
2833
|
+
`D_scroll_nomenclature` reserved the name for. Answers
|
|
2829
2834
|
[#3](https://github.com/mvysny/tuile/issues/3).
|
|
2830
2835
|
|
|
2831
2836
|
**Context.** Shell-style prompt-history recall in a `TextArea`: Up recalls the
|
|
@@ -2865,7 +2870,7 @@ the auto-growing prompt strip — the case the name was reserved for — uses
|
|
|
2865
2870
|
0.9.0 layout re-grow rule explicitly sanctions the opposite — capability
|
|
2866
2871
|
returning as "an *optional, read-only, caller-side query* … never as an
|
|
2867
2872
|
automatic channel the framework consults" — which is also why
|
|
2868
|
-
`
|
|
2873
|
+
`D_scroll_nomenclature` rejected a general `Component` scroll seam. It serves
|
|
2869
2874
|
one question, in one direction, at one moment, where the readers also serve the
|
|
2870
2875
|
prompt strip, a "row 3/7" readout and a caller-drawn scrollbar. And it needs a
|
|
2871
2876
|
subclass, where the readers serve `on_key` too. In COP terms it is neither a
|
|
@@ -2897,7 +2902,7 @@ the auto-growing prompt strip — the case the name was reserved for — uses
|
|
|
2897
2902
|
responsible for keeping them consistent. **(4) It flips a written invariant for
|
|
2898
2903
|
no argued caller** — AGENTS.md holds the class private "until a second caller
|
|
2899
2904
|
actually exists", and nobody has asked for `row_text` / `index_at` from
|
|
2900
|
-
outside. Forwarders grow on demand at one line each; `
|
|
2905
|
+
outside. Forwarders grow on demand at one line each; `D_float_field`'s
|
|
2901
2906
|
temperament ("a fourth copy is when to re-argue it") applies.
|
|
2902
2907
|
- **A `wrapped_text` method documented "do not store".** Same staleness, renamed.
|
|
2903
2908
|
- **A validity token on `WrappedText`,** so a holder can detect a stale snapshot.
|
|
@@ -2921,7 +2926,7 @@ the auto-growing prompt strip — the case the name was reserved for — uses
|
|
|
2921
2926
|
which is the whole reason the object stays private. Specs pin that both track a
|
|
2922
2927
|
text change and a width change.
|
|
2923
2928
|
|
|
2924
|
-
##
|
|
2929
|
+
## D_text_view_scroll_verbs — `TextView#scroll_half_page_up` / `#scroll_half_page_down` (2026-08-15)
|
|
2925
2930
|
|
|
2926
2931
|
**Status:** Accepted; implemented 2026-08-15. Amended 2026-08-23: the
|
|
2927
2932
|
`active?` guard this was originally argued *from* turned out to be dead code
|
|
@@ -2966,7 +2971,7 @@ it never learns the row count, never clamps, and never touches focus.
|
|
|
2966
2971
|
"Page" should page, it would break `Ctrl+U`/`Ctrl+D`'s reason to exist, and it
|
|
2967
2972
|
fixes nothing anyway: an unfocused view still sees no keys.
|
|
2968
2973
|
- **Ship the whole ladder as verbs** (full page, top, bottom, by-row). No caller
|
|
2969
|
-
yet; `
|
|
2974
|
+
yet; `D_text_area_rows`'s temperament applies — a future caller argues its own
|
|
2970
2975
|
case, and these two settle the spelling for the rest.
|
|
2971
2976
|
|
|
2972
2977
|
**Consequences.**
|
|
@@ -2999,10 +3004,10 @@ widget now obeys the framework-wide rule (AGENTS.md, book ch5) that a
|
|
|
2999
3004
|
key to an unfocused view now scrolls it, which is what every other widget in the
|
|
3000
3005
|
gem already did (`examples/sampler.rb`'s unfocused `List` is the house idiom).
|
|
3001
3006
|
|
|
3002
|
-
##
|
|
3007
|
+
## D_notification — One corner toast, N messages, one ticker draining them (2026-08-17)
|
|
3003
3008
|
|
|
3004
3009
|
**Status:** Accepted and implemented, `Component::Notification`. Builds on
|
|
3005
|
-
`
|
|
3010
|
+
`D_attach_hooks` (the synced-from-an-invariant ticker), `D_color_slots` (the
|
|
3006
3011
|
per-message color), and Tier 1 of the component survey. Book ch7 "Notifications"
|
|
3007
3012
|
is the user-facing half; the rdoc owns the per-symbol contract. What this entry
|
|
3008
3013
|
owns is *why each choice*, and the alternatives that looked right first.
|
|
@@ -3094,7 +3099,7 @@ makes "at most one" true.
|
|
|
3094
3099
|
- Corollary for a future factory: `self.show` calls bare `new`, never
|
|
3095
3100
|
`Notification.new`, so a subclass's `show` builds the subclass.
|
|
3096
3101
|
- This widget is what surfaced `Popup.self.open` as a subclass trap (it had to be
|
|
3097
|
-
privatized here too, until the factory was deleted outright — `
|
|
3102
|
+
privatized here too, until the factory was deleted outright — `D_popup_open`).
|
|
3098
3103
|
|
|
3099
3104
|
### The singleton lives in the popups stack, never in a class ivar
|
|
3100
3105
|
|
|
@@ -3212,11 +3217,11 @@ second *kind* of anchoring that would unlock it (per the component survey), but
|
|
|
3212
3217
|
`Notification` ships its own `reposition` first so the extraction is judged with
|
|
3213
3218
|
two real implementations rather than one and a guess.
|
|
3214
3219
|
|
|
3215
|
-
##
|
|
3220
|
+
## D_popup_open — No class-level `Popup.open` factory; `#open` returns `self` (2026-08-17)
|
|
3216
3221
|
|
|
3217
3222
|
**Status:** Accepted and implemented; `Component::Popup.open` **removed**, and
|
|
3218
3223
|
`Popup#open` now returns `self`. Surfaced while building
|
|
3219
|
-
{Tuile::Component::Notification} (`
|
|
3224
|
+
{Tuile::Component::Notification} (`D_notification`), which had to privatize the
|
|
3220
3225
|
inherited factory to stop it undermining a private constructor.
|
|
3221
3226
|
|
|
3222
3227
|
**Context.** `Popup.open(content:, modal:, size:)` was one-line sugar for
|
|
@@ -3257,7 +3262,7 @@ own arguments, and wraps the popup rather than *being* one — none of them is a
|
|
|
3257
3262
|
inherited factory, so the trap does not apply. `popup_spec` asserts that neither
|
|
3258
3263
|
`Popup` nor `ListDropdown` responds to `open` at the class level.
|
|
3259
3264
|
|
|
3260
|
-
##
|
|
3265
|
+
## D_bracketed_paste — A paste is its own event, not a burst of keys (2026-08-23)
|
|
3261
3266
|
|
|
3262
3267
|
**Status:** Accepted and implemented in `Keys` (`BRACKETED_PASTE_ON`,
|
|
3263
3268
|
`PASTE_START`, `read_paste`, `normalize_paste`), `EventQueue::PasteEvent`,
|
|
@@ -3277,7 +3282,7 @@ gone before the second arrived.
|
|
|
3277
3282
|
|
|
3278
3283
|
Nothing downstream can repair that. By the time `handle_key("\r")` runs, "the
|
|
3279
3284
|
user pressed Enter" and "the clipboard held a line break" are the same event.
|
|
3280
|
-
The only downstream lever is inter-keystroke timing, which `
|
|
3285
|
+
The only downstream lever is inter-keystroke timing, which `D_select` already
|
|
3281
3286
|
rejected for type-ahead on exactly this ground: a terminal degrades that signal
|
|
3282
3287
|
(bytes in one read burst merge into a single key) and a paste has no gaps at all.
|
|
3283
3288
|
The information exists only at the layer that talks to the terminal, which is
|
|
@@ -3308,7 +3313,7 @@ mechanism.
|
|
|
3308
3313
|
thread posts one event carrying the whole payload; `Screen#event_loop` routes it
|
|
3309
3314
|
to `handle_paste` down the focus chain, with the same modal scoping as a key and
|
|
3310
3315
|
no other rung. *Rejected: reusing `KeyEvent` with a flag*, which would put a
|
|
3311
|
-
`pasted?` predicate on the ladder and re-create the runtime gate `
|
|
3316
|
+
`pasted?` predicate on the ladder and re-create the runtime gate `D_key_dispatch`
|
|
3312
3317
|
deleted — every `handle_key` would have to remember to check it, and the ones
|
|
3313
3318
|
that forgot would be exactly today's bug. *Rejected: replaying an unhandled paste
|
|
3314
3319
|
as individual keys.* It reads like graceful degradation and is the ambiguity
|
|
@@ -3356,7 +3361,7 @@ as `\n` and a typed one as `\r`, which is backwards and read as though multi-lin
|
|
|
3356
3361
|
paste already worked. Accepting {Keys::CTRL_J} is still right, but its
|
|
3357
3362
|
justification is now the honest one: that is the byte a *typed* Ctrl+J sends.
|
|
3358
3363
|
|
|
3359
|
-
##
|
|
3364
|
+
## D_repaint_cascade — the repaint cascade skips the clear, never the invalidate (2026-08-23)
|
|
3360
3365
|
|
|
3361
3366
|
**Status:** Accepted and implemented in {Tuile::Component#repaint}. Found while
|
|
3362
3367
|
building {Tuile::Component::TabSheet}, but the bug predates it and was already
|
|
@@ -3396,7 +3401,7 @@ the invalidation set and the buffer were both self-consistent.
|
|
|
3396
3401
|
A container that paints nothing of its own can only redraw its area *through* its
|
|
3397
3402
|
children, so being invalidated has to mean invalidating them. The tiling test
|
|
3398
3403
|
keeps doing the one job it is good for — deciding whether there is a gap worth
|
|
3399
|
-
blanking, which is what `
|
|
3404
|
+
blanking, which is what `D_progress_bar`'s "never blank a cell you are about to
|
|
3400
3405
|
paint over" cares about.
|
|
3401
3406
|
|
|
3402
3407
|
**Why the extra invalidation is not a cost.** It is a repaint of a subtree that
|
|
@@ -3424,15 +3429,15 @@ moves, and only on the frames where an ancestor cleared.
|
|
|
3424
3429
|
the tree below it is none of its business. Each container forwarding one hop is
|
|
3425
3430
|
the local rule that composes.
|
|
3426
3431
|
|
|
3427
|
-
##
|
|
3432
|
+
## D_tabs — `Tabs` / `TabSheet`: a strip, and a strip that swaps panes (2026-08-23)
|
|
3428
3433
|
|
|
3429
3434
|
**Status:** Accepted; `Component::Tabs` (with `Tabs::Tab`) and
|
|
3430
3435
|
`Component::TabSheet` implemented 2026-08-23, demoed in the sampler, taught in
|
|
3431
3436
|
book ch7 ("Switching between views"). Brainstormed in `ideas/tabs.md`, now
|
|
3432
|
-
retired. Leans on `
|
|
3433
|
-
precedent for a selection kept *out* of that seam), `
|
|
3434
|
-
identities), `
|
|
3435
|
-
glyph), `
|
|
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
|
|
3436
3441
|
detachment fires).
|
|
3437
3442
|
|
|
3438
3443
|
**Context.** Several views, one visible at a time, and a one-row strip of
|
|
@@ -3446,7 +3451,7 @@ goes with it.
|
|
|
3446
3451
|
test that decides it, and it generalizes: **would a form save it?** A
|
|
3447
3452
|
`RadioGroup`'s selection *is* the datum being edited, so it is a value; a tab's
|
|
3448
3453
|
selection is where the user is looking — nothing saves it, nothing validates it,
|
|
3449
|
-
and a forms layer iterating fields must never find it. `
|
|
3454
|
+
and a forms layer iterating fields must never find it. `D_progress_bar` made the
|
|
3450
3455
|
same call one step further out (a `value` that is a read-only report), and
|
|
3451
3456
|
`List` has held a cursor and an `on_item_chosen` without being a field since it
|
|
3452
3457
|
existed. External corroboration: **Vaadin's `Tabs` is not a field either** — it
|
|
@@ -3497,7 +3502,7 @@ smuggled in under one component. AGENTS.md carries the one-line invariant.
|
|
|
3497
3502
|
|
|
3498
3503
|
**Decision — one tab stop for the whole strip, and arrows activate
|
|
3499
3504
|
immediately.** Three arguments against a component per tab, in order of force:
|
|
3500
|
-
"exactly one stop per widget" (`
|
|
3505
|
+
"exactly one stop per widget" (`D_has_value`), and n tabs would mean n Tab
|
|
3501
3506
|
presses before the content is reachable; making the *Tab key* walk between
|
|
3502
3507
|
*tabs* is the one thing the key ladder forbids by construction (Tab is claimed
|
|
3503
3508
|
above everything and means "leave this widget"), so it would read as a feature
|
|
@@ -3514,7 +3519,7 @@ highlighted.** Manual activation needs two states on one row — the selection a
|
|
|
3514
3519
|
the roamed-to tab — and therefore two visual channels to separate them, on a
|
|
3515
3520
|
strip that spends both on the selection alone (below). `RadioGroup` could afford
|
|
3516
3521
|
that split vertically because each row has a glyph column of its own
|
|
3517
|
-
(`
|
|
3522
|
+
(`D_radio_group`); a one-row strip cannot, and two highlights side by side read
|
|
3518
3523
|
as noise rather than as two kinds of state. Auto-activation deletes the
|
|
3519
3524
|
distinction instead of styling it, and every code path — paint, hit test,
|
|
3520
3525
|
callback — has one index to consult. Consequence, and it runs the opposite way
|
|
@@ -3524,7 +3529,7 @@ that owes its own answer (a cheap placeholder, or building on a settle delay),
|
|
|
3524
3529
|
not a return to Enter-to-activate.
|
|
3525
3530
|
|
|
3526
3531
|
**Decision — the strip claims LEFT / RIGHT and the mouse, and nothing else.**
|
|
3527
|
-
`
|
|
3532
|
+
`D_select`'s contract restated: Enter and Space have nothing to do once arrows
|
|
3528
3533
|
activate, and declining them keeps a form's default button and the app's keys
|
|
3529
3534
|
alive. UP / DOWN are declined so a future arrow-navigating layout can move focus
|
|
3530
3535
|
*out* of the strip on the axis the strip doesn't use. HOME / END are declined
|
|
@@ -3557,7 +3562,7 @@ in the gem had used bold and a caption is a `StyledString` that may carry its ow
|
|
|
3557
3562
|
colors.
|
|
3558
3563
|
|
|
3559
3564
|
**Decision — the separator is `│`, the glyph `Window` paints its borders with,
|
|
3560
|
-
not ASCII `|`.** This inverts `
|
|
3565
|
+
not ASCII `|`.** This inverts `D_ambiguous_width`'s "a new component defaults to
|
|
3561
3566
|
ASCII when the pretty glyph is Ambiguous", and the inversion is the point: that
|
|
3562
3567
|
rule exists to keep the Ambiguous inventory small and enumerable, and `│` is
|
|
3563
3568
|
already *in* the inventory — `window.rb` paints it on every window, and nothing
|
|
@@ -3573,7 +3578,7 @@ separator column. Every segment has the same shape including the first and last
|
|
|
3573
3578
|
covers the padding, because one that stopped at the glyphs would read as a
|
|
3574
3579
|
ragged smear; and a click on a padding column selects that tab, while the
|
|
3575
3580
|
separator column selects nothing — same rule as the blank tail past `extent`,
|
|
3576
|
-
which focuses without selecting (`
|
|
3581
|
+
which focuses without selecting (`D_boolean_fields`). One private `segments`
|
|
3577
3582
|
method is the sole source of that arithmetic, read by *both* the paint and the
|
|
3578
3583
|
hit test, and derived from the captions on each call rather than recorded during
|
|
3579
3584
|
the last paint — so a hit test is correct before the first paint and after a
|
|
@@ -3598,12 +3603,12 @@ Rejected — *segment-aligned scrolling* (the offset always a segment start): it
|
|
|
3598
3603
|
buys clean edges and needs no glyph snapping, but wastes up to a segment of width
|
|
3599
3604
|
at the right edge, and a strip this narrow is exactly where columns are scarce.
|
|
3600
3605
|
Rejected — *reserved cue columns*: reserving two columns makes the window width a
|
|
3601
|
-
function of the scroll state that is computed from it, which is `
|
|
3606
|
+
function of the scroll state that is computed from it, which is `D_select`'s
|
|
3602
3607
|
`:auto`-scrollbar circularity, and shifts the whole strip sideways when a caption
|
|
3603
3608
|
is edited. The cues are **overlaid** on the edge columns instead, keeping the
|
|
3604
3609
|
style of the cell they cover so one landing on the selected segment doesn't punch
|
|
3605
3610
|
a hole in its highlight, and they are ASCII `<` / `>` — `‹ ›` are Ambiguous-width
|
|
3606
|
-
(`
|
|
3611
|
+
(`D_ambiguous_width`), and a `cue_glyphs=` knob with no caller is a knob to argue
|
|
3607
3612
|
about later. They stay chrome, not buttons: a click on a cue falls through to the
|
|
3608
3613
|
half-visible segment under it, which selects it and reveals it — the direction the
|
|
3609
3614
|
cue pointed anyway — where a clickable cue would need the column to hit-test
|
|
@@ -3629,12 +3634,12 @@ returns a `Tabs::Tab`, Vaadin-style. The test that separates the two is sharper
|
|
|
3629
3634
|
than "items feel wrong": **an item is an element of a collection someone else
|
|
3630
3635
|
owns** — assignment is whole-collection, and an item carries no per-element
|
|
3631
3636
|
state, the renderer deriving everything from the object each paint (which is why
|
|
3632
|
-
`
|
|
3637
|
+
`D_list_items` *removed* the appenders). **A tab is identity plus per-element
|
|
3633
3638
|
mutable state**, minted by the widget and living as long as it, and re-assigning
|
|
3634
3639
|
the whole set — the operation an items API is built around — is precisely what a
|
|
3635
3640
|
strip must never offer: it would destroy tab identity and with it `TabSheet`'s
|
|
3636
3641
|
pane mapping. Two corollaries make the ruling durable: the unbuilt half of
|
|
3637
|
-
`
|
|
3642
|
+
`D_list_items` is a *data provider* behind `items`, and a provider cannot own
|
|
3638
3643
|
per-tab state, so `HasItems` would arrive carrying a promise Tabs must refuse
|
|
3639
3644
|
(paging tabs is meaningless — a million tabs is not a UI); and the growth path
|
|
3640
3645
|
here is per-element *attributes* (hidden, disabled, closeable), which items have
|
|
@@ -3694,7 +3699,7 @@ which is misleading (the pane is *derived* from the selection, not assignable);
|
|
|
3694
3699
|
see a click; and `HasContent#on_focus` forwards focus into the content, which is
|
|
3695
3700
|
the behavior this design rejects (switching a tab must not move focus into the
|
|
3696
3701
|
new pane — browser and Vaadin behavior). What *is* reused is the slot-swap
|
|
3697
|
-
recipe `
|
|
3702
|
+
recipe `D_tree_api` specifies for `Window`: detach without notifying, rewire,
|
|
3698
3703
|
then `on_child_removed` last, so the focus repair cascades into the *new*
|
|
3699
3704
|
occupant. `TabSheet` overrides that hook to land focus on **the strip** rather
|
|
3700
3705
|
than on itself, which is not focusable; the other candidate (the new pane's
|
|
@@ -3778,7 +3783,7 @@ across caption edits and reorderings of nothing else. And a starved strip stays
|
|
|
3778
3783
|
wholly reachable, at the cost of a scroll offset that every future paint-time or
|
|
3779
3784
|
hit-test change has to keep threading through one place.
|
|
3780
3785
|
|
|
3781
|
-
##
|
|
3786
|
+
## D_menu_bar — `MenuBar`: a focused strip driving a cascade of `ListDropdown`s (2026-08-24)
|
|
3782
3787
|
|
|
3783
3788
|
**Status:** Accepted; v1 (`Component::MenuBar` with `MenuBar::Item` and the
|
|
3784
3789
|
private `MenuBar::Cascade`) implemented 2026-08-24, demoed in the sampler, taught
|
|
@@ -3787,7 +3792,7 @@ in book ch7 ("Menus"); v2 (mnemonics) the same day. Designed in a since-retired
|
|
|
3787
3792
|
Terminal.Gui, notcurses, MC, and the frameworks that have no menu) this entry
|
|
3788
3793
|
only summarizes.
|
|
3789
3794
|
|
|
3790
|
-
**Update 2026-08-24: a narrow bar scrolls**, on `
|
|
3795
|
+
**Update 2026-08-24: a narrow bar scrolls**, on `D_tabs`' scrolling decision,
|
|
3791
3796
|
which both strips implement identically (one private `left_column`, one
|
|
3792
3797
|
`adjust_left_column` as its sole writer, ASCII cues overlaid on the edge
|
|
3793
3798
|
columns). `MenuBar`'s share of it: one private `highlight=` funnels the arrow,
|
|
@@ -3802,7 +3807,7 @@ placement*, not a second kind of overlay.
|
|
|
3802
3807
|
the open menus are non-modal `ListDropdown`s mounted on the `ScreenPane` — owned
|
|
3803
3808
|
by the bar, parented by nobody — so every key arrives at `MenuBar#handle_key`,
|
|
3804
3809
|
which offers it to a `Cascade` first. That is `Select`'s architecture
|
|
3805
|
-
(`
|
|
3810
|
+
(`D_select`) extended to N levels, which is why **nothing in the key-dispatch
|
|
3806
3811
|
ladder changes** and why the whole widget is additive: two new placement helpers
|
|
3807
3812
|
on `ListDropdown`, one callback pass-through, and no change to `Popup`,
|
|
3808
3813
|
`ScreenPane` or `Component`.
|
|
@@ -3838,7 +3843,7 @@ error.
|
|
|
3838
3843
|
trigger is the first non-`List` content wanting anchoring (Tooltip, a
|
|
3839
3844
|
date-picker grid). It originally had a second half — a third placement method
|
|
3840
3845
|
on `ListDropdown`, from `ContextMenu`'s `anchor_at(point)` — which went dormant
|
|
3841
|
-
when that widget was iced (`
|
|
3846
|
+
when that widget was iced (`D_no_context_menu`).
|
|
3842
3847
|
- **A command-code bus** (Turbo Vision's `cmOpen` + `handleEvent`) instead of
|
|
3843
3848
|
per-item callables. Rejected: Ruby has closures, and Vaadin, Terminal.Gui and
|
|
3844
3849
|
ratatui's `tui-menu` all landed on per-item listeners.
|
|
@@ -3897,12 +3902,12 @@ extraction.
|
|
|
3897
3902
|
- **So does detaching**, from `on_detached`: the panels are the pane's children,
|
|
3898
3903
|
not the bar's, so nothing else would take them down.
|
|
3899
3904
|
- **An open menu swallows keys; a closed strip does not.** The one deliberate
|
|
3900
|
-
divergence from `
|
|
3905
|
+
divergence from `D_select`'s claim-the-minimum rule, and the honest reading of
|
|
3901
3906
|
what a menu is — an app key firing behind a visible panel is worse than a dead
|
|
3902
3907
|
keystroke.
|
|
3903
3908
|
- **A click outside an open cascade is not blocked**, because non-modal overlays
|
|
3904
3909
|
block nothing — but it does *dismiss*. The framework-level fix this entry
|
|
3905
|
-
called for (and declined to invent here) shipped as `
|
|
3910
|
+
called for (and declined to invent here) shipped as `D_outside_click`: the
|
|
3906
3911
|
pane closes every popup a left click missed, and `Cascade` reconciles its level
|
|
3907
3912
|
stack from each panel's `Popup#on_close`. The click itself still reaches
|
|
3908
3913
|
whatever is beneath.
|
|
@@ -3912,13 +3917,13 @@ extraction.
|
|
|
3912
3917
|
`open_below` / `handle_key` / `close` / `open?` it is a boundary; if it grows
|
|
3913
3918
|
accessors that expose the level stack, the "class" was only ever a seam and it
|
|
3914
3919
|
folds back in.
|
|
3915
|
-
- **Widths are measured per level, caller-side**, third repeat of the `
|
|
3920
|
+
- **Widths are measured per level, caller-side**, third repeat of the `D_select`
|
|
3916
3921
|
pattern (`anchor_to` and `anchor_beside` measure nothing). The submenu arrows
|
|
3917
3922
|
right-align against the level's *widest label*, a number the cascade already
|
|
3918
3923
|
has, so they line up without asking the `List` how wide it ended up.
|
|
3919
3924
|
- **The `▸` is Neutral, not Ambiguous** — verified, like `Select`'s `▾`. The
|
|
3920
3925
|
obvious `▶` / `▼` are Ambiguous and would have needed an ASCII opt-in under
|
|
3921
|
-
`
|
|
3926
|
+
`D_ambiguous_width`.
|
|
3922
3927
|
|
|
3923
3928
|
**Mnemonics (v2), and why they are legal.** `add_item(caption, mnemonic: "f")`
|
|
3924
3929
|
at *every* depth. AGENTS.md deleted `Component#key_shortcut` and the capture
|
|
@@ -3983,17 +3988,17 @@ reordering, dynamically computed items, open-on-hover
|
|
|
3983
3988
|
collapse-into-an-overflow-menu.
|
|
3984
3989
|
|
|
3985
3990
|
**Update 2026-08-24: `ContextMenu` is iced indefinitely** — designed, priced and
|
|
3986
|
-
declined the same day, in `
|
|
3991
|
+
declined the same day, in `D_no_context_menu`. It would have reused `Cascade` and
|
|
3987
3992
|
`Item` verbatim, which is why the two consequences above are worded the way they
|
|
3988
3993
|
are: the nested `Item` name is *settled* rather than deferred, and the `Popover`
|
|
3989
3994
|
extraction trigger keeps only its "first non-`List` content" half.
|
|
3990
3995
|
|
|
3991
|
-
##
|
|
3996
|
+
## D_outside_click — An outside click dismisses a popup, by flag not by notice (2026-08-24)
|
|
3992
3997
|
|
|
3993
3998
|
**Status:** Decided and implemented 2026-08-24. Designed in a since-retired
|
|
3994
3999
|
`ideas/outside-click-dismiss.md`, itself split out of the declined `ContextMenu`
|
|
3995
|
-
(`
|
|
3996
|
-
`
|
|
4000
|
+
(`D_no_context_menu`), so this entry is the whole record. Supersedes the wart
|
|
4001
|
+
`D_menu_bar` recorded without fixing.
|
|
3997
4002
|
|
|
3998
4003
|
**Context.** Whether an open overlay closed when you clicked elsewhere depended
|
|
3999
4004
|
on what you happened to click *on*. A click on a focusable widget moved focus,
|
|
@@ -4113,13 +4118,13 @@ back would leave `depth` / `deepest` / `highlighted` all lying. It wires an
|
|
|
4113
4118
|
identity-keyed, idempotent delete — idempotent because the same notice also
|
|
4114
4119
|
arrives from its own `truncate` (which has already popped the entry) and from
|
|
4115
4120
|
teardown, in no guaranteed order. That is the shape the house rules ask for:
|
|
4116
|
-
`@levels` is a `
|
|
4117
|
-
state is *synced from an invariant*, not toggled by the hooks (`
|
|
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
|
|
4118
4123
|
`sync_ticker`). Per-level truncate closures wired at `push` are the toggle
|
|
4119
4124
|
version.
|
|
4120
4125
|
|
|
4121
4126
|
**Left button only.** `MouseEvent` is X10 press-only (no release, no motion), so
|
|
4122
|
-
there is no drag case. Excluding scroll is `
|
|
4127
|
+
there is no drag case. Excluding scroll is `D_notification`'s stray-spin lesson;
|
|
4123
4128
|
excluding `:right` keeps a future context action from nuking an open dropdown.
|
|
4124
4129
|
|
|
4125
4130
|
**Vaadin, verified against the 24 docs.** "Modal dialogs are closable in three
|
|
@@ -4161,7 +4166,7 @@ or a cleared flag.
|
|
|
4161
4166
|
about it; app modals keep `true` and opt out per dialog. The one accepted risk is
|
|
4162
4167
|
a stray click discarding a half-filled form dialog.
|
|
4163
4168
|
|
|
4164
|
-
##
|
|
4169
|
+
## D_no_context_menu — No `ContextMenu`: designed, priced and declined (2026-08-24)
|
|
4165
4170
|
|
|
4166
4171
|
**Status:** Decided 2026-08-24 — **not building it**, indefinitely. Designed in a
|
|
4167
4172
|
since-retired `ideas/context-menu.md` (opened and graduated the same day), so
|
|
@@ -4252,10 +4257,10 @@ to stay available as a mnemonic — and for `keyboard_hint`.
|
|
|
4252
4257
|
component along the rect chain, ancestor first and deepest last, so "which
|
|
4253
4258
|
target owns this click" has no answer. (What that ordering *would* give free is
|
|
4254
4259
|
deepest-wins, if a revival adds "opening one closes any other open context
|
|
4255
|
-
menu" — the `
|
|
4260
|
+
menu" — the `D_notification` shape, found by scanning the popups stack rather
|
|
4256
4261
|
than a class ivar.)
|
|
4257
4262
|
- **Type-ahead search inside an open menu**, which `List#select_next` makes nearly
|
|
4258
|
-
free. Same rejection as in `
|
|
4263
|
+
free. Same rejection as in `D_menu_bar`: it competes with explicit mnemonics for
|
|
4259
4264
|
the same keystroke and owes a precedence rule.
|
|
4260
4265
|
|
|
4261
4266
|
**Two gaps it surfaced that outlive it.**
|
|
@@ -4263,17 +4268,17 @@ to stay available as a mnemonic — and for `keyboard_hint`.
|
|
|
4263
4268
|
- **An outside click on an open overlay notified nobody.** `Select`, `MenuBar`
|
|
4264
4269
|
and the sampler's slash menu all lingered on a click that landed on decoration,
|
|
4265
4270
|
and a modal popup could not dismiss on an outside click at all. **Closed**
|
|
4266
|
-
2026-08-24 by `
|
|
4271
|
+
2026-08-24 by `D_outside_click`, which also dissolved the modal/non-modal split
|
|
4267
4272
|
the gap was framed around.
|
|
4268
4273
|
- **A right-click does not move a `List` cursor.** `List::Cursor#handle_mouse`
|
|
4269
4274
|
acts on `:left` only (specced), and there is no public `item_index_at(point)`,
|
|
4270
4275
|
so "act on the row I clicked" is unsayable unless the app computes
|
|
4271
4276
|
`event.y - rect.top + scroll_top_row` itself. Nothing needs it today; it is the
|
|
4272
|
-
same shape of hole as the `List#select(index)` gap `
|
|
4277
|
+
same shape of hole as the `List#select(index)` gap `D_menu_bar` had to fill.
|
|
4273
4278
|
|
|
4274
4279
|
---
|
|
4275
4280
|
|
|
4276
|
-
##
|
|
4281
|
+
## D_status_bar — Delete the framework status bar; the app owns its bottom row (2026-08-25)
|
|
4277
4282
|
|
|
4278
4283
|
**Status:** Accepted 2026-08-25; unimplemented. Supersedes the shipped
|
|
4279
4284
|
`ScreenPane#status_bar` slot and the `Component#keyboard_hint` channel that fed
|
|
@@ -4385,7 +4390,7 @@ screen.on_focus_changed = -> { bar.text = hint_for(screen.focused) }
|
|
|
4385
4390
|
claimed the bar was "driven by focus" and showed "the focused context's own
|
|
4386
4391
|
advertised hint" — behavior that never existed; focus only triggered the
|
|
4387
4392
|
rebuild. Deleting it removes a documented promise the code never kept.
|
|
4388
|
-
- **`
|
|
4393
|
+
- **`D_boolean_fields`' aside is retired**, not overruled: "hints are a
|
|
4389
4394
|
window/popup-level affordance; per-field hints would drown the status bar" was
|
|
4390
4395
|
an argument about where a hint belongs, and there is no longer a framework
|
|
4391
4396
|
hint to place.
|
|
@@ -4405,7 +4410,7 @@ screen.on_focus_changed = -> { bar.text = hint_for(screen.focused) }
|
|
|
4405
4410
|
same baked app policy as the `"q quit"` string, but it is *dispatch*, not
|
|
4406
4411
|
presentation, and it is separable — deleting it would make every example and
|
|
4407
4412
|
both downstream apps grow a quit handler in the same breath as an unrelated
|
|
4408
|
-
change. **Ruled 2026-08-25 by `
|
|
4413
|
+
change. **Ruled 2026-08-25 by `D_quit_key`: it stays, unadvertised**, on the
|
|
4409
4414
|
same convention argument as the popup's lost `q Close` above.
|
|
4410
4415
|
|
|
4411
4416
|
**There is no app-facing `keyboard_hint` convention, and the book must not
|
|
@@ -4428,7 +4433,7 @@ framework-owned row. Textual is the shape to copy if it does: its `Footer` is a
|
|
|
4428
4433
|
widget the app mounts in `compose()`, reading from the `BINDINGS` table the
|
|
4429
4434
|
framework owns ⚠. That splits ownership at the right seam — the app decides
|
|
4430
4435
|
whether a bar exists and where, the widget declares its keys — and it is already
|
|
4431
|
-
on record as steal-candidate #1 in `
|
|
4436
|
+
on record as steal-candidate #1 in `D_key_dispatch`. Bringing back a bar the
|
|
4432
4437
|
framework *places* reopens this entry.
|
|
4433
4438
|
|
|
4434
4439
|
**Prior art** (surveyed 2026-08-25; ⚠ marks memory-based claims worth checking
|
|
@@ -4460,17 +4465,17 @@ this one — and no app ever invoked it.
|
|
|
4460
4465
|
|
|
4461
4466
|
---
|
|
4462
4467
|
|
|
4463
|
-
##
|
|
4468
|
+
## D_quit_key — `q` / ESC quit the loop, unadvertised, as a Tuile quirk (2026-08-25)
|
|
4464
4469
|
|
|
4465
4470
|
**Status:** Accepted 2026-08-25; no code change — this records a decision to
|
|
4466
|
-
*keep* what ships. Closes the question `
|
|
4471
|
+
*keep* what ships. Closes the question `D_status_bar` deferred.
|
|
4467
4472
|
|
|
4468
4473
|
**Context.** `Screen#event_loop` ends with
|
|
4469
4474
|
`@event_queue.stop if !handled && ["q", Keys::ESC].include?(key)` — after the
|
|
4470
4475
|
three-rung ladder has declined a key, bare `q` or ESC stops the loop and the
|
|
4471
4476
|
app exits. It is app policy the framework enforces, and no app opted into it.
|
|
4472
4477
|
|
|
4473
|
-
`
|
|
4478
|
+
`D_status_bar` deleted the framework status bar and with it the hardcoded
|
|
4474
4479
|
`"q quit"` prefix that was this fallback's only advertisement, deliberately
|
|
4475
4480
|
leaving the behavior alone as a separate question. That left the least coherent
|
|
4476
4481
|
state of the three: a hardcoded quit key with nothing anywhere surfacing it.
|
|
@@ -4481,10 +4486,10 @@ open question.
|
|
|
4481
4486
|
- **It is a convention, not an invention.** `q` quits `less`, `man`, `top`,
|
|
4482
4487
|
`htop` and every pager git shells out to; ESC dismisses. A user arriving at a
|
|
4483
4488
|
full-screen terminal app already tries both. That is the same argument that
|
|
4484
|
-
settled the popup's lost `q Close` hint in `
|
|
4489
|
+
settled the popup's lost `q Close` hint in `D_status_bar` — a convention the
|
|
4485
4490
|
user brings is not something each app must teach.
|
|
4486
4491
|
- **The escape hatch already exists and needs no new surface.** A component
|
|
4487
|
-
keeps `q` by consuming it, which is the whole of `
|
|
4492
|
+
keeps `q` by consuming it, which is the whole of `D_key_dispatch`'s
|
|
4488
4493
|
delivery rung: a focused {Component::TextField} does it for free (`q` is
|
|
4489
4494
|
printable — this is why pikuri-tui's shells never quit on a typed `q`), and an
|
|
4490
4495
|
app wanting `q` as a command binds it in the scope root's `handle_key`. ESC
|
|
@@ -4520,3 +4525,898 @@ open question.
|
|
|
4520
4525
|
- **What would reopen it:** a real app that needs bare `q` at the scope root and
|
|
4521
4526
|
finds consuming it awkward, or a second key wanting the same treatment (which
|
|
4522
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.
|