tuile 0.8.0 → 0.10.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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -0
  3. data/DECISIONS.md +1961 -0
  4. data/README.md +82 -48
  5. data/book/01-first-app.md +186 -0
  6. data/book/02-repaint.md +177 -0
  7. data/book/03-layout.md +379 -0
  8. data/book/04-event-loop.md +295 -0
  9. data/book/05-focus.md +219 -0
  10. data/book/06-theming.md +302 -0
  11. data/book/07-components.md +585 -0
  12. data/book/08-testing.md +199 -0
  13. data/book/09-styled-text.md +132 -0
  14. data/book/README.md +85 -0
  15. data/examples/hello_world.rb +1 -2
  16. data/examples/sampler.rb +435 -20
  17. data/ideas/new-components.md +109 -0
  18. data/ideas/per-component-buffers.md +55 -0
  19. data/lib/tuile/buffer.rb +113 -43
  20. data/lib/tuile/color.rb +4 -10
  21. data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
  22. data/lib/tuile/component/button.rb +25 -29
  23. data/lib/tuile/component/checkbox.rb +133 -0
  24. data/lib/tuile/component/checkbox_group.rb +188 -0
  25. data/lib/tuile/component/combo_box.rb +281 -0
  26. data/lib/tuile/component/has_caption.rb +44 -0
  27. data/lib/tuile/component/has_content.rb +5 -5
  28. data/lib/tuile/component/has_value.rb +64 -0
  29. data/lib/tuile/component/info_window.rb +4 -2
  30. data/lib/tuile/component/integer_field.rb +135 -0
  31. data/lib/tuile/component/label.rb +20 -25
  32. data/lib/tuile/component/layout.rb +3 -26
  33. data/lib/tuile/component/list.rb +8 -33
  34. data/lib/tuile/component/list_dropdown.rb +106 -0
  35. data/lib/tuile/component/log_window.rb +0 -14
  36. data/lib/tuile/component/password_field.rb +105 -0
  37. data/lib/tuile/component/popup.rb +70 -79
  38. data/lib/tuile/component/progress_bar.rb +278 -0
  39. data/lib/tuile/component/radio_group.rb +188 -0
  40. data/lib/tuile/component/text_area.rb +189 -65
  41. data/lib/tuile/component/text_field.rb +170 -32
  42. data/lib/tuile/component/text_view.rb +57 -137
  43. data/lib/tuile/component/window.rb +88 -121
  44. data/lib/tuile/component.rb +246 -142
  45. data/lib/tuile/event_queue.rb +39 -21
  46. data/lib/tuile/fake_event_queue.rb +32 -7
  47. data/lib/tuile/fake_screen.rb +4 -5
  48. data/lib/tuile/fraction.rb +42 -0
  49. data/lib/tuile/screen.rb +210 -109
  50. data/lib/tuile/screen_pane.rb +56 -44
  51. data/lib/tuile/styled_string.rb +112 -83
  52. data/lib/tuile/theme.rb +78 -41
  53. data/lib/tuile/version.rb +1 -1
  54. data/sig/tuile.rbs +2291 -890
  55. metadata +28 -9
  56. data/ideas/back-buffer.md +0 -217
  57. data/lib/tuile/sizing.rb +0 -59
data/DECISIONS.md ADDED
@@ -0,0 +1,1961 @@
1
+ # DECISIONS.md
2
+
3
+ A living record of the design decisions behind Tuile — especially the
4
+ *roads not taken*. It exists because the graduation pipeline (see
5
+ `AGENTS.md`) is lossy: when an `ideas/*.md` note is retired, its
6
+ user-facing half moves to the book and its invariant half to AGENTS.md,
7
+ but the **rationale for the rejected alternative** used to evaporate.
8
+ This file is that rationale's durable home.
9
+
10
+ It is the *why-we-chose* record; it is not the *how-it-works* reference
11
+ (rdoc), the *why-the-concept* narrative (the book), or the
12
+ *what-you-must-not-break* list (AGENTS.md). When a fact belongs in one of
13
+ those, put it there and don't restate it — an entry here links out rather
14
+ than duplicating.
15
+
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 kebab hint at the subject
18
+ (`D-bg-inherit`), so a reference carries meaning on its own — a running
19
+ counter would not. The `(date)` on the heading is *decided* provenance,
20
+ not a log position; git owns the edit history (consistent with the "No
21
+ history" rule — don't narrate how an entry used to read). Keep each entry
22
+ tight: context, the decision, the alternatives rejected and why, and the
23
+ consequences a future contributor would trip over. A decision is worth
24
+ logging the moment it's *made* — implementation can lag (the `Status:`
25
+ line says which).
26
+
27
+ **Entries are mutable — edit in place, don't append addendums.** Each
28
+ entry is the single coherent home for one *live* decision; keep it current
29
+ by editing its body as the decision is refined or extended (still the same
30
+ choice, now sharper or broader). Two things this does *not* license:
31
+
32
+ - **The roads-not-taken stay.** "We chose X, rejected Y because Z" is live
33
+ content of the current decision, not stale history — never edit it away.
34
+ It's the most valuable thing in the file.
35
+ - **A reversed *shipped* decision forks a tombstone, it is not overwritten.**
36
+ When a design was tried, shipped, and then thrown away, leave the old
37
+ entry as the scar, set its `Status:` to **Superseded by D-<slug>**, and
38
+ write the replacement fresh. (The shape of such a reversal: the deleted
39
+ bottom-up `content_size` sizing channel, replaced by top-down layout —
40
+ see AGENTS.md "Layout is top-down".) The line: *refined or extended* →
41
+ edit in place; *reversed after shipping* → tombstone + new entry.
42
+
43
+ ---
44
+
45
+ ## D-bg-inherit — Background color: fill-the-gaps inheritance (2026-07-23)
46
+
47
+ **Status:** Accepted; implemented 2026-07-23. Tracks
48
+ [issue #1](https://github.com/mvysny/tuile/issues/1).
49
+
50
+ **Context.** Overlays (a slash/autocomplete popup) need a distinctive
51
+ background across a whole `List` — content rows *and* the blank filler
52
+ below — so the panel reads as one solid tint. Today there is no knob:
53
+ `bg:` on a row's `StyledString` tints only that row, leaving filler on
54
+ the terminal default (a ragged half-shaded box). Terminal cells are
55
+ opaque: every cell holds exactly one `bg`, and a glyph painted with
56
+ `bg: nil` writes terminal-default, clobbering any fill underneath — so
57
+ "parent fills, child paints on top" does *not* yield inherited text.
58
+
59
+ **Decision.** Add `Component#bg_color` (a `Color`, default `nil`), with
60
+ **fill-the-gaps inheritance resolved at render**:
61
+ `effective_bg_color = @bg_color || parent&.effective_bg_color` (computed at
62
+ paint, never cached), and `StyledString#under_bg`, which applies a bg only
63
+ to spans whose bg is `nil`. Set the tint once on a container and
64
+ descendants pick it up; a widget with its own explicit bg
65
+ (`TextField`/`TextArea` wells) keeps its look. `nil` keeps its existing
66
+ meaning — "inherit upward," with the terminal default as the root of the
67
+ chain. Self-painters route the effective bg through a single choke point,
68
+ `Component#draw_line` / `#draw_char`.
69
+
70
+ **Alternatives rejected.**
71
+ - *Explicit per-component, no inheritance* (Textual/ratatui end):
72
+ simplest and zero new `StyledString` surface, but fails the motivating
73
+ "set it once on the Popup" case (you'd set it on Popup *and* List). Kept
74
+ as the fallback only if the per-leaf routing proves more coupling than
75
+ it's worth.
76
+ - *Naive CSS-`background` inheritance* (child silently adopts a parent's
77
+ concrete bg, glyphs included): rejected because it's what even Textual
78
+ refuses; the respected inheriting frameworks (urwid, brick, Lipgloss)
79
+ all do *fill-the-gaps* (apply only where unset) — the chosen design.
80
+ - *A built-in `panel_bg` theme token:* rejected — it would poke a hole in
81
+ the standing "no global bg/fg token; non-accent cells inherit the
82
+ terminal default" invariant (AGENTS.md theme section). Apps that want
83
+ the tint theme-tracked source it from a **custom** token and reassign in
84
+ `on_theme_changed`, exactly the documented pattern for theme-derived
85
+ content colors.
86
+ - *A new `INHERIT` sentinel:* unnecessary — `bg: nil` already means
87
+ inherit-from-upward; fill-the-gaps just splices component ancestors
88
+ between a leaf and the terminal root.
89
+ - *notcurses-style true per-cell alpha compositing:* deferred — a much
90
+ larger commitment that belongs with the parked
91
+ `ideas/per-component-buffers.md` compositor, not here.
92
+
93
+ **Consequences.**
94
+ - Self-painters (`List`, `Window`'s border) can't ride the base
95
+ `clear_background` fill; `List` must **bake** the effective bg into
96
+ every row it emits (content + filler) and still compose
97
+ `active_bg_color` on the cursor row on top.
98
+ - A fully-tiled container's `bg_color` won't paint (it's 100%
99
+ occluded) — correct, not a bug; cells are opaque, so there is no "tint
100
+ behind opaque children." Document in rdoc so nobody files it.
101
+ - `bg_color=` must invalidate the **whole subtree** (`on_tree`),
102
+ not just self, so descendants re-resolve. Over-invalidation is
103
+ acceptable: `Buffer#flush` emits only changed cells, so a shielded
104
+ descendant repaints to a byte-identical region and costs no wire
105
+ traffic. Pruned invalidation is a future optimization only if a
106
+ hot-path workload proves it out.
107
+ - No opt-*out*: `nil` can't express "force terminal-default despite a
108
+ tinted ancestor." Rare; add a `:default` / `Color::TERMINAL_DEFAULT`
109
+ sentinel if a real need appears.
110
+
111
+ **Graduation (2026-07-23).** The design sketch
112
+ (`ideas/background-fill-color.md`) is retired; its invariants graduated to
113
+ AGENTS.md ("Background color") and its reader-half to book ch6 ("Backgrounds
114
+ are opt-in"). {Component::Label} already carried its own `#bg` (override-all
115
+ via `with_bg`); it composes with `bg_color` (explicit span bgs survive
116
+ `under_bg`, so `#bg` wins locally), but the two-knob overlap is a wart
117
+ flagged for a later consolidation decision. The theme-token variant that
118
+ surfaced during design landed separately — see `D-theme-ref`.
119
+
120
+ ---
121
+
122
+ ## D-theme-ref — Live theme references for `bg_color` (2026-07-23)
123
+
124
+ **Status:** Accepted; implemented 2026-07-23. Tracks
125
+ [issue #1](https://github.com/mvysny/tuile/issues/1). Relaxes the
126
+ `bg_color`-takes-`Color`-only stance of `D-bg-inherit`, which rejected a
127
+ built-in `panel_bg` token and deferred the general "themeable color
128
+ property" question.
129
+
130
+ **Context.** Tracking a themed background meant setting the color *twice* —
131
+ once as a concrete `Color`, and again in an `on_theme_changed` block so it
132
+ survives light/dark flips — for every tinted panel. `D-bg-inherit` deferred
133
+ the fix; this is it.
134
+
135
+ **Decision.** `Component#bg_color` accepts a `Theme::Ref` (built by
136
+ `Theme.ref(:token)`) alongside a `Color`. A `Ref` names a theme token and
137
+ is resolved against `screen.theme` at paint time inside
138
+ `effective_bg_color`, so a `Theme::Ref` background tracks the theme with
139
+ **zero `on_theme_changed` boilerplate** — exactly as framework chrome
140
+ already does. It resolves both a **built-in chrome token**
141
+ (`Theme::CHROME_TOKENS` — the `Data` members bar `:custom`:
142
+ `active_bg_color`, `active_border_color`, `input_bg_color`, `hint_color`)
143
+ and a `custom` token; a chrome name takes precedence on the (pathological)
144
+ same-name collision. Scope: `bg_color` only. The setter validates the token
145
+ eagerly (a bad token raises `KeyError` at assignment, not deep in
146
+ `repaint`).
147
+
148
+ **Why `bg_color` and not colors generally.** It is the only app-settable
149
+ color already resolved late: `effective_bg_color` reads a lone ivar at
150
+ paint, so a `Ref` there changes *what the existing resolution reads*, not
151
+ adds a resolution pass (one `is_a?` branch). Content colors (`Label#text`,
152
+ `List#lines`, `TextView#text`) bake `Color`s into a frozen `StyledString`
153
+ at construction and stay on the hook — a `Ref` there would force
154
+ `StyledString` to become theme-aware, breaking its round-trip /
155
+ memoization / zero-`Screen` invariants. So this is **not** a third color
156
+ channel: it opens the *existing* live-chrome channel (the built-ins already
157
+ read `screen.theme` at paint) to app-set backgrounds.
158
+
159
+ **Why chrome tokens too, not `custom`-only.** The first cut walled `Ref` to
160
+ `custom` tokens, to be sure it couldn't smuggle in a global bg/fg token. But
161
+ that blocked a *framework* component from pointing its `bg_color` at an
162
+ existing chrome accent and tracking flips — concretely
163
+ {Component::ComboBox}'s borderless dropdown, which tints with
164
+ `input_bg_color` (tying it to the field's own well) and would otherwise need
165
+ the very `on_theme_changed`/resolve-on-open boilerplate `Theme::Ref` exists
166
+ to kill. The invariant `D-bg-inherit` actually protects is *the Theme
167
+ carries no global bg/fg field* — and every chrome token is an **accent**
168
+ (`active_bg`, `active_border`, `input_bg`, `hint`), never a global
169
+ background. A `Ref` to one adds no new token and creates no global
170
+ background; it only lets an app-set slot read a color the theme *already*
171
+ carries. So "custom-only" was a stronger proxy than the invariant required;
172
+ reaching chrome tokens leaves the no-global-bg/fg guard untouched.
173
+
174
+ **Alternatives rejected.**
175
+ - *Custom-only `Ref`* (the first cut): keep the wall and give ComboBox an
176
+ `on_theme_changed` rebuild or a resolve-on-open of `input_bg_color` —
177
+ works, but is the precise hook-boilerplate `Theme::Ref` exists to remove,
178
+ needed only because of a wall the invariant didn't require. Or ship a
179
+ framework `:dropdown_bg` `custom` token in the default `ThemeDef` —
180
+ fragile: `ThemeDef.new` enforces matching custom key sets, so an app
181
+ assigning its own `ThemeDef` without that key would `KeyError` the
182
+ framework's own `Ref` at paint.
183
+ - *A bare symbol* (`bg_color = :panel_bg`): collides with `Color.coerce`,
184
+ where `:red` / `:blue` name the 16 ANSI colors — `bg_color = :blue` would
185
+ be ambiguous. The `Theme::Ref` wrapper disambiguates and carries the
186
+ eager validation.
187
+ - *Other names — `Token` / `Var` / `ColorRef` / `Key` / `Style*`:* `Ref`
188
+ chosen — honest about being a late-bound reference, short, and
189
+ future-proof if a theme ever holds a non-color entry. `Style*` was out
190
+ because it collides with `StyledString::Style`.
191
+ - *A general themeable-property mechanism across every color setter:*
192
+ deferred, not rejected — the general type (`Theme::Ref`, resolved live at
193
+ paint) exists, but its only current application is `bg_color`, because
194
+ baked content is walled off. Widen only if the probe proves out
195
+ ("re-grow deliberately", as with top-down layout).
196
+ - *Pushing theme-awareness into `StyledString`:* rejected as a distinct,
197
+ heavier decision — it collides head-on with StyledString's load-bearing
198
+ invariants and is not required by `Theme::Ref`.
199
+
200
+ **Consequences.**
201
+ - A `Ref` adds no new token (chrome tokens are all accents; `custom` is
202
+ app-supplied), so it **cannot** reintroduce the global bg/fg token that
203
+ `D-bg-inherit` and the AGENTS.md theme stance refuse — the two stay
204
+ orthogonal.
205
+ - Collision precedence is chrome-wins; a `custom` token named after a chrome
206
+ token is shadowed when referenced by `Ref` (harmless, documented on
207
+ `Theme::Ref`).
208
+ - A `Theme::Ref` background stays current only because `theme=` invalidates
209
+ the whole tree (`needs_full_repaint`). A future prune of that must keep
210
+ `Theme::Ref` backgrounds invalidated on theme change, or they strand on
211
+ the old color (guarded in `screen_spec`).
212
+ - `bg_color`'s reader returns the value as set — a `Ref` comes back
213
+ unresolved; `effective_bg_color` is the resolved `Color`.
214
+
215
+ ---
216
+
217
+ ## D-has-value — Typed value seam (`HasValue`) over String-only (2026-07-23)
218
+
219
+ **Status:** Accepted; implemented 2026-07-23 (`Component::HasValue`, included
220
+ by `AbstractStringField`; first typed consumer is `ComboBox`). Tracks the "do input
221
+ components share a value concept?" question raised while designing `ComboBox`.
222
+
223
+ **Context.** Tuile's only editable component exposed its contents as `text`
224
+ (a `String`) with an `on_change`. Adding a second input kind (`ComboBox`, and
225
+ later an integer/date field) forced a choice: keep **every** input's value a
226
+ `String` (caller maps it back — `"42".to_i`, look a label up in a hash), or
227
+ give each input a value of its **natural type** behind a uniform seam.
228
+
229
+ **Decision.** A uniform, typed value seam: `Component::HasValue`, a thin mixin
230
+ of `value` / `value=` / `empty?` / `clear` + an `on_value_change` listener
231
+ (new value only). `value` holds whatever the component holds — `String` for a
232
+ text field (its value *is* its text; `value`/`value=` are aliases over the
233
+ `text` buffer, and `text=` fires both `on_change` and `on_value_change`), a
234
+ domain object for a `ComboBox`. Model-mapping (presentation ⟷ domain) is left
235
+ to a future forms/binder layer *above* the field, never baked into field
236
+ state.
237
+
238
+ **Why typed, not String-only.** The pull toward String-only is the fear of
239
+ "renderer machinery" — but that is a *Java* cost. In Java a typed value drags
240
+ `HasValue<E,V>` generics through every signature plus `ItemLabelGenerator`/
241
+ `Renderer`/`DataProvider`. In Ruby "generic over V" is free (duck typing *is*
242
+ the generic) and a renderer is a one-line proc defaulting to `:to_s`. So
243
+ String-only buys almost nothing here while costing the ergonomics of
244
+ date/int/combo inputs and re-introducing "pick a `Person`, get back a
245
+ `"Alice"` you must re-resolve" bugs. A survey of Vaadin / Swing / Android /
246
+ Textual / React / Flutter / SwiftUI found **no** toolkit that holds
247
+ "String everywhere"; the dynamically-typed ones (Ruby's camp) get typed
248
+ values *and* a uniform seam for free.
249
+
250
+ **Alternatives rejected.**
251
+ - *String-only value on every input:* fails "pick a domain object, get the
252
+ object," and bakes a `String` assumption a future `IntegerField`/`DatePicker`
253
+ would fight. Kept only as a theoretical fallback.
254
+ - *A full Vaadin-shaped `HasValue`* (read-only, required-indicator,
255
+ old-value/`isFromClient` event payload, converters/validators): every one of
256
+ those answers a forms/binder problem Tuile doesn't have yet. Deferred, not
257
+ adopted — re-grow deliberately when a Forms layer lands.
258
+ - *Naming — `Field` / `Valued` / `Bindable` / `Input` / `HoldsValue` /
259
+ `Editable`:* each names an *adjacent* capability (focus/editing, esteem,
260
+ a nonexistent binder, a role, a wrapper class, the deferred read-only axis)
261
+ rather than "holds a value." `HasValue` is brutally literal, matches its own
262
+ method names, and carries the Vaadin lineage the project already wears.
263
+
264
+ **Consequences.**
265
+ - `AbstractStringField#empty_value` is `""`; the mixin default is `nil`.
266
+ - Deferred for the Forms layer (not decided here): where a `Converter` lives
267
+ (on the field vs. purely in the binder), `read_only`, required-indicator,
268
+ and whether the listener ever needs an old-value/from-client payload. The
269
+ survey's verdict — model-mapping is a layer *above* the field — is the
270
+ standing guidance for that work.
271
+
272
+ ---
273
+
274
+ ## D-combobox — `ComboBox`: composed, typed, filterable-first (2026-07-23)
275
+
276
+ **Status:** Accepted; implemented 2026-07-23 (`Component::ComboBox`, demoed in
277
+ the sampler). Builds on `D-has-value`, `D-bg-inherit`, `D-theme-ref`.
278
+
279
+ **Context.** A text field with a filtering dropdown. The ad-hoc version already
280
+ existed in the sampler's slash-command demo (a `TextField` + a non-modal
281
+ `Popup` over a `List`, wired by hand); `ComboBox` promotes that assembly to a
282
+ component.
283
+
284
+ **Decision.**
285
+ - **Compose, don't inherit.** `ComboBox < Component` *holding* a `TextField` +
286
+ owning a `Popup(List)` — not `ComboBox < TextField`. Inheriting would nail
287
+ the value to `String` and leak caret/insertion semantics onto the combo's
288
+ face; composition lets it expose a clean typed `value` and delegate editing.
289
+ (The COP carve-out: subclass a framework widget only to *be* one thing.)
290
+ - **Typed value via a strategy.** `items=` (`Array` of any type) + `item_label`
291
+ (`item -> String|StyledString`, default `:to_s`); `value` is the *selected
292
+ item*. An index is how a selection is **resolved**, never how it is
293
+ **stored**: a click/Enter resolves the row to an object (`@filtered[idx]`) and
294
+ the object is what `value` holds — which is what makes identity survive
295
+ duplicate labels. Say it that way round; "selection is by list index" reads as
296
+ index *storage* and invites the rejected design below.
297
+ - **`items` is chrome; `value` is authoritative and independent.** `items=`
298
+ never touches `value` and never fires `on_value_change`; a value absent from
299
+ `items` renders nothing selected and **survives intact** (hence the rdoc's
300
+ "the value need not be in `#items`"). Two reasons: a form saved without the
301
+ user editing anything must change nothing silently, and async-loaded items
302
+ make value-before-items the normal case rather than a corner. The cost — the
303
+ app owns keeping them in sync, reconciling with a one-line intersection when
304
+ it wants to — is smaller than any framework reconcile step (see the rejected
305
+ three in `D-checkbox-group`, where the set-valued case forced the question).
306
+ One rule, two instances: singular here, a `Set` of items in `CheckboxGroup`.
307
+ - **Two values, never conflated.** `value` = the committed selection (changes
308
+ only on Enter/click; sole trigger of `on_value_change`); the field's `text`
309
+ = a transient **query** that filters the list and reverts to the value's
310
+ label on ESC/blur.
311
+ - **Filterable first;** the non-filterable `Select` is deferred (it wants the
312
+ read-only field behavior `D-has-value` parked for the forms layer).
313
+ - **Borderless tinted dropdown** (no `Window`): a bare `Popup(List)` told apart
314
+ from the content by a background tint, `bg_color = Theme.ref(:input_bg_color)`
315
+ — live-tracked, no `on_theme_changed` hook (leans on `D-bg-inherit` +
316
+ `D-theme-ref`). A `▾` affordance marks the field; the dropdown flips above
317
+ when it would overrun the screen bottom.
318
+
319
+ **Alternatives rejected.**
320
+ - *`ComboBox < TextField`:* String-typed value, leaked editing surface — see
321
+ above.
322
+ - *String value (the display text):* fails identity-across-duplicate-labels,
323
+ the whole reason to prefer a component over `List` + a lookup hash
324
+ (`D-has-value`).
325
+ - *Store the selected **index** rather than the object* (and clear the selection
326
+ when `value=` gets something not in `items`): the plausible misreading of the
327
+ identity rule, and it breaks the chrome/value split above — replacing `items`
328
+ silently reinterprets an index as whatever now sits there, so a filter panel
329
+ quietly filters by the wrong thing with no event fired. An index is a
330
+ *resolution* mechanism, valid only at the instant of a click.
331
+ - *`Window`-framed dropdown:* the border is redundant chrome once a tint
332
+ separates the panel, and costs 2 rows + 2 cols; the tint is what
333
+ `D-bg-inherit` was built to make solid.
334
+ - *`allow_custom_value`* (Vaadin's "typed text not in the list" escape hatch):
335
+ deferred — a custom value is a `String`, reintroducing the String/`T` tension
336
+ at the value boundary; no use case needs it yet.
337
+
338
+ **Consequences.**
339
+ - Programmatic `value=` and label write-backs sync the field's text behind a
340
+ suppress-filter guard, so they don't spring the dropdown open (see AGENTS.md).
341
+ - The dropdown `List` is deliberately **non-focusable**: the combo forwards
342
+ keys to it while focus stays in the field, and a click selects without
343
+ stealing focus — which also keeps popup close/reopen free of focus
344
+ re-entrancy.
345
+ - Enter **and** Down open the dropdown when it is closed; when open, Enter
346
+ commits.
347
+
348
+ ---
349
+
350
+ ## D-integer-field — `IntegerField`: the second typed input, and the composed-field taxonomy (2026-07-23)
351
+
352
+ **Status:** Accepted; implemented 2026-07-23 (`Component::IntegerField`). Builds
353
+ on `D-has-value`, `D-combobox`. Its real job was to *validate the `HasValue`
354
+ seam* for the case where `value`'s type diverges from the editing buffer:
355
+ `ComboBox` proved the fully-detached case (value ⟂ query), `IntegerField`
356
+ probes the *derived* case (value = a parse of the buffer). Extended 2026-08-02
357
+ with the converse half of the taxonomy (`PasswordField`, value = the buffer).
358
+
359
+ **Context.** A single-line field whose value is an `Integer` (or `nil`). The
360
+ user types only `0`–`9` and a leading `-`; an empty or un-parseable buffer is
361
+ `nil`. This is the second field whose value isn't a `String`, so it was the
362
+ moment to settle the input taxonomy while still pre-1.0.
363
+
364
+ **Decision.**
365
+ - **Compose an `AbstractStringField`, don't subclass one.** `IntegerField <
366
+ Component` *holding* a `TextField`. The decisive reason is API vocabulary,
367
+ not reuse: subclassing drags `TextField`'s `String`-typed `text`/`value` seam
368
+ onto the field's public face, next to the real `Integer` `value` as a
369
+ conflicting second seam, and Ruby can't cleanly hide inherited public
370
+ methods. (Same shape as `D-combobox`; makes `IntegerField` a *simpler
371
+ ComboBox* — the identical structure minus the dropdown.)
372
+ **The taxonomy is two-sided: compose when the value's type diverges from the
373
+ buffer, subclass when it doesn't.** `Component::PasswordField < TextField`
374
+ (added 2026-08-02) is the second half — a password's value *is* its text, so
375
+ there is no conflicting seam to hide and nothing to gain from a wrapper; it
376
+ is the sanctioned "subclass the framework widget to *be* a variant of it"
377
+ case, and its whole delta is `TextField#display_text`. Read the rule off the
378
+ *value*, not off how much behavior is reused.
379
+ - **`TextInput` renamed `AbstractStringField`**, and re-scoped in its doc as
380
+ the *String-valued* base of `TextField`/`TextArea`. A field whose value isn't
381
+ a `String` composes one of these; its `text=` seam-fire is correct precisely
382
+ because it's only used where `value == text`.
383
+ - **`HasValue` reframed to the input-field mixin.** It absorbs `focusable? =
384
+ true` (previously duplicated on `AbstractStringField` and `ComboBox`). It
385
+ does **not** absorb `tab_stop?`: that diverges — the leaf editable field is a
386
+ tab stop, but a composing wrapper is not (its inner field carries the stop,
387
+ and a tab-stop wrapper around a tab-stop field would double-stop Tab, since
388
+ `cycle_focus` collects stops via `on_tree`).
389
+ - **The converter stays private and hardcoded** (`Integer(t, 10)` / `to_s`),
390
+ exactly as `TextField` hardcodes identity-String. No public `converter=`
391
+ strategy — that is the future Binder's job (`D-has-value` keeps converters
392
+ *above* the field).
393
+ - **Value is a derived parse, fired eagerly.** `value` is recomputed from the
394
+ buffer on read; `on_value_change` fires per keystroke but only on a real
395
+ *value* change (`"7"`→`"07"` is silent). No normalization in v1 (`"007"`
396
+ shows as typed); canonicalizing needs a blur/commit point a TUI lacks.
397
+ - **Up/Down are a built-in ±1 spinner**, treating an empty/un-parseable field
398
+ as `0`, handled inside the field's `on_key` interceptor. `IntegerField`
399
+ therefore does *not* expose `on_key_up`/`on_key_down` (`on_enter`, a submit
400
+ hook, stays delegated) — on a numeric field the arrows have a native meaning,
401
+ so surfacing them as app callbacks would fight the spinner.
402
+ - **Both composed fields include `HasContent`.** `ComboBox` and `IntegerField`
403
+ hold their inner `TextField` as their single `HasContent` child rather than
404
+ hand-rolling `children`/`rect=`/`on_focus`. This reuses an *existing* mixin
405
+ (not a new base), dedups the wrapper shell across both, and gives them
406
+ click-to-position-caret for free.
407
+
408
+ **Why compose over a shared base.** The genuinely-shared code between the two
409
+ wrappers is a thin single-child shell. `HasContent` already *is* that shell as
410
+ framework behavior, so both include it — that is reuse of an existing seam, not
411
+ a new abstraction. A *bespoke* `AbstractComposedField` / universal
412
+ `AbstractField` **class** was rejected: it would be machinery for shallow
413
+ commonality (the `cop` rule to duplicate rather than fold a shallow base), and
414
+ `on_enter`/`on_key_up`/`on_key_down` live only on `TextField` (Enter is a
415
+ newline in `TextArea`), so no single field class can own a submit callback.
416
+ `HasValue` is the Ruby-idiomatic `AbstractField` — a mixin is how Ruby shares
417
+ what Java needs a class for, and `is_a?(HasValue)` is the Binder's marker.
418
+
419
+ **Alternatives rejected.**
420
+ - *`IntegerField < TextField`:* leaks the String-typed seam onto the typed
421
+ field's face — the core reason to compose (above).
422
+ - *Public `converter=` / an `AbstractConvertingField` base:* a converting-field
423
+ base *is* the converter machinery in disguise, reached through the back door;
424
+ keep it out until a Forms layer owns converters deliberately.
425
+ - *Fold `tab_stop?` into `HasValue`:* breaks the composed wrappers' focus model
426
+ (double-stop). The idea note wrongly assumed both flags were duplicated on
427
+ `ComboBox`; only `focusable?` was.
428
+ - *Deprecate `AbstractStringField#text`:* `text` is the correct domain name for
429
+ a text editor; the defect was it *leaking via inheritance*, which composition
430
+ removes at the source.
431
+ - *`min`/`max`, `+` sign, grouping:* out of scope — range and format are a
432
+ forms concern (same line the converter debate draws).
433
+ - *Exposing `on_key_up`/`on_key_down`:* dropped in favor of the built-in
434
+ spinner (above) — the arrows are the field's own affordance now.
435
+
436
+ **Consequences.**
437
+ - `content`/`content=` are public on `ComboBox`/`IntegerField` (from
438
+ `HasContent`) — a structural accessor, distinct from the typed `value` seam
439
+ that stays the intended domain API.
440
+ - The digit filter is the inner field's `on_key`, consulted *before* insertion,
441
+ so a rejected key never moves the caret.
442
+ - Empty is per-component: `nil` for `IntegerField`, `""` for a text input.
443
+
444
+ ---
445
+
446
+ ## D-ambiguous-width — Bet on ambiguous-as-narrow; keep the inventory small (2026-07-30)
447
+
448
+ **Status:** Accepted 2026-07-30; describes what Tuile already does, plus one
449
+ new *forward-looking* rule (the inventory discipline) that governs new glyph
450
+ choices. The migration path below is deliberately **not** implemented.
451
+
452
+ **Context.** Unicode's `East_Asian_Width` (UAX #11) marks some characters
453
+ **Ambiguous** — they occur both in legacy East Asian charsets (where they
454
+ were double-wide) and in Western use (single-wide), so their column count is
455
+ a property of the *terminal*, not the character. Terminals expose it as a
456
+ setting (`xterm -cjk_width`, mintty "Ambiguous width", iTerm2
457
+ "ambiguous-width as double width"); a process cannot read it, which is why
458
+ `Unicode::DisplayWidth.of` takes `ambiguous` as a *parameter* and defaults it
459
+ to 1. Tuile's every rect, caret column and clip derives from
460
+ `StyledString#display_width`, so if the terminal disagrees by one column on
461
+ one glyph, text after it shifts, the caret desyncs, and paint escapes
462
+ `rect` — a violation of the "never draw outside your rect" invariant, not a
463
+ cosmetic blemish.
464
+
465
+ Tuile's own chrome is already built out of Ambiguous glyphs: `Window`'s
466
+ entire border (U+2500..U+254B) and `VerticalScrollBar`'s `█` (U+2580..U+258F
467
+ are all Ambiguous; its `░` U+2591 is Neutral). Nothing in the framework was
468
+ designed to survive those measuring 2 — a double-wide scrollbar block in a
469
+ one-column scrollbar has no meaningful rendering.
470
+
471
+ **Decision.** Two halves.
472
+
473
+ 1. **Tuile bets that terminals render Ambiguous as one column**, matching
474
+ `unicode-display_width`'s default and the overwhelming majority of
475
+ non-CJK-configured terminals. No detection, no per-glyph fallback, no
476
+ configuration knob. The bet is *global* and the framework's, not the
477
+ app's, so the failure mode under an ambiguous-wide terminal is uniform
478
+ and obvious (misaligned chrome) rather than subtle and local.
479
+ 2. **Inventory discipline: an Ambiguous glyph is allowed only in framework
480
+ chrome, from a small enumerable set.** New components default to ASCII
481
+ where a plausible Ambiguous glyph exists, and offer the pretty one as an
482
+ opt-in knob for someone who knows their terminal. This is what makes
483
+ half 1 *reversible*: the migration below costs a lookup table only as
484
+ long as the inventory stays enumerable.
485
+
486
+ **Consequences — how this resolves live glyph choices.** The rule, not a
487
+ per-component width argument, is why these land on ASCII:
488
+
489
+ - `password-field`: `mask_char` defaults to `"*"`, not `"•"` (U+2022 is
490
+ Ambiguous). Keeps the knob, and validates *one single-column grapheme
491
+ cluster* at assignment — the width half guards the column axis, the
492
+ cluster half the one-glyph-per-character contract `display_text` rests on.
493
+ Sharpest case in the batch: the caret sits *inside* masked text, so a
494
+ wrong width desyncs it mid-typing. Note the validator cannot catch `"•"`
495
+ itself — Tuile measures Ambiguous as 1 by construction — which is exactly
496
+ why the *default* has to carry the ruling.
497
+ - `radio-group`: `(*)`/`( )` default, not `(•)`/`( )`; same character, same
498
+ ruling.
499
+ - `checkbox`: `[x]`/`[ ]`, but for *unrelated* reasons — `☐`/`☑`
500
+ (U+2610..U+2613) are **Neutral**, so no width bet is involved. They lose
501
+ on font coverage (missing from most monospace fonts, and `☐` is the
502
+ worse-covered of the pair, so the two states can degrade asymmetrically to
503
+ tofu) and on **ink overflow** — a fallback-font glyph wider than the cell
504
+ box, which Alacritty draws oversized (kitty squeezes it to the cell).
505
+ Ink overflow is cosmetic and leaves coordinates correct; do not conflate
506
+ it with a cell-count mismatch.
507
+ - `progress-bar`: `█`/`░` is a *mixed* pair (Ambiguous + Neutral), so under
508
+ an ambiguous-wide terminal the bar's rendered length would vary with its
509
+ fill level. It ships anyway under half 1 — matching the scrollbar it
510
+ visually rhymes with — rather than inventing a third convention.
511
+
512
+ **The migration path, if support for ambiguous-as-wide is ever needed.**
513
+ Detect once and swap glyphs, rather than re-deriving widths everywhere:
514
+
515
+ - **Detect** with the cursor-position probe — paint a known Ambiguous glyph,
516
+ ask `CSI 6n` where the cursor landed, erase. It must run in
517
+ `Screen#initialize`, alongside the OSC 11 scheme probe and for the same
518
+ reason (the reply arrives on stdin, which the key thread owns once the
519
+ loop starts — see AGENTS.md "Theme").
520
+ - **Swap** the small chrome inventory — border set plus block set — for
521
+ ASCII (`+ - |`, `#`, `.`). Note there is **no pretty Unicode fallback**:
522
+ the Neutral parts of the box-drawing block (U+254C..U+254F, U+2574..U+257F)
523
+ are dashes and half-lines with no corners, so nothing composes a Neutral
524
+ box. ASCII is the only complete alternative set.
525
+ - **Enabling condition, worth honoring now:** those glyphs must live in
526
+ named constants, not inline string literals scattered across `window.rb`
527
+ and `vertical_scroll_bar.rb`, or the swap becomes a grep-and-pray.
528
+
529
+ **Alternatives rejected.**
530
+ - *Measure with `ambiguous: 2` to be safe:* mis-measures for nearly every
531
+ real user, breaking the common case to protect the rare one.
532
+ - *Probe at startup now and pick a glyph set:* pays a synchronous stdin
533
+ round-trip and a full second probe protocol for a configuration nobody has
534
+ reported. Deferred, not refused — the path above is the whole point of
535
+ writing this down.
536
+ - *A public `ambiguous_width=` knob on `Screen`:* pushes a Unicode trivia
537
+ question onto app authors, and every component would then have to consult
538
+ it. If the need arrives, detection is strictly better than asking.
539
+ - *Purge Ambiguous glyphs entirely (ASCII-only chrome):* Tuile's box-drawn
540
+ windows are most of its visual identity; surrendering them to a
541
+ configuration almost nobody runs is the wrong trade.
542
+ - *Make `StyledString` ambiguous-width-aware:* same objection as
543
+ theme-awareness (AGENTS.md "Theme") — it is a pure frozen value type with
544
+ no `Screen` dependency, and width would become context-dependent,
545
+ breaking memoization and the `parse(to_ansi(x)) == x` round-trip.
546
+
547
+ ---
548
+
549
+ ## D-key-dispatch — Delete `key_shortcut`; scope-wide keys ride the bubble (2026-07-30)
550
+
551
+ **Status:** Accepted 2026-07-30; implemented the same day. Supersedes the
552
+ shipped capture phase of `ScreenPane#handle_key` — see *the scar* at the end.
553
+
554
+ **Context.** Tuile's dispatch ladder had four rungs: Tab, the global-shortcut
555
+ registry, **capture** (scan the scope subtree for a `Component#key_shortcut`
556
+ match, focus it, consume the key), then **delivery** (bubble up the focus
557
+ chain). Capture existed for one shape: virtui's three tiled windows, where
558
+ `1`/`2`/`3` jump between panes, advertised by `Window` as a `[1]-` caption
559
+ prefix.
560
+
561
+ Capture-before-delivery has an obvious hazard — a `key_shortcut = "d"`
562
+ anywhere in the scope steals the `d` a focused text field is trying to
563
+ type — so it was gated: capture is skipped while `Screen#cursor_position`
564
+ is non-nil. That gate is the whole problem. It uses "does the focused
565
+ component own a hardware cursor" as a **proxy** for "is this component in
566
+ text-entry mode." The two are not the same thing: a checkbox that grew a
567
+ cursor would silently change key routing, and a component that swallows
568
+ typing without a cursor gets no protection. `ideas/key-dispatch.md` carried
569
+ three ways to fix the gate (document it, invert capture and delivery, or
570
+ replace the proxy with a declared `text_entry?` predicate) — and the
571
+ realization that ended the discussion was that **rung 4 already solves the
572
+ problem rung 3 created.**
573
+
574
+ **Decision.** Delete the mechanism. `Component#key_shortcut`,
575
+ `Component#find_shortcut_component`, the capture phase, the cursor gate, and
576
+ `Window`'s `[k]-` border prefix are all gone; the ladder is three rungs, and
577
+ `cursor_position` means only "where to park the hardware cursor."
578
+
579
+ A scope-wide one-key binding belongs on the **scope root's own
580
+ `handle_key`** — the last rung of the bubble:
581
+
582
+ ```ruby
583
+ class AppLayout < Tuile::Component::Layout::Absolute
584
+ def handle_key(key)
585
+ case key
586
+ when "1" then @vms.focus; true
587
+ when "2" then @log.focus; true
588
+ else false
589
+ end
590
+ end
591
+ end
592
+ ```
593
+
594
+ This is strictly better than what it replaces, on every axis the gate was
595
+ trying to cover:
596
+
597
+ - **The suppression is free and *correct*.** A focused `TextField` consumes
598
+ the key at delivery and returns true, so the ancestor never sees it — not
599
+ because of a cursor proxy, but because the field genuinely handled it.
600
+ `handle_key` returning true *is* the "I'm in text-entry mode" declaration,
601
+ per-key, which is the granularity the rejected option C was reaching for.
602
+ - **It's scoped, not global.** The bubble stops at the scope root, so an
603
+ open modal popup owns its own `1`, and the layout's binding is dormant
604
+ while it's up. Two popups get two different defaults.
605
+ - **No lifecycle bookkeeping.** Nothing to unregister; a detached component
606
+ simply stops being on anyone's focus chain. (Vaadin needs
607
+ `bindLifecycleTo` for exactly this.)
608
+ - **One mechanism per job.** The registry runs an app-wide *action*;
609
+ an ancestor's `handle_key` claims a *scope-wide key*. Two shortcut
610
+ mechanisms that both "capture a key from anywhere" are gone.
611
+
612
+ Second half, forced by the first: since the registry is now the *only*
613
+ mechanism above the tree and nothing suppresses it, it must refuse every key
614
+ a widget can need. It already rejected printables and Tab; it now also
615
+ rejects `Screen::EDITING_KEYS` (`ENTER`, `BACKSPACE`, `DELETE`, arrows).
616
+ `ENTER` is the trap worth naming — unprintable, so nothing else stopped it,
617
+ and `register_global_shortcut(Keys::ENTER) { submit }` was the obvious way to
618
+ build a default button and silently broke `TextArea` newlines app-wide. This
619
+ stays a **registration-time reservation, not a runtime gate**: a gate here
620
+ would re-create the wart this entry deleted. `HOME`/`END`/`PAGE_UP`/
621
+ `PAGE_DOWN` are deliberately left legal — they navigate within a widget
622
+ rather than mutate its value, and "PgUp scrolls the log pane" is a real
623
+ binding.
624
+
625
+ **The default-button pattern**, which falls out of the same bubble and is the
626
+ reason no new machinery is needed: a focused `TextArea` consumes Enter
627
+ (newline); a `TextField` with an `on_enter` consumes it (no double-submit);
628
+ one without declines and it bubbles to the form's `handle_key`; a `Button`
629
+ consumes it and activates *itself*. A future `Window#default_button=` is a
630
+ five-line ancestor `handle_key`, not a dispatch change. (Swing agrees:
631
+ `JRootPane#setDefaultButton` is *window*-scoped, not global.)
632
+
633
+ **Consequences — what was given up, honestly.**
634
+
635
+ - **The child no longer declares its own mnemonic**; the parent holds the
636
+ key → child table. Swing (`WHEN_IN_FOCUSED_WINDOW` InputMaps) and Vaadin
637
+ (`shortcut.listenOn(form)`) both support the child-declares model, so this
638
+ isn't unprecedented — but "which key jumps where" is a decision about the
639
+ assembly, and it reads fine in one place.
640
+ - **`Window` no longer renders a `[1]-Caption` prefix.** An app that wants
641
+ it writes it into the caption. Pure chrome; not worth an API.
642
+ - **Bubble-based bindings need focus inside the scope.** `bubble_key` bails
643
+ unless the chain reaches the scope root, so with `screen.focused == nil`
644
+ nothing fires, where capture used to. Edge case; the cure (focus something)
645
+ is what apps do anyway.
646
+ - Migration cost was three lines in virtui plus its spec — the only consumer
647
+ the mechanism ever had.
648
+
649
+ **Re-grow rule.** If jump-to-pane digits prove ubiquitous across apps, bring
650
+ them back as **sugar over an ancestor's `handle_key`** (e.g. a `mnemonics`
651
+ hash on `Layout` that its `handle_key` consults), never as a dispatch phase
652
+ and never with a gate. The distinguishing test: the sugar must be reachable
653
+ *only* after the focus chain declined the key.
654
+
655
+ **Alternatives rejected.**
656
+ - *Keep capture, replace the gate with a declared predicate*
657
+ (`text_entry?` / `consumes_printable_keys?`, default false, true on
658
+ `AbstractStringField`): honest about what it means, and it's Win32's
659
+ `WM_GETDLGCODE`/`DLGC_WANTCHARS` thirty years earlier. Rejected because it
660
+ keeps a whole dispatch phase and a declaration alive to serve a feature the
661
+ bubble already provides for free. A predicate nobody needs is worse than no
662
+ predicate.
663
+ - *Keep capture but move it after delivery* (option B): also deletes the
664
+ gate, and preserves child-declares plus the `[1]-` chrome, at ~4 lines
665
+ changed. Genuinely the cheap alternative, and it was rejected on
666
+ simplicity, not correctness — it leaves two "capture a key from anywhere"
667
+ mechanisms in a framework whose pitch is small pieces. Note it *is* what
668
+ Swing does (a focused component's own bindings beat window-wide ones), so
669
+ this is a taste call, not a technical one.
670
+ - *Document it and ban printable shortcuts by convention* (option A): cheapest
671
+ of all, and the ladder documentation in AGENTS.md would have carried it —
672
+ but it preserves the proxy indefinitely.
673
+ - *Keep `key_shortcut`, tell apps to use `Alt+1` via the registry instead*:
674
+ the framing that opened the discussion, and worse than the bubble on three
675
+ counts. Alt has no `Keys` constants (it arrives as `"\e" + char`); macOS
676
+ Terminal needs Option-as-Meta enabled; and `Keys.getkey`'s fixed 5-byte
677
+ gulp makes `ESC` then `1` indistinguishable from `Alt+1`, which is a bad
678
+ trade in a framework where bare ESC closes popups. `Ctrl+digit` doesn't
679
+ exist in terminals at all. Modified-key accelerators remain fine when
680
+ they're genuinely app-global — that's what the registry is for.
681
+ - *Gate the registry at runtime instead of reserving keys* (suppress a global
682
+ `ENTER` while a text widget is focused): reintroduces the deleted proxy one
683
+ rung higher, and fails silently (the binding just stops working) where a
684
+ reservation fails loudly at registration.
685
+
686
+ **The scar.** Capture shipped in 0.9.0 and is deleted in 0.10.0, so per this
687
+ file's tombstone rule this entry *is* the replacement; there is no prior
688
+ entry to supersede (the capture model was recorded in AGENTS.md and the
689
+ CHANGELOG, never here). Do not re-add a capture phase without reading this
690
+ whole entry — the gate is what it costs.
691
+
692
+ **Prior art** (surveyed 2026-08-02, after the fact — this decision did not
693
+ wait on it). Eight frameworks against the seven axes this entry argues over.
694
+ Claims marked ⚠ are from memory and want checking before anyone acts on them.
695
+ Tuile's own row, for reference: **A.** 3 phases, no capture — **B.** focus
696
+ wins — **C.** the focused field consumes the key and returns `true`, nothing
697
+ else — **D.** the form/popup ancestor's `handle_key` — **E.** none —
698
+ **F.** Tab is absolute — **G.** imperative, hand-written `keyboard_hint`.
699
+
700
+ | | A. Phases | B. Accel vs focus | C. Protects typing | D. Default button | E. Mnemonic | F. Tab | G. Declarative + hints |
701
+ |---|---|---|---|---|---|---|---|
702
+ | **Swing** | focused InputMap → ancestor maps → window-wide map | **focus wins** (window-wide is last) | ordering + accelerators carry modifiers | `JRootPane#setDefaultButton`, **window**-scoped | Alt+letter, LAF-drawn underline | per-component; `JTextArea` traps it ⚠ | InputMap/ActionMap tables; no hint generation |
703
+ | **Win32 dialogs** | `TranslateAccelerator` → `IsDialogMessage` → control | accel wins, but control **declares** via `WM_GETDLGCODE` | `DLGC_WANTCHARS`/`WANTALLKEYS` | `DLGC_DEFPUSHBUTTON`, **dialog**-scoped | `&`+Alt, dialog manager | `DLGC_WANTTAB` lets a control claim it | static accel table; no hints |
704
+ | **Turbo Vision** | `phPreProcess` → `phFocused` → `phPostProcess` | opt-in per view (`ofPreProcess`) | ordering; hotkeys are Alt-ish | `bfDefault` button, **dialog**-scoped | `~H~` hotkeys | dialog handles `kbTab` | event/command constants; a separate `TStatusLine` |
705
+ | **GTK4** | controllers with `CAPTURE`/`TARGET`/`BUBBLE`, chosen per controller | either — the *controller* picks | app accels use Ctrl | ⚠ `default-widget` on `GtkWindow`, window-scoped | `_`+Alt via mnemonic labels | ⚠ focus-chain, widget-overridable | `GtkShortcutController` with `LOCAL`/`MANAGED`/`GLOBAL` scope |
706
+ | **DOM / web** | capture → target → bubble, per-listener | whatever the app writes | **nothing** — every app hand-rolls `if (target is input)` | app-written form `submit` | `accesskey` (widely regarded a failure) | browser-owned, `preventDefault`-able | none |
707
+ | **Vaadin Flow** | shortcut registry (UI-scoped by default) → component | ⚠ registry wins unless scoped/modified — the known gotcha | `.listenOn(scope)` + modifiers | `button.addClickShortcut(ENTER).listenOn(form)` | ⚠ `Shortcuts.addFocusShortcut(focusable, key, mods)` | browser | fluent `ShortcutRegistration`, `bindLifecycleTo` |
708
+ | **Textual** | priority bindings → focused widget → bubble to App | priority-first, else **focus wins** | `Input` consumes printables and stops propagation | ⚠ `Input.Submitted` message, per-screen | none built in | ⚠ `TextArea#tab_behavior` opt-in | **`BINDINGS` tables whose descriptions feed the `Footer`** |
709
+ | **Bubbletea / Ratatui** | none — one `Update` match | n/a | nothing; apps write an explicit `mode` enum | app-written | none | app-written | none |
710
+
711
+ What the table settles, beyond confirming the choices above:
712
+
713
+ - **Focus-first is the majority position** (Swing, Textual, and Tuile), and
714
+ the two frameworks that put an accelerator first (Win32, Vaadin) each pay
715
+ for it — Win32 with `WM_GETDLGCODE`, i.e. the rejected `text_entry?`
716
+ predicate thirty years earlier; Vaadin with a documented gotcha where a
717
+ UI-scoped unmodified shortcut fires while a field has focus ⚠. That is the
718
+ failure mode the reservation rule now makes unreachable.
719
+ - **The default button is scoped everywhere** — window, dialog or screen,
720
+ never global. Nobody disagrees.
721
+ - **A capture-like phase, where it exists, is opt-in per participant**
722
+ (Turbo Vision's `ofPreProcess`, GTK4's per-controller phase), never a rung
723
+ everyone pays for. If capture ever comes back, that is the only form worth
724
+ considering.
725
+ - **DOM is the argument for making suppression structural:** with no
726
+ accelerator layer at all, every web app hand-rolls the "is the user
727
+ typing?" guard — the guard this entry deleted — and does it badly.
728
+ - **Textual is Tuile-after-this-decision, structurally** (focus → bubble to
729
+ App, `Input` eats printables, modal screen scopes bindings), which is the
730
+ strongest available evidence the three-rung ladder is a stable resting
731
+ point rather than a local minimum.
732
+
733
+ **Steal candidates, ranked** — none adopted; all are *additions*, and none can
734
+ reopen the ladder:
735
+
736
+ 1. **A `bindings` table whose descriptions feed the status bar** (Textual's
737
+ `BINDINGS` + `Footer`). It attacks a real duplication: a key's handler, its
738
+ hint string and its status-bar registration are three pieces of knowledge
739
+ about one binding. This is exactly the re-grow rule's shape — a binding is
740
+ reached only when the event bubbles to that node, so it is sugar, not a
741
+ phase. Would have to prove it composes with `handle_key` rather than
742
+ replacing it, and that generated hints beat hand-written ones where the
743
+ hint is *conditional* (a `List`'s changes with its cursor). Touches
744
+ `keyboard_hint` / `refresh_status_bar`, not dispatch.
745
+ 2. **Naming the two scopes in the book** (GTK's `GLOBAL` vs `MANAGED`). Zero
746
+ code; Tuile's registry and ancestor-`handle_key` are the same two useful
747
+ points on that axis, and naming them makes "which one?" a one-line
748
+ decision for app authors.
749
+ 3. **Fluent scoping for the registry** (Vaadin's `listenOn`) — only ever as
750
+ the implementation of #1; on its own it is a second way to do what
751
+ `handle_key` already does.
752
+
753
+ Explicitly **not** stealing: capture phases (Win32 / Turbo Vision / GTK4 — all
754
+ cost a gate or an opt-in flag); child-declared window-wide bindings (Swing /
755
+ Vaadin — the trade this entry made); and per-binding priority flags (Textual —
756
+ they collide with the registry's key-*refusal* duty, which has nowhere to live
757
+ on a per-binding flag).
758
+
759
+ ---
760
+
761
+ ## D-boolean-fields — `Checkbox`: two-state value, painted extent, ASCII glyphs (2026-07-30)
762
+
763
+ **Status:** Accepted; `Component::Checkbox` implemented 2026-07-30. Builds on
764
+ `D-has-value`. The glyph and caption rulings are shared with
765
+ `Component::CheckboxGroup` (`D-checkbox-group`, which scopes the key and hit-test
766
+ rulings below to a *standalone* widget) and with `RadioGroup`
767
+ (`D-radio-group`). Tri-state is settled here but **not built**, and this
768
+ entry is its only home — see the last section.
769
+
770
+ **Context.** The first boolean input: one row, `[x] Enable syslog forwarding`,
771
+ Space or click to toggle. Deliberately a near-copy of `Button`'s single-row
772
+ shell, so it was the moment to settle the vocabulary the two group components
773
+ will follow.
774
+
775
+ **Decision.**
776
+ - **`value` is `true`/`false`, never `nil`**, coerced in a `value=` override,
777
+ with `empty_value == false` (unchecked *is* empty, as in Vaadin).
778
+ `checked?`/`checked=`/`toggle` are the domain-word face over that one piece
779
+ of state, each a thin **delegator** to `value`/`value=` so there is a single
780
+ write path and `on_value_change` can't double-fire. Delegators, not `alias`:
781
+ an alias binds to the body present when it runs, so a subclass overriding
782
+ `value=` would not be reached through `checked=` — and it would be missing
783
+ from the sord-generated `sig/tuile.rbs` besides.
784
+ - **`caption`, not `label`** (`HasCaption`): this is app-authored chrome, and
785
+ the mixin's split says chrome is `caption`. Tuile has no field-label seam
786
+ yet; when one lands, a checkbox's caption should stay what it is — the
787
+ clickable target, not a caption *for* another widget.
788
+ - **Space toggles; Enter is left unclaimed, but not *promised*.** Space-to-flip
789
+ is the native gesture (Vaadin's checkbox is Space-only too) and a checkbox has
790
+ no default action to confirm, so there is nothing for Enter to do here.
791
+ Claiming a key you don't need is the irreversible direction — teaching Enter a
792
+ meaning later breaks nobody, taking it back breaks apps — and that, alone, is
793
+ why `handle_key` ignores it. It is emphatically **not** a promise that a
794
+ form's Enter-to-submit can bubble past a focused checkbox: no widget owes
795
+ that (`TextArea` claims Enter for newline, `Button` to activate itself), and
796
+ book ch5's Enter table states it per widget precisely because it is per
797
+ widget. A checkable row in a `List` toggles on Enter (`D-checkbox-group`) —
798
+ `List`'s own *choose the item under the cursor*, not a checkbox gesture, so
799
+ the two don't read as inconsistent.
800
+ - **No constructor block, but a `value:` kwarg.** `Button.new(caption,
801
+ &on_click)` and `PickerWindow` are the gem's only ctor blocks, and both exist
802
+ to *produce one outcome* — the callback is mandatory in practice. A checkbox
803
+ exists to *hold* state and a form usually attaches no listener at all, so a
804
+ ctor slot for `on_value_change` would privilege the exception. `value:` earns
805
+ its slot instead: it *is* achievable post-hoc (assign before wiring the
806
+ listener and nothing fires), but that silently depends on assignment order a
807
+ form helper may not control. It also seeds the backing ivar — unseeded,
808
+ `HasValue#value`'s bare reader would return `nil`, making a fresh checkbox
809
+ report itself non-empty. Same ruling for the rest of the field batch.
810
+ - **The extent is one number, used by both the highlight and the hit test:**
811
+ `min(caption.display_width + 4, rect.width)` columns, one row. A form column
812
+ routinely hands a field 40 columns for a 22-column widget. Two consequences:
813
+ the painted glyph is the affordance, so a click on the blank tail doesn't
814
+ toggle (it still *focuses* — `Component#handle_mouse`'s click-to-focus is
815
+ ungated by geometry, and the tail is the field's own row); and a 40-column
816
+ highlight band would read as a selected *row*, the wrong signal for one field
817
+ in a column of ten. **`Button#handle_mouse` was narrowed to the same rule in
818
+ the same commit** — the ruling is cross-component, and leaving Button on
819
+ `rect.contains?` would re-split it. Clipping is *not* a third consumer:
820
+ `ellipsize(rect.width)` already equals `ellipsize(extent.width)` in both
821
+ directions.
822
+ **The rule is scoped to a *standalone* one-row field.** A checkable row
823
+ *inside a list* hit-tests its full width instead (`D-checkbox-group`), and the
824
+ difference is perceptual rather than a relaxation of rigor: with a cursor
825
+ visible and ten rows stacked, the unit the user aims at is a **row**, and a
826
+ row's affordance is its whole width — which is what `List`'s row-wide cursor
827
+ highlight already advertises. A lone `[ ] Enable syslog` in a 40-column form
828
+ cell advertises nothing of the sort. The **vertical** half is not relaxed even
829
+ there, and comes free: `List#handle_mouse` fires `on_item_chosen` only for
830
+ `line < @lines.size` (`list.rb:264`), so a click below the last row toggles
831
+ nothing. The two axes therefore differ by *reason* — horizontal is
832
+ row-affordance, vertical is still don't-activate-what-isn't-painted — which is
833
+ the distinction to preserve if a third checkable-row consumer appears.
834
+ - **ASCII `[x] `/`[ ] ` glyphs, as a documented convention rather than
835
+ constants.** Not a width ruling — U+2610..U+2613 are EAW-**Neutral**, so
836
+ every `wcwidth` agrees they're one cell. They lose on **font coverage**
837
+ (absent from most monospace fonts, and `☐` is the worse-covered of the pair,
838
+ so the two states degrade *asymmetrically* to tofu — checked renders,
839
+ unchecked doesn't, which reads as a bug rather than a fallback) and on **ink
840
+ overflow** (the fallback glyph is drawn wider than its cell in Alacritty —
841
+ cosmetic, coordinates stay correct; see `D-ambiguous-width` for why that's a
842
+ different problem). Locally, three columns is also a bigger click target that
843
+ survives a monochrome terminal, and keeps `region_text` assertions ASCII.
844
+
845
+ **Alternatives rejected.**
846
+ - *Reserve Enter as "the form-submit key" — i.e. have the checkbox promise to
847
+ decline it so an ancestor's default button always sees it:* tempting, and it
848
+ is what this entry originally claimed, but it's a single component
849
+ guaranteeing a framework-wide property the framework doesn't have —
850
+ `TextArea` and `Button` both claim Enter. Worse, it prices in a real cost
851
+ elsewhere: `List#handle_key` claims Enter whenever its cursor is on an item
852
+ (`list.rb:209`) *regardless of whether `on_item_chosen` is set*, so honoring
853
+ the promise in `CheckboxGroup` would have forced it onto the
854
+ `ListDropdown::Menu` shape — a non-focusable `List` subclass plus
855
+ hand-forwarded movement keys — to protect a guarantee nothing relied on
856
+ (`D-checkbox-group`). Enter-reaches-your-form is a per-assembly property the
857
+ app verifies for its own focusable widgets, not a framework invariant.
858
+ - *Hit-test the whole `rect`:* activates clicks that visibly land on nothing,
859
+ and `Rect#contains?` spans every row, so a click two rows below a visible
860
+ `[ ]` would toggle it. Vaadin agrees — a 100%-wide checkbox ignores clicks
861
+ right of its label. (Rejected *for a standalone field*. The second clause is
862
+ the durable one: the row-scoped carve-out above widens the target
863
+ horizontally, never past the last painted row.)
864
+ - *Let the extent follow `bg_color`:* with a tint the dead tail is visibly
865
+ painted, so the hit test arguably should widen. It must not: a target that
866
+ silently changes when an ancestor gains a background is an invisible mode
867
+ switch, untestable by inspection and unpredictable for the reader. One rule,
868
+ always.
869
+ - *`Component#extent` as a framework seam:* nothing generic consults it, and
870
+ each widget's arithmetic is its own. Two one-line methods beat a speculative
871
+ base-class hook (the `cop` duplicate-rather-than-fold rule).
872
+ - *Public `Checkbox::CHECKED`/`UNCHECKED` constants:* would publish a seam
873
+ before a consumer needs one — `CheckboxGroup` renders its own rows over a
874
+ `List` and never instantiates a Checkbox, so a reference would read as a
875
+ dependency that isn't there, and a future `glyphs=` knob would demote the
876
+ constant to merely *a* default. Drift between the copies surfaces as a
877
+ `region_text` spec mismatch, not a silent bug, and promoting a literal to a
878
+ constant later is additive.
879
+ - *`☑`/`☐` by default:* above. Available later as an opt-in `glyphs=` for
880
+ someone who has picked a font with a proper box.
881
+ - *A `keyboard_hint` override advertising "space toggle":* hints are a
882
+ window/popup-level affordance; per-field hints would drown the status bar.
883
+ (`Screen#refresh_status_bar` can't even reach a leaf field — it consults the
884
+ active `Window` or the top popup's *direct* content.)
885
+ - *A read-only flag:* parked with the rest of the forms-layer axes by
886
+ `D-has-value`.
887
+
888
+ **Tri-state (indeterminate) — settled, not built.** When it lands it adopts
889
+ **Vaadin's orthogonal flag**: `indeterminate`/`indeterminate=` as a plain
890
+ display override painting `[-] `, with `value` staying boolean. That is what
891
+ keeps the question decoupled — `empty_value == false`, the boolean coercion,
892
+ `checked? == (value == true)` and a group's set arithmetic all survive, and it
893
+ models the use case correctly (mixed is a *reflection* of children; a parent
894
+ over a partially-selected group has no boolean of its own). Two deviations from
895
+ Vaadin: **any statement about the value clears the flag** (`value=`, `toggle`,
896
+ `clear`, Space, click), so `checked && indeterminate` — representable and
897
+ meaningless in Vaadin, which is why its own group-header example must set both
898
+ properties in every branch — is unrepresentable here; and if the flag ever
899
+ needs observing it gets a plain `on_indeterminate_change`, not a second channel
900
+ on the value seam. Rejected: a **`nil`-able `value`** (breaks all four
901
+ properties above) and a separate **`TriStateCheckbox`** class (duplicates the
902
+ whole single-row shell for one flag). Also not auto-wired to `CheckboxGroup` —
903
+ which children a header governs, and whether checking it selects all, is app
904
+ policy.
905
+
906
+ Four details for whoever builds it. **The flag is computed, never typed:**
907
+ nothing lets a *user* enter mixed, and Space or a click *from* mixed lands on
908
+ **checked** — clear the flag, then toggle, firing `on_value_change` once (the
909
+ HTML activation steps; Vaadin inherits them). **Put the clearing in the
910
+ `value=` override**, not in each caller — that is precisely why `checked=` and
911
+ `toggle` are delegators rather than aliases, and an alias here would silently
912
+ skip it. **`empty?` ignores the flag** (a mixed box still reports empty:
913
+ harmless, but worth one rdoc word). **`on_theme_changed` is untouched** — the
914
+ marker is live-resolved chrome like every other built-in accent.
915
+
916
+ Deferred because the use case (a partially-checked tree parent) has no home in
917
+ Tuile today. Its first plausible consumer would be a `CheckboxGroup` header row
918
+ — which `D-checkbox-group` declined to build, leaving this unbuilt too; that
919
+ entry names the forcing function to watch for.
920
+
921
+ ---
922
+
923
+ ## D-checkbox-group — `CheckboxGroup`: a field composing a `List`, `Set`-valued (2026-07-30)
924
+
925
+ **Status:** Accepted; `Component::CheckboxGroup` implemented 2026-07-30, demoed
926
+ in the sampler. Builds on `D-has-value`, `D-combobox` (the chrome/value split it
927
+ generalizes), `D-integer-field` (the composed-field taxonomy it extends) and
928
+ `D-boolean-fields` (the glyphs, and the two rulings it scopes).
929
+
930
+ **Context.** Multi-select from a handful of typed items, one `[x] label` row
931
+ each. The cursor and the selection are genuinely two pieces of state here —
932
+ which is exactly the shape `List` already implements, so the question was how
933
+ much of `List` to reuse and what the value should be. (A single-select group
934
+ *could* have conflated them, and `D-radio-group` records why it doesn't.)
935
+
936
+ **Decision.**
937
+ - **Compose a plain `List`, unmodified.** `CheckboxGroup` holds one as its single
938
+ `HasContent` child, which supplies the cursor, scrolling, the scrollbar and
939
+ per-row hit-testing. The group's own code is four lines of wiring: rebuild
940
+ `lines=` on any change to items/labels/selection, claim **Space** in
941
+ `handle_key`, and toggle from `on_item_chosen`. That one callback covers Enter
942
+ *and* click (`list.rb:209` and `:264`), so there is no `handle_mouse` override
943
+ at all. This **extends `D-integer-field`'s taxonomy** from "a typed field
944
+ composes a `TextField`" to "a typed field composes whatever widget already has
945
+ the interaction" — the tab stop lives on the inner widget, the wrapper is not
946
+ one, exactly as for `ComboBox`.
947
+ - **`value` is a frozen `Set` of the selected items**, of whatever type `items`
948
+ holds. Frozen for a reason that is not tidiness: `HasValue#value=` opens with
949
+ `return if value == new_value`, so a selection mutated *in place* and
950
+ re-assigned would compare equal to itself and **silently swallow the change
951
+ event**. Freezing makes `cg.value << item` raise instead, and internally
952
+ `Set#+`/`#-` return new sets, so no in-place path exists to begin with.
953
+ - **`value=` coerces any `Enumerable` to a frozen copy *before* delegating.**
954
+ Coercing after the inherited no-op guard would have it comparing an `Array` to
955
+ a `Set`, finding them unequal, and firing spuriously on `value = value.to_a`.
956
+ The copy also means a caller's set can't reach in afterwards. `nil` means "select
957
+ nothing" and `empty_value` is a frozen empty `Set`.
958
+ - **The set's contract is *unordered*.** Ruby's `Set` is Hash-backed and so
959
+ iterates in insertion order, and a delete-then-re-add moves an element to the
960
+ end — i.e. the observable order is the user's *toggle history*. Documented as
961
+ unordered so nobody builds on that; `items & value.to_a` is the idiom for
962
+ items order, and the sampler pane uses it visibly.
963
+ - **Items are chrome (`D-combobox`), so `items=` never touches `value`** and never
964
+ fires `on_value_change`. A selected item absent from `items` renders no checked
965
+ row and survives intact.
966
+ - **Two `D-boolean-fields` rulings are scoped, not broken.** A click anywhere on
967
+ a row toggles it (a row's affordance is its full width, which its cursor
968
+ highlight already advertises) while a *standalone* checkbox still ignores its
969
+ blank tail; and Enter toggles here because that is `List`'s choose gesture. The
970
+ vertical half of the hit-test ruling survives untouched — `List` fires
971
+ `on_item_chosen` only for `line < @lines.size`, so a click below the last row
972
+ toggles nothing.
973
+ - **No header row, no tri-state, no select-all.** A header is the only plausible
974
+ consumer of `D-boolean-fields`' settled-but-unbuilt `indeterminate` flag, and
975
+ it is also where every policy question lives: which children it governs,
976
+ whether checking it selects all, one change event or N, whether it scrolls with
977
+ the rows. That entry already rules a header *app policy*, so building one here
978
+ would mean inventing that policy with no consumer. Select-all likewise gets no
979
+ key (`Ctrl+D` is a `List` scroll key, `Ctrl+A` is HOME-ish in readline terms)
980
+ and no chrome; `cg.value = cg.items` is the app's one-liner. **Forcing
981
+ function:** if the sampler pane ever wants an "All" row, build the flag then
982
+ and keep the header app-composed there — that demonstrates the app-policy
983
+ claim on one real case instead of asserting it for all of them.
984
+
985
+ **Alternatives rejected.**
986
+ - *Store selected **indices** (a `Set<Integer>`) and map to items on read:* the
987
+ first design, and it forces a reconcile policy onto `items=` that has no good
988
+ answer. All three candidates lose: *clamp* silently reinterprets a selection as
989
+ whatever now occupies that index; *re-map by `==`* is the honest one but still
990
+ can't preserve intent across duplicates and must decide whether to fire; *clear*
991
+ discards the user's work when items merely gained a row. Storing items deletes
992
+ the question rather than answering it — see `D-combobox`'s matching rejection.
993
+ - *The `ListDropdown::Menu` shape — a non-focusable `List` subclass, focus on the
994
+ wrapper, movement keys hand-forwarded:* the design forced by taking Enter away
995
+ from the list. Correct, and about 15 lines of forwarding plus a subclass, all
996
+ to protect a promise nothing relied on (see `D-boolean-fields`' rejected Enter
997
+ reservation). Reach for it only if a driver genuinely needs Enter for itself.
998
+ - *Paint the rows directly (`< Component`, `draw_line` per row):* wrong here.
999
+ The cursor-distinct-from-selection structure *is* `List`, a checkbox group is
1000
+ the one most likely to be long enough to scroll, and painting rows means
1001
+ re-implementing the cursor, the viewport, the scrollbar and the mouse
1002
+ arithmetic. This was left explicitly open for a radio group, on the grounds
1003
+ that three rows and a selection-follows-cursor model would need almost none of
1004
+ it; `D-radio-group` then closed it the same way, because dropping that model
1005
+ removed the friction that made painting attractive.
1006
+ - *An `Array`-valued `value` in `items` order:* would make ordering meaningful and
1007
+ so make it a contract to maintain, plus `==` would then treat two identical
1008
+ selections as different when toggled in a different order — breaking the
1009
+ seam's no-op detection.
1010
+ - *A shared base with `RadioGroup`/`MultiSelectComboBox`:* speculative folding of
1011
+ shallow commonality. The set bookkeeping is small enough to duplicate when the
1012
+ multi-select combo lands, and it inherits the chrome/value rule for free
1013
+ because that rule is `ComboBox`'s already (the `cop` duplicate-rather-than-fold
1014
+ rule).
1015
+ - *Public `CHECKED`/`UNCHECKED` glyph constants shared with `Checkbox`:* declined
1016
+ again here for the reason `D-boolean-fields` gives — the group paints its own
1017
+ rows and never instantiates a `Checkbox`, so importing a constant would read as
1018
+ a dependency that isn't there. Drift between the two copies surfaces as a
1019
+ `region_text` mismatch, not a silent bug.
1020
+
1021
+ **Consequences a contributor will trip over.** A bare `List` has **no cursor** —
1022
+ `Cursor::None` at position `-1` — so a future `List`-composer must install
1023
+ `List::Cursor.new` or arrows, Enter and the row highlight are all silently dead.
1024
+ `List` also pads a **one-column gutter**, so rows paint at `rect.left + 1`; that
1025
+ offset is baked into the spec's `region_text` assertions and the rdoc's example.
1026
+ Items need stable `#hash`/`#eql?` (a `Set`), so an item mutated after selection
1027
+ becomes unfindable — accepted, and the same constraint Vaadin's `HashSet`-backed
1028
+ group carries. Two `==`-equal items therefore share one selection and their rows
1029
+ toggle together, while two *distinct* items rendering the same label stay
1030
+ independent.
1031
+
1032
+ ---
1033
+
1034
+ ## D-radio-group — `RadioGroup`: cursor roams, selection commits; the cursor is chrome (2026-07-31)
1035
+
1036
+ **Status:** Accepted; `Component::RadioGroup` implemented 2026-07-31, demoed in
1037
+ the sampler. Builds on `D-has-value`, `D-combobox` (the chrome/value split),
1038
+ `D-integer-field` (the composed-field taxonomy), `D-checkbox-group` (the
1039
+ `List`-composing shape it copies) and `D-ambiguous-width` (the glyphs). Most of
1040
+ this component was settled by those five; what it owns is the **interaction
1041
+ model**, which reverses both the desktop convention and this note's own first
1042
+ design.
1043
+
1044
+ **Context.** Single-select from a handful of typed items, one `(*) label` row
1045
+ each — `ComboBox`'s job when the set is small enough to show at once. Every
1046
+ graphical radio group ever built (HTML, Vaadin, Windows dialogs, GTK) moves the
1047
+ *selection* with the arrow keys: focus and choice are one thing, and Down means
1048
+ "I have now chosen the next option." This component's design note originally
1049
+ adopted that, on the strength of the convention, and called it "the one real
1050
+ design call."
1051
+
1052
+ **Decision — the cursor roams; Space, Enter or a click selects.** Cursor and
1053
+ selection are two pieces of state, exactly as in `CheckboxGroup`. Two reasons:
1054
+
1055
+ - **Framework consistency.** "A cursor roams, Enter chooses" is the idiom in
1056
+ `List`, `ListDropdown`, `PickerWindow` and `CheckboxGroup`. Two group widgets
1057
+ one Tab apart in the same form must not answer Down differently, and the
1058
+ convention being imported is a *GUI* convention — a TUI has no per-row focus
1059
+ ring to make it read naturally.
1060
+ - **Selection-follows-arrows fires `on_value_change` once per row traversed.**
1061
+ Arrowing from row 1 to row 5 fires four times, so a listener that resorts a
1062
+ pane, refetches a page or writes a config does that work four times, three of
1063
+ them for choices the user never made. HTML radio groups carry this wart and
1064
+ apps debounce around it. This is the argument that decides it; consistency
1065
+ alone would have been a preference.
1066
+
1067
+ **Decision — the cursor is *chrome*.** It joins `items` on the presentation
1068
+ side of the chrome/value split, which makes the independence symmetric:
1069
+ committing leaves the cursor alone, and `value=` (and the `value:` ctor kwarg)
1070
+ does **not** move it. This is not a new rule — it is what `CheckboxGroup`
1071
+ already does, unnamed, by installing a bare `List::Cursor.new` whatever the
1072
+ seeded value was; naming it is what stops `RadioGroup` diverging by accident.
1073
+ The `(*)` glyph carries the selection at all times, and the row highlight
1074
+ carries the cursor and correctly vanishes when the group goes inactive
1075
+ (`show_cursor_when_inactive` stays at its `false` default). An app that wants
1076
+ the cursor parked on the selection parks it through the public `content`.
1077
+
1078
+ **Decision — `items=` clamps the cursor**, the one place chrome touches chrome.
1079
+ Not tidiness: `List#lines=` deliberately leaves a stale cursor alone, so a
1080
+ shrinking `items=` strands it off-content (no highlight, dead Enter), and Space
1081
+ in that window resolves `items[stale]` to `nil` and *silently clears the
1082
+ selection*, firing `on_value_change(nil)`. The clamp goes through
1083
+ `Cursor#go_to_last`, mirroring `List`'s own one-sided-clamp idiom, so an empty
1084
+ list floors at 0 and a `Cursor::Limited` keeps its own notion of "last". The
1085
+ `index.between?` guard on the select path is still required — it covers
1086
+ `Cursor::None` — which is what `CheckboxGroup` survives on today.
1087
+
1088
+ **Alternatives rejected.**
1089
+ - *Selection == cursor (the desktop convention), the first design:* above. Worth
1090
+ recording what it also dragged in, since each looked like an independent
1091
+ problem at the time: an `on_cursor_changed` → `value=` → `lines=` →
1092
+ `notify_cursor_changed` re-entrancy loop terminated only by `HasValue`'s no-op
1093
+ guard; `List`'s PgUp/PgDn moving the viewport rather than the cursor, which
1094
+ scrolls the selection off-screen; Enter swallowed by the inner list for no
1095
+ gain; and `show_cursor_when_inactive` needing to be flipped so an unfocused
1096
+ group still showed its selection. Four frictions, one cause — they evaporated
1097
+ together when the models split, which is the tell that the model was wrong
1098
+ rather than the framework awkward.
1099
+ - *Park the cursor on the selected row on `value=`:* the intuitive nicety, and
1100
+ the reason to decline it is that it is *asymmetric* — a programmatic write
1101
+ moving a piece of user-facing navigation state. It also does not scroll into
1102
+ view (`move_viewport_to_cursor` is private to `List`'s own key/mouse paths),
1103
+ so on a scrolling group it parks the cursor off-screen. Left to the app.
1104
+ - *Paint the rows directly (`< Component` + `draw_line`), the fallback the idea
1105
+ note held open:* it existed to escape the four frictions above, which the
1106
+ interaction model removes. Composing a `List` then costs nothing and keeps the
1107
+ cursor, viewport, scrollbar and mouse arithmetic in one place.
1108
+ - *A `glyphs=` knob for `(•)`:* `D-ambiguous-width` blesses an opt-in knob but
1109
+ doesn't demand one, and `Checkbox`/`CheckboxGroup` both ship literals. Adding
1110
+ it here alone would create symmetry pressure for a third. Ship `(*)`/`( )`;
1111
+ add the knob to all three the day someone wants the bullet.
1112
+ - *A shared base with `CheckboxGroup`:* declined for the third time (see
1113
+ `D-checkbox-group`). The two differ in exactly one line — `Set` membership vs
1114
+ `==` — and the `cop` duplicate-rather-than-fold rule covers the rest.
1115
+
1116
+ **Consequences.** Space on the already-selected row is a no-op, not a deselect:
1117
+ `value=`'s no-op guard swallows it, so `nil` is reachable only programmatically
1118
+ — an app wanting "none" gives it a row. Two `==`-equal items share one
1119
+ selection and *both* rows render `(*)`, while two distinct items sharing a label
1120
+ stay independent (a row resolves to an item by index). The sampler pane reports
1121
+ value and cursor side by side, which is the cheapest way to see the split.
1122
+
1123
+ ## D-text-field-axes — `TextField`: two axes, horizontal scrolling, a logical cap (2026-07-31)
1124
+
1125
+ **Status:** Accepted; `Component::TextField` rewritten 2026-07-31. Builds on
1126
+ `D-ambiguous-width` (which already asserted that "every rect, caret column and
1127
+ clip derives from `StyledString#display_width`" — a claim `TextField` was quietly
1128
+ violating). Scoped to `TextField`; `TextArea` carries the same bug and is *not*
1129
+ fixed here.
1130
+
1131
+ **Context.** `TextField` treated its caret index and its terminal column as one
1132
+ number. That is correct for ASCII and wrong for everything else, and it failed in
1133
+ four separate places at once: the hardware cursor landed at `rect.left + caret`
1134
+ (with `"日本語"` and the caret at the end, column 3 — the middle of the second
1135
+ glyph — instead of column 6); `repaint` padded with `rect.width - text.length`
1136
+ spaces, so the field's background well overran its rect by one column per wide
1137
+ glyph (columns 0..12 of a 10-wide field, breaking the never-draw-outside-your-rect
1138
+ invariant); the capacity check counted characters against a column budget, so a
1139
+ 10-wide field accepted 18 columns of CJK; and a mouse click mapped its column
1140
+ straight onto a character index, misplacing the caret from the second glyph on.
1141
+ Combining marks broke the same conversions from the other side — a decomposed
1142
+ `"é"` is two characters and one column.
1143
+
1144
+ **Decision — name the two axes and convert explicitly.** An **index** counts
1145
+ characters into `text` (the axis of `caret`, `max_text_length`, every edit); a
1146
+ **column** counts terminal cells (the axis of `rect`, `left_column`,
1147
+ `cursor_position`, `MouseEvent`). Every crossing goes through one private pair,
1148
+ `column_at(index)` / `index_at(column)`; the class rdoc states that adding an
1149
+ index to a column anywhere else is the bug they exist to prevent. Keeping the
1150
+ caret on the index axis was never in question — edits, word jumps and
1151
+ `text[i]` all want it — so the fix is the *missing conversion*, not a
1152
+ redefinition.
1153
+
1154
+ **Decision — scroll horizontally instead of capping to the width.** `left_column`
1155
+ follows the caret by the minimum needed, mirroring `TextArea#top_display_row`.
1156
+ This deletes the width-derived capacity rule rather than fixing its arithmetic:
1157
+ the old `rect.width - 1` cap existed to reserve a column for the caret parked
1158
+ past the last glyph, and that reservation now lives in the scroll clamp
1159
+ (`text_columns - rect.width + 1`) where it belongs. Consequence: `text=` no
1160
+ longer silently trims, and a printable key is now *always* consumed — previously
1161
+ a full field let typing fall through to a scope-wide binding, contradicting the
1162
+ book's own claim that a focused field consumes every printable key.
1163
+
1164
+ **Decision — `left_column` snaps *forward* to a glyph boundary.** The window must
1165
+ never open on a wide glyph's right half. Forward is the only safe direction, and
1166
+ the reason is not "it shows more": the caret's own column is always a glyph
1167
+ boundary, so the next boundary at or after `left_column` cannot overshoot it.
1168
+ Snapping backward pulls the window's right edge inward and strands the caret
1169
+ outside it whenever wide glyphs exactly fill a narrow field (width 4, `"日本語"`,
1170
+ caret at end: the window becomes exactly `本語` with no column left for the
1171
+ caret). A glyph straddling the *right* edge is dropped and its cell padded, never
1172
+ half-painted.
1173
+
1174
+ **Decision — `max_text_length` returns as an app-set logical bound.** Optional
1175
+ (`nil` by default), counted **in characters** — a wide glyph counts once — and it
1176
+ gates *typing only*: at the cap a printable key does nothing and is still
1177
+ consumed. It deliberately does not police `text=`, which stays authoritative as
1178
+ it is for `ComboBox#value` and `CheckboxGroup#value` (`D-combobox`,
1179
+ `D-checkbox-group`), so lowering the cap under an existing value leaves that
1180
+ value intact instead of silently trimming it. A cap in *columns* was rejected: it
1181
+ would make the maximum text depend on which characters were typed, which is
1182
+ exactly the width-vs-length confusion this note removes.
1183
+
1184
+ **Alternatives rejected.**
1185
+
1186
+ - **Redefine `caret` as a column.** Every edit operation (`insert`, `slice!`,
1187
+ the word jumps in `AbstractStringField`) is index-native, so this pushes the
1188
+ conversion into more places rather than fewer, and the shared base would have
1189
+ to carry two meanings for one ivar.
1190
+ - **Fix the arithmetic but keep reject-on-overflow.** Cheaper, and it keeps a
1191
+ cap whose value silently depends on the user's script — a field that holds 9
1192
+ Latin characters and 4 CJK ones. Scrolling is what every real text input does.
1193
+ - **Grapheme-cluster caret stepping.** Out of scope here, and it is a change to
1194
+ `AbstractStringField` (arrows, backspace) that `TextArea` shares. The
1195
+ conversions tolerate a mid-cluster caret today by displaying it at the column
1196
+ just past the cluster, which is the direction the arrow key was pressed.
1197
+ - **Cache the index↔column mapping.** A single line of text is short and
1198
+ `Buffer.display_width` is memoized per grapheme, so each walk is a few hash
1199
+ reads. A cache would need invalidating on every mutation — `TextArea`'s
1200
+ `@display_rows` hazard — for no measured gain.
1201
+
1202
+ **Consequences.** `TextField` no longer has a maximum length by default;
1203
+ an app that wants one sets `max_text_length`. `ComboBox` and `IntegerField`
1204
+ inherit scrolling for free through the `TextField` they compose, so a long query
1205
+ or a long number is now reachable instead of rejected. `TextArea` is now the
1206
+ only component still conflating the axes — its wrap computation measures
1207
+ characters against a column width, so CJK prose overflows every row.
1208
+
1209
+ ## D-text-area-columns — `TextArea`: a cluster-iterating wrap over two axes (2026-07-31)
1210
+
1211
+ **Status:** Accepted; `Component::TextArea` wrap rewritten 2026-07-31. The second
1212
+ half of `D-text-field-axes`, which fixed `TextField` and recorded this as open.
1213
+ Deliberately does **not** touch how the caret *steps* — that is
1214
+ `D-cluster-caret`.
1215
+
1216
+ **Context.** `compute_display_rows` filled each row by counting **characters**
1217
+ against `rect.width`, a **column** budget. So CJK prose wrapped at roughly twice
1218
+ the visible width and overflowed every row; `caret_to_display` returned a
1219
+ character offset that `cursor_position` consumed as a column; and `repaint`
1220
+ padded with `rect.width - row[:length]` spaces, overrunning the rect exactly as
1221
+ `TextField` did. Same three symptoms, same cause.
1222
+
1223
+ Two things surfaced only once the rewrite was underway.
1224
+
1225
+ **The old wrap could hang the UI thread.** Any whitespace that is neither space,
1226
+ tab nor newline — `\r`, `\v`, `\f` — dead-looped it: the character matches
1227
+ `/\s/`, so the word scan measured length zero and `pos` never advanced; it fails
1228
+ `/[ \t]/`, so the whitespace branch was skipped; and it is not `"\n"`, so the
1229
+ loop never broke. `area.text = File.read(crlf_file)` was enough to wedge the
1230
+ event loop forever. Reproduced by replaying the old loop on `"ab\r\ncd"`,
1231
+ `"ab\vcd"` and `"ab\fcd"`. This was never a reported bug, which is why it is
1232
+ recorded here: a character wrap has no structural reason to advance, so
1233
+ termination was accidental rather than guaranteed.
1234
+
1235
+ **`"\r\n"` is one grapheme cluster.** Verified. A cluster-iterating wrap
1236
+ therefore cannot test `c == "\n"` for a hard break.
1237
+
1238
+ **Decision — rows carry both counts; the wrap walks clusters.** A row is
1239
+ `{start: <char index>, length: <chars>, columns: <cols>}`: the wrap fills to a
1240
+ column budget while recording a character span, so the index axis and the column
1241
+ axis each stay authoritative for what they address. Iterating **grapheme
1242
+ clusters** rather than characters is required twice over — a combining mark must
1243
+ add zero columns *and* must not be split from its base across a row break — and
1244
+ it makes termination structural: `measure_word` and `hard_wrap` advance on any
1245
+ cluster that is neither blank nor a newline, so the `\r` / `\v` / `\f` class of
1246
+ hang cannot recur. `hard_wrap` consumes a glyph even when that single glyph is
1247
+ wider than the entire row, for the same reason; such a row reports more columns
1248
+ than the rect holds and `padded_row` drops the glyph — a 2-column glyph in a
1249
+ 1-column area is unpaintable either way, but the wrap must still finish.
1250
+
1251
+ **Decision — one shared measurement primitive.** `AbstractStringField#columns_of`
1252
+ (per-cluster, over the memoized `Buffer.display_width`) is the only place either
1253
+ input measures a width; `TextField#column_at` collapsed into a call to it. A
1254
+ second copy in `TextArea` was the alternative and is exactly how the two classes
1255
+ would drift apart again.
1256
+
1257
+ **Decision — vertical movement preserves the *column*.** Up/Down used to carry a
1258
+ character offset into the target row, which put the caret in a visually different
1259
+ place whenever the two rows had different glyph widths. It now converts the
1260
+ column back to a character offset in the target row. This is a behavior change,
1261
+ not just a bug fix, and it matches every editor.
1262
+
1263
+ **Alternatives rejected.**
1264
+
1265
+ - **Iterate characters, summing per-character widths.** Gets the column totals
1266
+ right (a mark measures 0, a wide glyph 2) and is a smaller diff, but it can
1267
+ split a cluster across a row break — leaving a bare base letter on one row and
1268
+ a mark with no base on the next, which `Buffer#set_line` drops entirely. It
1269
+ also keeps termination accidental.
1270
+ - **Wait for the cluster-caret redesign and do both at once.** The redesign is
1271
+ parked, and this fix does not depend on it: the caret stays a character index
1272
+ and only the conversions change. Waiting would have left a UI-thread hang in
1273
+ place.
1274
+ - **Store columns only, deriving char offsets on demand.** Every edit
1275
+ (`insert`, `slice!`) needs a character offset, so this trades one stored
1276
+ integer per row for a conversion on every mutation.
1277
+
1278
+ **Consequences.** A row's `start` and `length` stay **character** counts, and
1279
+ `D-cluster-caret` kept them that way — boundary-locking the caret needed no
1280
+ change here at all, precisely because this wrap is already cluster-iterating and
1281
+ `chars_for_column` / `caret_to_display` already return boundary-aligned counts.
1282
+ The cluster-**width** question this entry left open was closed separately by
1283
+ `D-cluster-width`.
1284
+
1285
+ ## D-cluster-width — Emoji width policy `:rgi`; a cluster may exceed two columns (2026-07-31)
1286
+
1287
+ **Status:** Accepted; implemented 2026-07-31. Completes the width story begun in
1288
+ `D-ambiguous-width` and continued through `D-text-field-axes` /
1289
+ `D-text-area-columns`, which fixed *where* widths were measured while this fixes
1290
+ *what a width is*.
1291
+
1292
+ **Context.** Two independent bugs, both about the grapheme cluster as the unit a
1293
+ terminal actually draws.
1294
+
1295
+ **(1) Sequences summed their parts.** `Unicode::DisplayWidth.of` defaults to no
1296
+ emoji handling, so `"👍🏽"` (thumbs-up + skin-tone modifier — one cluster, one
1297
+ glyph, 2 columns) measured **4**, and a ZWJ family measured **6**. Every rect,
1298
+ caret column and clip derives from that number, so an emoji in a label overran
1299
+ its cell, shifted the rest of the row and desynced the cursor. Worse, the
1300
+ measurement *unit* was inconsistent: `Buffer` measured per cluster while
1301
+ `StyledString`'s slice and wrap internals walked `each_char`. A per-character
1302
+ walk cannot see a sequence at all, and it cuts clusters apart — `slice(0, 3)` of
1303
+ `"abé"` (decomposed) returned `"abe"`, silently stripping the accent off a
1304
+ letter that was entirely inside the slice, because the zero-width mark fell past
1305
+ the slice end.
1306
+
1307
+ **(2) `Buffer` could not model a cluster wider than two columns.** `put_char`
1308
+ special-cased `w == 2` and wrote exactly one continuation cell. A cluster
1309
+ measuring 4 wrote its origin, no continuations, and left the next three cells
1310
+ holding whatever was there before — while `set_line` advanced the column by 4.
1311
+ Stale cells plus a cursor the flush positions from a wrong model.
1312
+
1313
+ **Decision — `emoji: :rgi`, in one named constant, at every call site.**
1314
+ `StyledString::EMOJI_WIDTH` is the single policy and all five
1315
+ `Unicode::DisplayWidth.of` calls pass it. `:rgi` credits width 2 only to
1316
+ [RGI](https://www.unicode.org/reports/tr51/#def_rgi_set) sequences — the ones
1317
+ vendors actually ship a single glyph for — and sums the parts of everything
1318
+ else.
1319
+
1320
+ The choice follows from an **asymmetry, not a preference**: under-measuring lets
1321
+ a glyph overrun its cell, which shifts the row, desyncs the cursor and escapes
1322
+ the component's rect; over-measuring leaves one blank column. Corruption versus
1323
+ cosmetics. `:rgi` is the only setting never wrong in the corrupting direction —
1324
+ for a sequence it is exact when the terminal draws the parts and over-measures
1325
+ when the terminal combines them, and it treats VS16 emoji presentation as 2.
1326
+
1327
+ Note this bets the *opposite* way from `D-ambiguous-width`, deliberately. That
1328
+ note bets narrow because the glyphs at stake are Tuile's **own chrome** — box
1329
+ drawing, the scrollbar block — which the framework controls and needs at one
1330
+ column. Here the glyphs are **app content**, where the framework controls
1331
+ nothing and the asymmetry above governs.
1332
+
1333
+ **Decision — a cluster may occupy any number of cells.** `put_char` writes its
1334
+ origin plus `w - 1` continuations, and the flank repairs walk the whole run:
1335
+ `blank_left_partner` climbs to the glyph's head instead of assuming `x - 1`, and
1336
+ `blank_right_partner` blanks every trailing continuation instead of one. The
1337
+ pre-existing rule that a multi-column glyph which would overflow the row is
1338
+ *blanked* rather than clipped now applies at any width — a terminal cannot draw
1339
+ a partial cluster.
1340
+
1341
+ **Decision — keep two measurement routes, and pin them with a spec.**
1342
+ `StyledString#display_width` keeps its single whole-string gem call;
1343
+ `Buffer.display_width` stays per-cluster and memoized. Measured: for an ASCII
1344
+ row — the common case — summing clusters is **~11x slower** than one gem call,
1345
+ because the gem has a dedicated ASCII fast path. Unifying on cluster-summing
1346
+ would therefore regress the documented repaint hot spot. The two routes agree
1347
+ (whole-string == sum-over-clusters under `:rgi`, verified over a corpus of ZWJ
1348
+ sequences, tag flags, keycaps, VS16 and decomposed Latin), and
1349
+ `styled_string_spec` asserts that agreement so the invariant is test-enforced
1350
+ rather than assumed.
1351
+
1352
+ **Alternatives rejected.**
1353
+
1354
+ - **`emoji: :all` or `:possible`.** Both credit width 2 to malformed or
1355
+ non-RGI sequences, which terminals draw as separate parts — under-measuring,
1356
+ the corrupting direction.
1357
+ - **`emoji: :rgi_at` / `:all_no_vs16` / the `:none` status quo.** All treat a
1358
+ VS16 emoji-presentation sequence as its East-Asian width (often 1) where
1359
+ most terminals draw 2. Same corrupting direction, narrower blast radius.
1360
+ - **`emoji: :auto`.** The gem can sniff the terminal and pick per environment.
1361
+ Rejected: it makes layout arithmetic non-reproducible across machines and
1362
+ makes the spec suite depend on whoever's `$TERM_PROGRAM` runs it — and Tuile's
1363
+ whole width strategy is one global answer with a small, enumerable inventory
1364
+ (`D-ambiguous-width`). An app that needs its terminal's exact answer is better
1365
+ served by a future explicit override than by ambient detection.
1366
+ - **Clamp any cluster to 2 columns.** Would have avoided touching `put_char`,
1367
+ and is simply wrong for a non-RGI sequence the terminal really does draw
1368
+ 4 columns wide.
1369
+ - **Make `StyledString#display_width` sum clusters for one unified path.** The
1370
+ ~11x ASCII regression above.
1371
+
1372
+ **Consequences.** `Buffer.display_width` of an RGI sequence changed from the sum
1373
+ of its parts to 2, so any app that hard-coded the old number will disagree.
1374
+ `slice`/`ellipsize`/`wrap` now keep clusters whole, which means a slice can
1375
+ return *fewer* columns than asked when a wide glyph straddles the boundary — it
1376
+ drops the glyph rather than halving it, as it already did for CJK. Unaffected: a
1377
+ cluster spanning two style spans takes the first span's style rather than being
1378
+ split. The caret stepped by character when this landed; `D-cluster-caret` fixed
1379
+ that separately.
1380
+
1381
+ ---
1382
+
1383
+ ## D-screen-lifecycle — UI thread confinement, and three named screen states (2026-08-01)
1384
+
1385
+ **Status:** Accepted; implemented 2026-08-01. First step of the tree-first
1386
+ sequencing (`D-tree-first`), and independent of the rest of it.
1387
+
1388
+ **Context.** `Screen` carried a two-valued, unnamed state machine:
1389
+ `@pretend_ui_lock = true` in `initialize`, flipped to `false` on
1390
+ `run_event_loop`'s first line and **never restored**. `check_locked` was
1391
+ `@pretend_ui_lock || @event_queue.locked?` (where `locked?` was
1392
+ `Mutex#owned?`). That has a hole with a decided end and an accidental one:
1393
+ pre-loop mutation was *deliberately* blessed, but once `run_event_loop`
1394
+ returned nobody held the mutex and the pretend flag was gone, so **every
1395
+ UI call raised "UI lock not held" during teardown** — a rule nobody chose.
1396
+ There was also no vocabulary for the phases, so "is this legal here?" had
1397
+ no answer to appeal to, and post-`close` mutation failed as
1398
+ `NoMethodError for nil` from inside a nil pane.
1399
+
1400
+ **Decision.** Two orthogonal concepts, named separately.
1401
+
1402
+ 1. **Thread confinement** — the UI belongs to one thread at a time: *the
1403
+ loop's thread while a loop runs, the thread that created the screen when
1404
+ none does.* `check_locked` asks `EventQueue#running?` (is a loop active
1405
+ on any thread) and then either `#on_loop_thread?` or
1406
+ `Thread.current.equal?(@ui_thread)`. `@pretend_ui_lock` is deleted; the
1407
+ post-loop hole closes because "no loop is running" is now an expressible
1408
+ state rather than the absence of a flag. `EventQueue#locked?` was renamed
1409
+ `#on_loop_thread?` — `locked?`-meaning-`owned?` was the misnomer that hid
1410
+ the bug.
1411
+ 2. **`Screen#state`** — `:idle` / `:running` / `:closed`, derived, with
1412
+ `@closed` the only stored phase. `:closed` is terminal and is the sole
1413
+ state that changes *what* is legal.
1414
+
1415
+ `FakeScreen#check_locked`'s no-op override is deleted too:
1416
+ `FakeEventQueue#running?` is `false`, so the *real* check admits the example
1417
+ thread on its own. Two overlapping fakes became one honest fact.
1418
+
1419
+ **Alternatives rejected.**
1420
+ - **Confine to the creating thread, unconditionally** — one identity check,
1421
+ no `running?`, the simplest possible rule; `run_event_loop` would raise
1422
+ unless called on the creating thread. Rejected on evidence: the gem's own
1423
+ `screen_spec` drives `event_loop` from a spawned thread against a screen
1424
+ built on the example thread (three examples), and that is a legitimate
1425
+ embedding pattern, not a spec hack. The two-question check costs one
1426
+ branch and keeps it working.
1427
+ - **Four states (`building` / `running` / `stopped` / `closed`).** The
1428
+ original instinct, and `stopped` is where the post-loop teardown window
1429
+ wanted to live. Rejected once confinement was factored out: `building` and
1430
+ `stopped` have *identical* rules, so distinguishing them means storing a
1431
+ `@ran` flag purely to name two things that behave the same — and a named
1432
+ state with no distinct rule is an invitation to invent one. `:idle`
1433
+ covering both ends is the honest merge.
1434
+ - **Leave the fake's lock bypass in place.** Convenient, but it means specs
1435
+ cannot observe the rule they're supposed to protect, and it hid the
1436
+ post-loop hole for as long as it existed.
1437
+ - **Let `close` work from `:running`.** Today it nils the pane the loop is
1438
+ still painting and dies confusingly on the next repaint. Now it raises,
1439
+ pointing at `event_queue.stop`. Verified no caller does it (all three
1440
+ `examples/` and every spec `after` close from `:idle`).
1441
+ - **Rename `check_locked`.** It is now a misnomer twice over — it checks
1442
+ state *and* affinity, and never checked a lock. Deferred anyway: it's
1443
+ public, called from `List`/`TextView`, and possibly by downstream apps;
1444
+ not worth the churn in the same change that fixes the semantics.
1445
+
1446
+ **Consequences.** `EventQueue#locked?` is gone — callers use
1447
+ `#on_loop_thread?`. A background thread that mutated UI during the pre-loop
1448
+ window still can (that was blessed before and stays blessed), but one that
1449
+ does so from a *non-creating* thread now raises where it used to pass; that
1450
+ is the hole closing, and it can surface in existing app startup code.
1451
+ `submit` outside `:running` is a silent no-op (before the loop it defers;
1452
+ after it, `run_loop`'s `ensure` has cleared the queue), which is why
1453
+ `check_locked`'s two messages differ — advising `submit` with no loop
1454
+ running would advise nothing happening. A background thread can still slip
1455
+ through by reading `running?` in the instant before the loop starts;
1456
+ inherent, and `:idle` is single-threaded by construction. Finally,
1457
+ `run_event_loop`'s guard had to move *outside* its `begin`/`ensure`: a
1458
+ refusal that ran the terminal teardown restored echo on a non-TTY stdin and
1459
+ raised `ENOTTY`, masking the real error.
1460
+
1461
+ ---
1462
+
1463
+ ## D-tree-api — `@children` is authoritative; `add_child`/`remove_child` are the only path (2026-08-01)
1464
+
1465
+ **Status:** Accepted and implemented 2026-08-01. No `children` override
1466
+ remains in `lib/`; the only `parent =` assignments left are the two inside
1467
+ `add_child` / `detach_child`.
1468
+
1469
+ **Context.** Five call sites used to hand-wire `child.parent = …` alongside
1470
+ their own child bookkeeping, each in its own order. That is where the
1471
+ transient tree inconsistency and the focus-repair ordering accident came
1472
+ from (`D-tree-first`), and it is what the attach/detach hooks would
1473
+ have to fire *through*. Two shapes fix it, and they are not equivalent:
1474
+
1475
+ - **A** — `Component` owns an `@children` array; `children` is a plain
1476
+ reader; protected `add_child(child, at:)` / `remove_child(child)` write the
1477
+ array *and* the parent pointer. Containers keep slot ivars (`@content`,
1478
+ `@popups`, `@footer`) as references and choose an insert index.
1479
+ - **B** — containers keep deriving `children` from their slots (as they do
1480
+ today), and only the *wiring* moves into shared mutators.
1481
+
1482
+ B is tempting because the hooks don't need A: they fire from `parent=` inside
1483
+ the mutator either way, and B costs no duplication and no index arithmetic.
1484
+
1485
+ **Decision.** **A.** The deciding argument is not aesthetics but that the
1486
+ hook feature reads *two different structures*: `attached?` walks the **parent
1487
+ chain**, while the subtree fire walks **`children`**. If those can disagree,
1488
+ hooks fire for the wrong set of components — a component can be `attached?`
1489
+ yet never walked. Under A one call writes both, so
1490
+ `children.include?(c) ⟺ c.parent == self` holds by construction. Under B they
1491
+ are independent per container, and every container has to keep them in
1492
+ agreement by hand, forever, with nothing checking it.
1493
+
1494
+ That failure mode is not hypothetical — it is *live* mid-migration, and
1495
+ `Window` demonstrates it exactly:
1496
+
1497
+ ```ruby
1498
+ w.footer = label
1499
+ label.parent.equal?(w) # => true
1500
+ w.children.include?(label) # => true (Window derives it)
1501
+ w.instance_variable_get(:@children) # => [] ← the authoritative list is a lie
1502
+ ```
1503
+
1504
+ **Alternatives rejected.**
1505
+ - **B (derived `children`, mutators for wiring only).** Above: leaves the two
1506
+ structures the hook walk depends on independent. Also gives up a measured
1507
+ 0-vs-6 objects per `children` read — and `on_tree` reads `children` once per
1508
+ node on every repaint, so it is a per-node, per-frame path.
1509
+ - **Derive `popups` from `@children`** to avoid the one real duplication A
1510
+ costs (`@popups` and `@children` both carry popup order). Every spelling is
1511
+ worse: an index slice (`@children[offset..-2]`) is fragile and allocates on
1512
+ the hot path where `popups` is read, and `grep(Popup)` breaks the moment a
1513
+ popup is used as tiled content. `@popups` stays, guarded by a drift
1514
+ assertion in `screen_pane_spec`.
1515
+ - **`size - 1` for the popup insert index.** Works, but silently assumes the
1516
+ status bar is last. `at: @children.index(@status_bar)` names the anchor.
1517
+
1518
+ **Consequences.** Migrating the two slot containers forced a third mutator:
1519
+ `HasContent#content=` and `Window#footer=` must notify `on_child_removed`
1520
+ *after* the new occupant is wired (the default focus repair cascades into
1521
+ whatever fills the slot now — `window_spec` pins that a content swap lands
1522
+ focus on the new content), so `detach_child` does delete-plus-unwire without
1523
+ notifying and `remove_child` is `detach_child` + notify. A container swapping
1524
+ a slot uses the quiet one and owes the notification.
1525
+
1526
+ The invariant is *maintained by the sane path*, not
1527
+ unbreakable: `parent=` has to stay `protected` (Ruby won't dispatch a private
1528
+ writer through an explicit receiver, which `child.parent = self` needs), so a
1529
+ subclass can still hand-wire and desynchronize. AGENTS.md carries the rule.
1530
+ Ordering moved from recomputed-per-read to maintained-at-insert, so it needs
1531
+ specs rather than being true by inspection. Every `Component` subclass must
1532
+ call `super` in `initialize` or `@children` is nil — all 20 currently do.
1533
+ A container needing `children` order to be a function of state that changes
1534
+ *without* a tree mutation (a z-index sort) would have to re-sort `@children`
1535
+ in that setter; none does today, and that is the one thing that would argue
1536
+ for B.
1537
+
1538
+ ---
1539
+
1540
+ ## D-attach-hooks — `on_attached` / `on_detached`: an edge trigger on the component (2026-08-01)
1541
+
1542
+ **Status:** Accepted and implemented 2026-08-01. Last step of the tree-first
1543
+ sequencing (`D-tree-first`); both `ideas/` notes it was designed in are retired.
1544
+
1545
+ **Context.** Tuile had two thirds of a tree lifecycle: `attached?` (a computed
1546
+ predicate) and `on_child_removed` (a *container-side* notification used for
1547
+ focus repair). Missing was an **edge trigger on the component itself**, so a
1548
+ component could not own a resource whose lifetime is its own mounted lifetime
1549
+ — a ticker, a subscription, a tailed file handle. Note the asymmetry that made
1550
+ this a real gap: `invalidate` is already attachment-gated, so the framework
1551
+ quietly handles the one resource it knows about, while anything the *app*
1552
+ acquires has no such gate. The general consumer is COP's listener inversion —
1553
+ a component subscribes to a service, and there was no symmetric place to
1554
+ unsubscribe, so every app either leaked for the process lifetime or hand-rolled
1555
+ teardown at each call site that closes a window.
1556
+
1557
+ **Decision.** Two `protected` no-op hooks on `Component`, fired from the
1558
+ protected `parent=` writer — the sole reparenting choke point, provably so now
1559
+ that `add_child` / `detach_child` are its only callers. `parent=` measures
1560
+ `attached?` either side of the pointer write and fires `fire_lifecycle` across
1561
+ the whole subtree only on a genuine transition. Past-tense `on_` names match
1562
+ the local convention (`on_child_removed`, `on_theme_changed`) rather than
1563
+ Vaadin's imperative `onAttach`. Contract: **`on_attached` starts what
1564
+ `on_detached` stops; both cheap and idempotent**, and whatever a hook acquires
1565
+ it must release in the mirror, because nothing else will.
1566
+
1567
+ **Alternatives rejected.**
1568
+ - **`!attached?` self-cancel inside the ticker block.** Stops the leak but
1569
+ never *restarts*: a component moved between parents silently loses its
1570
+ animation forever. The objection isn't the transient detachment, it's that
1571
+ there is no edge to restart on — which is exactly what a hook is.
1572
+ - **A Screen-owned animation registry** (`screen.animate(component, fps)`,
1573
+ auto-cancelled on detach). Fixes the same leak with no new `Component` API,
1574
+ but it doesn't restart either, it puts an animation concern into `Screen`,
1575
+ and it does nothing for the subscription case, which is the general one.
1576
+ - **Firing from the five reparenting sites**, or now from the two mutators.
1577
+ Rejected for the reason the whole tree-first arc exists: one site, one
1578
+ correct order. Attach must be measured after the pointer is wired, detach
1579
+ before — spread across sites that is five chances to get it wrong.
1580
+ - **`parent.equal?(self)` as the recursion re-check.** This was the design, and
1581
+ implementing it proved it wrong: a child a hook removes *during a detach
1582
+ walk* is already detached, so its own `parent=` saw no transition and stayed
1583
+ silent — and the parentage check then skips it too, so it never hears
1584
+ `on_detached` at all. Re-checking `attached? == attached` fixes it. The
1585
+ reverse case (removed during an *attach* walk) gets an unpaired
1586
+ `on_detached`, which the idempotence requirement makes harmless — whereas
1587
+ firing `on_attached` at a component that is no longer attached would start a
1588
+ ticker nothing ever stops.
1589
+ - **`on_attached=` / `on_detached=` writer pair** (the composition-style
1590
+ alternative to subclassing, as `on_theme_changed=`). Deferred: shipping four
1591
+ members when two are unproven is how a seam ends up wider than its need.
1592
+ **Re-grow rule:** add the writers the first time an assembly-style app needs
1593
+ a subscription without subclassing.
1594
+ - **Leaving `Screen#close` silent** (the shape shipped for one commit, then
1595
+ lifted the same day). The argument for silence was that a Tuile screen dies
1596
+ with the process, unlike Vaadin's UI, which closes inside a long-lived JVM
1597
+ that goes on serving other sessions — so a missed `onDetach` there leaks into
1598
+ a *surviving* process and here it does not. That still holds, and it is why
1599
+ teardown-detach was never *urgent*; what overrode it is that `attached?`
1600
+ became a type test (`D-tree-api`), so a tree rooted at a nilled `@pane` went
1601
+ on claiming to be attached forever and touching it raised "Screen not
1602
+ initialized". Firing is also just cheaper than explaining that. So
1603
+ `Screen#close` now calls `ScreenPane#detach_all`.
1604
+ - **Swallowing a raise during teardown** (rescue-and-log), which the deferred
1605
+ design had specified on the grounds that teardown must not be abortable.
1606
+ Rejected: a raising `on_detached` is a programming error, and the framework
1607
+ guarding it would hide the bug — Vaadin does not guard here either. The real
1608
+ concern behind that rider survives without a rescue, by putting the teardown
1609
+ flags in an **`ensure`**: the exception propagates loudly, but `@closed` and
1610
+ the singleton slot are still cleared, so one buggy hook stays one failure
1611
+ instead of cascading through every later example that inherits a half-closed
1612
+ screen.
1613
+ - **A generic `Component#remove_all_children`** as the unmount primitive.
1614
+ Unsafe: a slot container calling it would empty `@children` while `#content`
1615
+ / `#footer` still pointed at detached components — exactly the desync
1616
+ `D-tree-api` exists to prevent. Unmounting also has to clear the pane's own
1617
+ slots, so it is not a generic tree operation. Named `detach_all` rather than
1618
+ `close` because `Popup#close` already means "remove *me* from the pane".
1619
+
1620
+ **Consequences.** `Screen#close` fires `on_detached` for everything still
1621
+ mounted; a process that exits *without* closing fires nothing, and no `at_exit`
1622
+ is installed to change that. A cross-container move fires `on_detached` then
1623
+ `on_attached`, because between `remove` and `add` the component genuinely *is*
1624
+ detached, for arbitrarily long — honest, and strictly better than a heuristic
1625
+ that never restarts. A hook may not read `rect` (`on_attached` runs before the
1626
+ parent assigns it), may still see `Screen#focused` pointing into the subtree
1627
+ being detached (repair runs after), and must not inspect the ex-parent's
1628
+ bookkeeping. A raising hook propagates and leaves the tree undefined —
1629
+ durably so on the detach path, where the container's remaining work is skipped.
1630
+ Finally, hooks fire during `:idle` on the normal app path (a tree is assembled
1631
+ before `run_event_loop`), which `D-screen-lifecycle` made a decision rather
1632
+ than an accident.
1633
+
1634
+ ---
1635
+
1636
+ ## D-tree-first — `Screen` is the service, `ScreenPane` is the UI (2026-08-01)
1637
+
1638
+ **Status:** Accepted and implemented 2026-08-01, in five steps
1639
+ (`D-screen-lifecycle`, the one-axis `attached?`, `D-tree-api` in two parts,
1640
+ `D-attach-hooks`). The `ideas/` note it was designed in is retired.
1641
+
1642
+ **Context.** Designing two no-op lifecycle hooks
1643
+ (`Component#on_attached` / `#on_detached`) took *ten* documented corner cases:
1644
+ a predicate that raises, a traversal that double-fires, a transiently
1645
+ inconsistent tree, an exception policy that inverts during teardown, two
1646
+ hard-wired exceptions, and a "second axis" framing invented purely to make the
1647
+ exception list provable. Ten edges for two hooks is not a hook problem.
1648
+
1649
+ Six of them traced to one flaw: `attached?` was `root == screen.pane`, reading
1650
+ one property of the **component** (its parent chain) and one of a **mutable
1651
+ pointer inside a global singleton**. A seventh source was `children` being
1652
+ overridable, so five sites hand-wired the parent pointer alongside their own
1653
+ bookkeeping, each in its own order.
1654
+
1655
+ **Decision.** Model the tree as a tree, and keep the runtime out of it.
1656
+
1657
+ - **`Screen` stays machinery and stays out of the tree** — Vaadin's
1658
+ `VaadinService`, roughly. It may remain a process-singleton; nothing here
1659
+ required killing it.
1660
+ - **`ScreenPane` is the tree root and defines attachedness** — Vaadin's `UI`.
1661
+ `attached?` became `root.is_a?(ScreenPane)`: one axis, no `Screen`
1662
+ reference, so it never raises and a tree can be assembled with no screen in
1663
+ the process.
1664
+ - **The tree API is final** (`D-tree-api`), and `parent=` — reachable only
1665
+ through it — is the sole lifecycle firing site (`D-attach-hooks`).
1666
+
1667
+ Deleting the second axis deleted six edges outright rather than documenting
1668
+ them: the raise, the status-bar exception, the two-`@pane`-writes framing, the
1669
+ transient inconsistency, the focus-repair ordering accident, and the teardown
1670
+ exception (which then *inverted* — `Screen#close` now unmounts the tree).
1671
+
1672
+ **Alternatives rejected.**
1673
+ - **A DOM-style `Node`/`Element` split** (`Screen < Node`, `Component < Node`),
1674
+ with `Node` carrying `parent`/`children`/`on_child_removed`. DOM needs it
1675
+ because DOM has non-Element nodes — Text, Comment, DocumentFragment. Tuile
1676
+ has none; every node is a paintable `Component`, so the base would have
1677
+ exactly one subclass family and would not earn its place. `Node` is justified
1678
+ *only* if `Screen` itself joins the tree, which this shape declines.
1679
+ - **`Screen < Component`** — collapses `Screen` and `ScreenPane` into one
1680
+ class. Rejected: a runtime owner would inherit `rect`, `bg_color`,
1681
+ `focusable?`, `handle_key`, `repaint`, surface it has no use for. That mixed
1682
+ bag is what the split undoes.
1683
+ - **An `owning_screen` pointer on the pane** (`attached? =
1684
+ !root.owning_screen.nil?`). Strictly worse than the type test: it puts a
1685
+ screen reference back into the predicate for no gain, and it is a pointer
1686
+ someone eventually nils — which is the original bug.
1687
+ - **Killing the singleton to allow multiple screens.** Multiple screens is a
1688
+ *consequence* some designs permit, never a motivation: one terminal is one
1689
+ screen. `lib/` has exactly one `Screen.instance` call site, so removing it
1690
+ there is a one-line change — but the cost lands on the 27-of-42 spec files
1691
+ built on `Screen.fake` / `Screen.instance`. Keeping the singleton is what
1692
+ made the whole redesign affordable.
1693
+
1694
+ **Consequences.** `attached?` is now answerable with no `Screen` at all, which
1695
+ is what lets `parent=` consult it. `ScreenPane` gained the ordering discipline
1696
+ that `children` used to recompute per read, and `Screen#close` gained a real
1697
+ unmount step. The natural next question this shape *doesn't* answer: `Screen`
1698
+ is still reached as a singleton from `Component#screen`, so a component's
1699
+ screen is ambient rather than derived from its root — fine while one terminal
1700
+ means one screen, and the one-line change if that ever stops being true.
1701
+
1702
+ ---
1703
+
1704
+ ## D-color-slots — A component color slot, not a new chrome token (2026-08-01)
1705
+
1706
+ **Status:** Accepted; first applied by `Component::ProgressBar#bar_color`
1707
+ (implemented 2026-08-02). Binds Slider and Badge when they land — the question
1708
+ was cross-component from the start, so it is settled once here rather than
1709
+ re-argued per widget. Builds on `D-bg-inherit` (accents-only theme, no global
1710
+ bg/fg token) and `D-theme-ref` (the live-resolved slot machinery this reuses).
1711
+
1712
+ **Context.** {Theme} carries four chrome tokens — `active_bg_color`,
1713
+ `active_border_color`, `input_bg_color`, `hint_color` — and a component
1714
+ eventually needs a color none of them covers: the filled run of a progress
1715
+ bar, a slider's thumb and track, a badge's severity tint. The fork looks
1716
+ binary: grow the theme a token, or give the component its own color property.
1717
+
1718
+ **Decision — the slot, and the two were never alternatives.** Because a slot
1719
+ accepts a `Theme::Ref`, it is a *superset* of a token: a token would not remove
1720
+ the need for `bar_color=` (threshold coloring — green under 50 %, red over 90 %
1721
+ — is per-instance and app-owned), but `bar_color=` removes the need for the
1722
+ token. There are three surfaces, not two, and `custom` is the one that
1723
+ dissolves the argument:
1724
+
1725
+ | Surface | Read by | Right when |
1726
+ |---|---|---|
1727
+ | chrome token (a `Theme` `Data` member) | framework chrome, no app involvement | ≥2 built-ins share it *and* there is no app API |
1728
+ | component slot (`Color \| Theme::Ref`) | the component, resolved at paint | the app might brand or vary it |
1729
+ | `custom` token | the app's own slot values | the app wants *its* color to follow dark/light |
1730
+
1731
+ > A component adds a **slot** to give the app a color. A chrome token is added
1732
+ > only when the framework needs the color *with no app involvement*, in *more
1733
+ > than one place*.
1734
+
1735
+ That rule is descriptive rather than invented: all four existing tokens pass it
1736
+ and none has a slot (`active_bg_color` → List cursor + TextField well + Button;
1737
+ `active_border_color` → Window border; `input_bg_color` → both text inputs;
1738
+ `hint_color` → status-bar hints).
1739
+
1740
+ **Decision — a slot defaults to `nil`, the terminal default.** Not to a chrome
1741
+ token whose meaning is something else, and not to a hardcoded color unless the
1742
+ component is meaningless without one. Rejected defaults for `bar_color`, each
1743
+ of which looked right until checked against both built-in themes:
1744
+
1745
+ - **`Theme.ref(:active_bg_color)`** (this component's own first design) — a
1746
+ *background*-role token used as a foreground. `GREY37` (#5f5f5f) is muddy on a
1747
+ dark terminal and `GREY82` (#d0d0d0) is effectively **invisible** on a light
1748
+ one. The bug the rule exists to prevent.
1749
+ - **`Theme.ref(:active_border_color)`** — legible in both (it is the named ANSI
1750
+ green, remapped by the terminal), but the same mistake made invisible: that
1751
+ token means "border of a *focused window*", so a theme author recoloring
1752
+ borders would silently recolor every progress bar in the app.
1753
+ - **`Color::GREEN`** — legible and uncoupled, but a built-in asserting a color
1754
+ when it needs none. `nil` degrades identically and claims less.
1755
+
1756
+ **Decision — Badge starts as a slot too, with a promotion trigger.** Badge is
1757
+ the case that looks like it wants tokens, since info/success/warning/error
1758
+ *are* semantic — but only one built-in paints them today, so it gets a frozen
1759
+ `SEVERITY_COLORS` map of named ANSI colors picked by `severity=`, plus a
1760
+ `color=` slot that overrides. **Promote the map to chrome tokens when a second
1761
+ built-in needs the same semantic color** (a toast, a log-level row): at that
1762
+ moment the framework itself is sharing it, which is precisely what a token is
1763
+ for. The asymmetry is what makes starting at the slot safe — adding a `Data`
1764
+ member is additive, removing one is not.
1765
+
1766
+ **Consequences.**
1767
+
1768
+ - **Slots stay per-purpose and few.** A component sprouting five color slots
1769
+ has a theming problem, not a slot problem. `ProgressBar` therefore has *one*:
1770
+ `░` paints in `bar_color` too, so density distinguishes filled from empty and
1771
+ hue never does — which also keeps the bar readable with no color support at
1772
+ all. A `track_color` would have doubled the surface to weaken that.
1773
+ - **A slot's `Ref` is validated eagerly** (KeyError at assignment, as
1774
+ `bg_color=` does) and re-resolved at paint, never cached — same rules as
1775
+ `D-theme-ref`, including riding the invalidate-everything pass on `theme=`.
1776
+ - **This licenses no global bg/fg token.** `D-bg-inherit` stands: a slot's
1777
+ `Ref` can only point at a color the theme *already* carries.
1778
+
1779
+ ---
1780
+
1781
+ ## D-progress-bar — A value that is not a field; no text on the bar (2026-08-01)
1782
+
1783
+ **Status:** Accepted; `Component::ProgressBar` implemented 2026-08-02, demoed in
1784
+ the sampler. Color is `D-color-slots`; the glyph pair rides `D-ambiguous-width`;
1785
+ the ticker rides `D-attach-hooks`. What this entry owns is the *shape*.
1786
+
1787
+ **Context.** The first component with a `value` that is emphatically **not** an
1788
+ input: nothing focuses it, nothing types into it, and its number comes from the
1789
+ app's own work loop rather than a user.
1790
+
1791
+ **Decision — no `HasValue`.** Tempting (it has a `value`), but that mixin is the
1792
+ *input-field* seam: it carries `focusable? = true`, so including it would make a
1793
+ display widget a focus target and then need an override to undo that, and it
1794
+ would put a read-only report into the seam a future forms layer iterates over.
1795
+ Plain accessors instead. Vaadin's `ProgressBar` likewise has `setValue` without
1796
+ implementing `HasValue`.
1797
+
1798
+ **Decision — no text on the bar; compose a `Label`.** An earlier draft had a
1799
+ `caption` slot (`:percentage | :fraction | String | nil`, centered and overlaid
1800
+ on the fill). Three reasons it went:
1801
+
1802
+ - **The overlay is the entire complexity budget.** Without it `repaint` is a
1803
+ handful of lines; with it you slice a {StyledString} at the fill boundary and
1804
+ merge per-span fg so the text stays legible on both sides, plus centering
1805
+ arithmetic through `display_width`, plus specs at every fill level. More code
1806
+ than the bar it decorates, all of it formatting.
1807
+ - **Composition is strictly better here, not merely adequate.** A sibling
1808
+ {Component::Label} gets styling, theming and `on_theme_changed` free, and the
1809
+ app can put any words anywhere; an overlay can only ever be "centered, one
1810
+ line, clipped to the bar".
1811
+ - **The component-oriented toolkits agree.** Vaadin 25.2's `ProgressBar` has no
1812
+ text API at all and its own docs compose a label beside it; JavaFX exposes
1813
+ only `progressProperty()` with the same convention. The toolkits that *do*
1814
+ carry text are older and landed on either a boolean-plus-override-string
1815
+ (Swing `setStringPainted`/`setString`, GTK `show_text`/`set_text`) or a printf
1816
+ template (Qt `setFormat("%p%")`). Nobody ships a closure.
1817
+
1818
+ **Re-grow rule.** If text-on-bar ever earns its way in, it arrives as
1819
+ `label = ->(bar) { … }` — a closure over the bar, `nil` for bare — mirroring
1820
+ `ComboBox#item_label`. Never an enum (fuses a mode with literal text in one
1821
+ slot), never a Qt-style template string, and never a rich context object: a
1822
+ `ProgressValue` exposing `percent` / `value_slash_max` was considered and
1823
+ rejected as a whole new public type (rdoc + `sig` + spec) to shorten a
1824
+ 25-character interpolation. The honest cost of the decision, so a revisit has
1825
+ something to weigh: **an overlay cannot be composed on a TTY** — there are no
1826
+ overlapping tiled components, so a sibling label always takes its own row. A
1827
+ bar in a `Window`'s bottom border (`window.footer = bar`, which already works)
1828
+ therefore has nowhere to put one, and stays bare.
1829
+
1830
+ **Decision — one atomic `range=`, no `min=` / `max=` writers.** *Any* pairwise
1831
+ validation makes two setters order-dependent, rejecting an intermediate state
1832
+ the app never intended: `bar.min = 10` raises while `max` is still the default
1833
+ `1.0`, and writing the two lines the other way round works. That is a coin-flip
1834
+ API, which is why Swing and GTK both ship an atomic `setRange`. One writer means
1835
+ the invalid intermediate state cannot exist. (Re-adding the pair would break
1836
+ nothing a spec asserts — hence this note.)
1837
+
1838
+ **Decision — `min == max` is legal and reads as complete.** Only `max < min`
1839
+ raises. A zero-length job has nothing outstanding — the vacuous truth that makes
1840
+ `[].all?` true — so `bar.range = 0..files.size` needs no special case for an
1841
+ empty list. Raising there would blow up an app during setup for having no work
1842
+ to do; painting an empty bar forever would be the other wrong answer. Callers
1843
+ split cleanly: unknown total → `indeterminate = true`; zero total → a full bar;
1844
+ nonsense total → `ArgumentError` at the call site that got it wrong. Non-finite
1845
+ endpoints are refused for the same reason — `0..Float::INFINITY` would paint
1846
+ 0 % forever, and that caller wanted indeterminate mode.
1847
+
1848
+ **Decision — indeterminate mode animates itself, at a rate that is not a knob.**
1849
+ The ticker's lifetime is *synced from an invariant* rather than toggled by the
1850
+ attach hooks (see AGENTS.md, which owns that rule as a general one). The frame
1851
+ rate is a constant: an `indeterminate_fps=` setter would need a force-restart
1852
+ punched through `sync_ticker`'s idempotence check — a second writer of
1853
+ `@ticker`, which is the invariant the design rests on. If it is ever needed, add
1854
+ it as cancel-then-sync and keep `sync_ticker` the sole starter. Rejected with
1855
+ it: an app-driven `pulse`, which existed only to dodge the pre-hooks lifecycle
1856
+ gap and would have been a second way to animate one widget.
1857
+
1858
+ **Consequences.** `fraction` and `percent` are load-bearing public API rather
1859
+ than sugar, since the composed label is what reads them — which is why both
1860
+ scale through one helper with exact endpoints (a full bar means done, and
1861
+ anything above zero lights a cell). And the bar is the first *animated*
1862
+ component, which is what turned an ordinary `super` in `repaint` into a
1863
+ measurable wire-traffic bug; AGENTS.md carries the resulting rule.
1864
+
1865
+ ---
1866
+
1867
+ ## D-cluster-caret — The caret is boundary-locked; edits step by cluster (2026-08-02)
1868
+
1869
+ **Status:** Accepted; implemented 2026-08-02 in `AbstractStringField`, so it
1870
+ landed on `TextField`, `PasswordField` and `TextArea` at once. Closes the gap
1871
+ `D-text-field-axes` / `D-text-area-columns` / `D-cluster-width` each recorded as
1872
+ open.
1873
+
1874
+ **Context.** `@caret` indexed **codepoints** while the terminal draws **grapheme
1875
+ clusters**, and every edit stepped by one codepoint. Three symptoms, all
1876
+ reachable by *typing* (`Keys.printable?` admits combining marks, regional
1877
+ indicators, variation selectors and skin-tone modifiers):
1878
+
1879
+ | symptom | evidence | operation at fault |
1880
+ |---|---|---|
1881
+ | RIGHT stalls | decomposed `"éx"`, 3× RIGHT → columns `[0, 1, 1, 2]` | LEFT/RIGHT |
1882
+ | BACKSPACE mutilates | `"é"` → `"e"` — a valid, *wrong* letter; `"🇯🇵"` → `"🇯"` | `delete_before_caret` |
1883
+ | DELETE orphans | `"é"` caret 0 + DELETE → a lone U+0301: not `empty?`, paints as `""` | `delete_at_caret` |
1884
+
1885
+ That right-hand column is the whole finding: **only movement and deletion were
1886
+ wrong.** Insertion was already right (`String#insert` merges a typed combining
1887
+ mark into its base for free), painting was already cluster-native, and every
1888
+ index↔column conversion already walked clusters after the three decisions above.
1889
+
1890
+ **Decision — keep `caret` in character space; teach four operations about
1891
+ clusters.** LEFT/RIGHT move to the adjacent cluster boundary; BACKSPACE and
1892
+ DELETE remove a whole cluster. Three private single-walk primitives on
1893
+ `AbstractStringField` (`snap_to_cluster`, `cluster_boundary_before`,
1894
+ `cluster_boundary_after`) — no cache, no new state, no invalidation rule.
1895
+
1896
+ **Decision — snap at both write sites, making a mid-cluster caret
1897
+ unrepresentable.** `caret=` and `text=`'s clamp both snap to the smallest
1898
+ boundary `>= index`, so *the caret is always on a cluster boundary* is a real
1899
+ invariant with exactly two enforcement points. Snapping **forward** is
1900
+ display-preserving: `column_at` already measured a mid-cluster index as the
1901
+ whole cluster, so the snap moves nothing on screen. Consequence: the movement
1902
+ and deletion helpers may assume a boundary caret and carry no snap step, and the
1903
+ DELETE-orphan bug is unreachable rather than patched.
1904
+
1905
+ Both sites are load-bearing. `text=` is not redundant: typing a regional
1906
+ indicator *ahead of* an existing flag re-segments the neighborhood, so `insert`'s
1907
+ `@caret += 1` lands inside a cluster of the **new** text — only the `text=` snap
1908
+ can catch that. Pinned by "snaps the caret when the insertion re-segments its
1909
+ neighborhood".
1910
+
1911
+ **Decision — deletion is uniformly whole-cluster, with no per-script rules.**
1912
+ Unicode defines cluster boundaries (UAX #29) but not what Backspace means, and
1913
+ editors diverge: a ZWJ family may shed one member per press, and most Korean
1914
+ IMEs delete the last *jamo* rather than the syllable. Tuile deletes the whole
1915
+ cluster in every case. The cost is real and accepted — a Korean typist loses
1916
+ "one press, one jamo" — but per-script deletion would put a table of exceptions
1917
+ back into a design whose entire value is not having one, and it is exactly what
1918
+ makes the orphan bug unreachable.
1919
+
1920
+ **Alternatives rejected.**
1921
+
1922
+ - **Reinterpret `caret` as an index into a cached boundary table** (one row per
1923
+ cluster carrying `{offset:, column:}`; stepping becomes `± 1`). The original
1924
+ design, parked 2026-07-31 and rejected on implementation. It pays globally to
1925
+ fix four methods, and the snap above recovers its one real guarantee for five
1926
+ lines. Three concrete costs: (1) **it moves the axis, so every
1927
+ `caret = <something>.length` breaks silently** — five sites in `lib/` plus
1928
+ `examples/sampler.rb`'s `area.caret = start + command.length + 1`, all correct
1929
+ for ASCII and wrong otherwise, which is the failure mode `D-text-field-axes`
1930
+ deleted, relocated from the framework to its callers; it then forced an open
1931
+ question about a loud rename migration purely to convert those silent breaks
1932
+ into `NoMethodError`s. (2) `max_text_length` would silently change meaning,
1933
+ characters → clusters. (3) It adds a second invalidated cache to a class that
1934
+ already carries one (`TextArea`'s `@display_rows`), for state a per-keystroke
1935
+ walk recomputes in 62µs.
1936
+ - **Store an `Array` of clusters instead of a `String`.** Insertion is where
1937
+ cluster-native storage bites back: typing a combining mark after `e` would
1938
+ yield `["e", "◌́"]` — two clusters, the second a lone mark painting as nothing
1939
+ — so every keystroke would re-segment its neighborhood. **String storage gets
1940
+ insertion right and stepping wrong; cluster storage inverts exactly that.**
1941
+ - **Snap backward, to the enclosing cluster's start.** Would move the cursor on
1942
+ screen, since a mid-cluster index already displayed past its cluster.
1943
+ - **Tolerate mid-cluster carets and snap only inside the edit operations.** The
1944
+ cheapest version, and what the four operations would need anyway. Rejected for
1945
+ the two write-site lines: an invariant enforced once beats a tolerance
1946
+ repeated at every reader, and `caret=` already adjusts by clamping, so
1947
+ snapping there is not a new kind of surprise.
1948
+ - **Move `max_text_length` to counting clusters** alongside this. Deliberately
1949
+ not bundled: it stays character-counting and stays `D-text-field-axes`'s
1950
+ decision. Now a knowing choice rather than an untouched default — a decomposed
1951
+ `é` burns 2 of 10, and a field at its cap refuses an accent on its last letter
1952
+ because `insert`'s check fires before the mark can merge.
1953
+
1954
+ **Consequences.** ASCII behavior is bit-identical, so this is not a breaking
1955
+ change in practice; for non-ASCII the visible differences are the three bug
1956
+ fixes plus `caret=` reading back snapped. `TextArea` needed no changes at all —
1957
+ its row records keep character offsets and `chars_for_column` /
1958
+ `caret_to_display` already return boundary-aligned counts — so the two-commit
1959
+ plan the parked note assumed collapsed to one. Still out of scope and unfixed: a
1960
+ lone combining mark remains constructible via `text=` or by typing a mark into
1961
+ an empty field, which is input validation, not an axis question.