tuile 0.9.0 → 0.11.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +81 -36
  3. data/DECISIONS.md +2566 -0
  4. data/README.md +37 -24
  5. data/book/03-layout.md +153 -8
  6. data/book/04-event-loop.md +86 -0
  7. data/book/05-focus.md +84 -51
  8. data/book/06-theming.md +53 -1
  9. data/book/07-components.md +458 -9
  10. data/book/08-testing.md +21 -8
  11. data/book/09-styled-text.md +132 -0
  12. data/book/README.md +22 -14
  13. data/examples/sampler.rb +632 -67
  14. data/ideas/arrow-key-navigation.md +205 -0
  15. data/ideas/new-components.md +118 -0
  16. data/ideas/per-component-buffers.md +55 -0
  17. data/lib/tuile/buffer.rb +52 -51
  18. data/lib/tuile/color.rb +4 -10
  19. data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
  20. data/lib/tuile/component/big_decimal_field.rb +199 -0
  21. data/lib/tuile/component/button.rb +25 -21
  22. data/lib/tuile/component/checkbox.rb +134 -0
  23. data/lib/tuile/component/checkbox_group.rb +188 -0
  24. data/lib/tuile/component/combo_box.rb +263 -0
  25. data/lib/tuile/component/float_field.rb +161 -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/integer_field.rb +135 -0
  30. data/lib/tuile/component/label.rb +13 -10
  31. data/lib/tuile/component/layout/box.rb +316 -0
  32. data/lib/tuile/component/layout/horizontal.rb +40 -0
  33. data/lib/tuile/component/layout/vertical.rb +41 -0
  34. data/lib/tuile/component/layout.rb +149 -15
  35. data/lib/tuile/component/list.rb +8 -7
  36. data/lib/tuile/component/list_dropdown.rb +157 -0
  37. data/lib/tuile/component/password_field.rb +105 -0
  38. data/lib/tuile/component/popup.rb +10 -14
  39. data/lib/tuile/component/progress_bar.rb +278 -0
  40. data/lib/tuile/component/radio_group.rb +188 -0
  41. data/lib/tuile/component/select.rb +251 -0
  42. data/lib/tuile/component/text_area.rb +189 -65
  43. data/lib/tuile/component/text_field.rb +170 -32
  44. data/lib/tuile/component/text_view.rb +57 -114
  45. data/lib/tuile/component/window.rb +32 -47
  46. data/lib/tuile/component.rb +250 -87
  47. data/lib/tuile/event_queue.rb +14 -17
  48. data/lib/tuile/fake_event_queue.rb +11 -2
  49. data/lib/tuile/fake_screen.rb +4 -5
  50. data/lib/tuile/fraction.rb +6 -10
  51. data/lib/tuile/screen.rb +202 -104
  52. data/lib/tuile/screen_pane.rb +51 -41
  53. data/lib/tuile/styled_string.rb +125 -86
  54. data/lib/tuile/theme.rb +78 -41
  55. data/lib/tuile/version.rb +1 -1
  56. data/lib/tuile.rb +4 -0
  57. data/sig/tuile.rbs +2962 -680
  58. metadata +25 -7
data/DECISIONS.md ADDED
@@ -0,0 +1,2566 @@
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 and Enter both toggle** (Enter added 2026-08-03; unclaimed through
789
+ 0.10.0). Space-to-flip is the native gesture (Vaadin's checkbox is Space-only),
790
+ and the original ruling left Enter alone on the grounds that claiming a key you
791
+ don't need is the irreversible direction. What tipped it is *consistency with
792
+ the group components*: a checkable row inside a `List` toggles on Enter, since
793
+ Enter is `List`'s own choose-the-item-under-the-cursor gesture
794
+ (`D-checkbox-group`, `D-radio-group`). So `[ ] Verbose` flipped on Enter when
795
+ it sat in a `CheckboxGroup` and did nothing when it sat alone in a form — a
796
+ distinction the user cannot see, and one that reads as a bug in the standalone
797
+ widget rather than as restraint. One gesture set, both shapes, is worth more
798
+ than the option value of a key a checkbox has no other use for.
799
+ The consequence is explicit and accepted: a focused checkbox now **consumes**
800
+ Enter, so an ancestor's Enter-to-submit does not see it. That was never
801
+ promised — no widget owes it (`TextArea` claims Enter for newline, `Button` to
802
+ activate itself), and book ch5's Enter table states it per widget precisely
803
+ because it is per widget (see the rejected reservation below, which is why the
804
+ promise doesn't exist to break). An app wanting Enter-anywhere-submits binds it
805
+ on the ancestor *and* accepts that its focusable widgets each get first refusal.
806
+ Now that Enter is claimed, taking it back is the breaking direction — don't.
807
+ - **No constructor block, but a `value:` kwarg.** `Button.new(caption,
808
+ &on_click)` and `PickerWindow` are the gem's only ctor blocks, and both exist
809
+ to *produce one outcome* — the callback is mandatory in practice. A checkbox
810
+ exists to *hold* state and a form usually attaches no listener at all, so a
811
+ ctor slot for `on_value_change` would privilege the exception. `value:` earns
812
+ its slot instead: it *is* achievable post-hoc (assign before wiring the
813
+ listener and nothing fires), but that silently depends on assignment order a
814
+ form helper may not control. It also seeds the backing ivar — unseeded,
815
+ `HasValue#value`'s bare reader would return `nil`, making a fresh checkbox
816
+ report itself non-empty. Same ruling for the rest of the field batch.
817
+ - **The extent is one number, used by both the highlight and the hit test:**
818
+ `min(caption.display_width + 4, rect.width)` columns, one row. A form column
819
+ routinely hands a field 40 columns for a 22-column widget. Two consequences:
820
+ the painted glyph is the affordance, so a click on the blank tail doesn't
821
+ toggle (it still *focuses* — `Component#handle_mouse`'s click-to-focus is
822
+ ungated by geometry, and the tail is the field's own row); and a 40-column
823
+ highlight band would read as a selected *row*, the wrong signal for one field
824
+ in a column of ten. **`Button#handle_mouse` was narrowed to the same rule in
825
+ the same commit** — the ruling is cross-component, and leaving Button on
826
+ `rect.contains?` would re-split it. Clipping is *not* a third consumer:
827
+ `ellipsize(rect.width)` already equals `ellipsize(extent.width)` in both
828
+ directions.
829
+ **The rule is scoped to a *standalone* one-row field.** A checkable row
830
+ *inside a list* hit-tests its full width instead (`D-checkbox-group`), and the
831
+ difference is perceptual rather than a relaxation of rigor: with a cursor
832
+ visible and ten rows stacked, the unit the user aims at is a **row**, and a
833
+ row's affordance is its whole width — which is what `List`'s row-wide cursor
834
+ highlight already advertises. A lone `[ ] Enable syslog` in a 40-column form
835
+ cell advertises nothing of the sort. The **vertical** half is not relaxed even
836
+ there, and comes free: `List#handle_mouse` fires `on_item_chosen` only for
837
+ `line < @lines.size` (`list.rb:264`), so a click below the last row toggles
838
+ nothing. The two axes therefore differ by *reason* — horizontal is
839
+ row-affordance, vertical is still don't-activate-what-isn't-painted — which is
840
+ the distinction to preserve if a third checkable-row consumer appears.
841
+ - **ASCII `[x] `/`[ ] ` glyphs, as a documented convention rather than
842
+ constants.** Not a width ruling — U+2610..U+2613 are EAW-**Neutral**, so
843
+ every `wcwidth` agrees they're one cell. They lose on **font coverage**
844
+ (absent from most monospace fonts, and `☐` is the worse-covered of the pair,
845
+ so the two states degrade *asymmetrically* to tofu — checked renders,
846
+ unchecked doesn't, which reads as a bug rather than a fallback) and on **ink
847
+ overflow** (the fallback glyph is drawn wider than its cell in Alacritty —
848
+ cosmetic, coordinates stay correct; see `D-ambiguous-width` for why that's a
849
+ different problem). Locally, three columns is also a bigger click target that
850
+ survives a monochrome terminal, and keeps `region_text` assertions ASCII.
851
+
852
+ **Alternatives rejected.**
853
+ - *Reserve Enter as "the form-submit key" — i.e. have the checkbox promise to
854
+ decline it so an ancestor's default button always sees it:* tempting, and it
855
+ is what this entry originally claimed, but it's a single component
856
+ guaranteeing a framework-wide property the framework doesn't have —
857
+ `TextArea` and `Button` both claim Enter. Worse, it prices in a real cost
858
+ elsewhere: `List#handle_key` claims Enter whenever its cursor is on an item
859
+ (`list.rb:209`) *regardless of whether `on_item_chosen` is set*, so honoring
860
+ the promise in `CheckboxGroup` would have forced it onto the
861
+ `ListDropdown::Menu` shape — a non-focusable `List` subclass plus
862
+ hand-forwarded movement keys — to protect a guarantee nothing relied on
863
+ (`D-checkbox-group`). Enter-reaches-your-form is a per-assembly property the
864
+ app verifies for its own focusable widgets, not a framework invariant. Still
865
+ rejected, and now moot in both directions: the standalone widget claims Enter
866
+ too, which is what made the two shapes agree.
867
+ - *Hit-test the whole `rect`:* activates clicks that visibly land on nothing,
868
+ and `Rect#contains?` spans every row, so a click two rows below a visible
869
+ `[ ]` would toggle it. Vaadin agrees — a 100%-wide checkbox ignores clicks
870
+ right of its label. (Rejected *for a standalone field*. The second clause is
871
+ the durable one: the row-scoped carve-out above widens the target
872
+ horizontally, never past the last painted row.)
873
+ - *Let the extent follow `bg_color`:* with a tint the dead tail is visibly
874
+ painted, so the hit test arguably should widen. It must not: a target that
875
+ silently changes when an ancestor gains a background is an invisible mode
876
+ switch, untestable by inspection and unpredictable for the reader. One rule,
877
+ always.
878
+ - *`Component#extent` as a framework seam:* nothing generic consults it, and
879
+ each widget's arithmetic is its own. Two one-line methods beat a speculative
880
+ base-class hook (the `cop` duplicate-rather-than-fold rule).
881
+ - *Public `Checkbox::CHECKED`/`UNCHECKED` constants:* would publish a seam
882
+ before a consumer needs one — `CheckboxGroup` renders its own rows over a
883
+ `List` and never instantiates a Checkbox, so a reference would read as a
884
+ dependency that isn't there, and a future `glyphs=` knob would demote the
885
+ constant to merely *a* default. Drift between the copies surfaces as a
886
+ `region_text` spec mismatch, not a silent bug, and promoting a literal to a
887
+ constant later is additive.
888
+ - *`☑`/`☐` by default:* above. Available later as an opt-in `glyphs=` for
889
+ someone who has picked a font with a proper box.
890
+ - *A `keyboard_hint` override advertising "space toggle":* hints are a
891
+ window/popup-level affordance; per-field hints would drown the status bar.
892
+ (`Screen#refresh_status_bar` can't even reach a leaf field — it consults the
893
+ active `Window` or the top popup's *direct* content.)
894
+ - *A read-only flag:* parked with the rest of the forms-layer axes by
895
+ `D-has-value`.
896
+
897
+ **Tri-state (indeterminate) — settled, not built.** When it lands it adopts
898
+ **Vaadin's orthogonal flag**: `indeterminate`/`indeterminate=` as a plain
899
+ display override painting `[-] `, with `value` staying boolean. That is what
900
+ keeps the question decoupled — `empty_value == false`, the boolean coercion,
901
+ `checked? == (value == true)` and a group's set arithmetic all survive, and it
902
+ models the use case correctly (mixed is a *reflection* of children; a parent
903
+ over a partially-selected group has no boolean of its own). Two deviations from
904
+ Vaadin: **any statement about the value clears the flag** (`value=`, `toggle`,
905
+ `clear`, Space, click), so `checked && indeterminate` — representable and
906
+ meaningless in Vaadin, which is why its own group-header example must set both
907
+ properties in every branch — is unrepresentable here; and if the flag ever
908
+ needs observing it gets a plain `on_indeterminate_change`, not a second channel
909
+ on the value seam. Rejected: a **`nil`-able `value`** (breaks all four
910
+ properties above) and a separate **`TriStateCheckbox`** class (duplicates the
911
+ whole single-row shell for one flag). Also not auto-wired to `CheckboxGroup` —
912
+ which children a header governs, and whether checking it selects all, is app
913
+ policy.
914
+
915
+ Four details for whoever builds it. **The flag is computed, never typed:**
916
+ nothing lets a *user* enter mixed, and Space or a click *from* mixed lands on
917
+ **checked** — clear the flag, then toggle, firing `on_value_change` once (the
918
+ HTML activation steps; Vaadin inherits them). **Put the clearing in the
919
+ `value=` override**, not in each caller — that is precisely why `checked=` and
920
+ `toggle` are delegators rather than aliases, and an alias here would silently
921
+ skip it. **`empty?` ignores the flag** (a mixed box still reports empty:
922
+ harmless, but worth one rdoc word). **`on_theme_changed` is untouched** — the
923
+ marker is live-resolved chrome like every other built-in accent.
924
+
925
+ Deferred because the use case (a partially-checked tree parent) has no home in
926
+ Tuile today. Its first plausible consumer would be a `CheckboxGroup` header row
927
+ — which `D-checkbox-group` declined to build, leaving this unbuilt too; that
928
+ entry names the forcing function to watch for.
929
+
930
+ ---
931
+
932
+ ## D-checkbox-group — `CheckboxGroup`: a field composing a `List`, `Set`-valued (2026-07-30)
933
+
934
+ **Status:** Accepted; `Component::CheckboxGroup` implemented 2026-07-30, demoed
935
+ in the sampler. Builds on `D-has-value`, `D-combobox` (the chrome/value split it
936
+ generalizes), `D-integer-field` (the composed-field taxonomy it extends) and
937
+ `D-boolean-fields` (the glyphs, and the two rulings it scopes).
938
+
939
+ **Context.** Multi-select from a handful of typed items, one `[x] label` row
940
+ each. The cursor and the selection are genuinely two pieces of state here —
941
+ which is exactly the shape `List` already implements, so the question was how
942
+ much of `List` to reuse and what the value should be. (A single-select group
943
+ *could* have conflated them, and `D-radio-group` records why it doesn't.)
944
+
945
+ **Decision.**
946
+ - **Compose a plain `List`, unmodified.** `CheckboxGroup` holds one as its single
947
+ `HasContent` child, which supplies the cursor, scrolling, the scrollbar and
948
+ per-row hit-testing. The group's own code is four lines of wiring: rebuild
949
+ `lines=` on any change to items/labels/selection, claim **Space** in
950
+ `handle_key`, and toggle from `on_item_chosen`. That one callback covers Enter
951
+ *and* click (`list.rb:209` and `:264`), so there is no `handle_mouse` override
952
+ at all. This **extends `D-integer-field`'s taxonomy** from "a typed field
953
+ composes a `TextField`" to "a typed field composes whatever widget already has
954
+ the interaction" — the tab stop lives on the inner widget, the wrapper is not
955
+ one, exactly as for `ComboBox`.
956
+ - **`value` is a frozen `Set` of the selected items**, of whatever type `items`
957
+ holds. Frozen for a reason that is not tidiness: `HasValue#value=` opens with
958
+ `return if value == new_value`, so a selection mutated *in place* and
959
+ re-assigned would compare equal to itself and **silently swallow the change
960
+ event**. Freezing makes `cg.value << item` raise instead, and internally
961
+ `Set#+`/`#-` return new sets, so no in-place path exists to begin with.
962
+ - **`value=` coerces any `Enumerable` to a frozen copy *before* delegating.**
963
+ Coercing after the inherited no-op guard would have it comparing an `Array` to
964
+ a `Set`, finding them unequal, and firing spuriously on `value = value.to_a`.
965
+ The copy also means a caller's set can't reach in afterwards. `nil` means "select
966
+ nothing" and `empty_value` is a frozen empty `Set`.
967
+ - **The set's contract is *unordered*.** Ruby's `Set` is Hash-backed and so
968
+ iterates in insertion order, and a delete-then-re-add moves an element to the
969
+ end — i.e. the observable order is the user's *toggle history*. Documented as
970
+ unordered so nobody builds on that; `items & value.to_a` is the idiom for
971
+ items order, and the sampler pane uses it visibly.
972
+ - **Items are chrome (`D-combobox`), so `items=` never touches `value`** and never
973
+ fires `on_value_change`. A selected item absent from `items` renders no checked
974
+ row and survives intact.
975
+ - **Two `D-boolean-fields` rulings are scoped, not broken.** A click anywhere on
976
+ a row toggles it (a row's affordance is its full width, which its cursor
977
+ highlight already advertises) while a *standalone* checkbox still ignores its
978
+ blank tail; and Enter toggles here because that is `List`'s choose gesture. The
979
+ vertical half of the hit-test ruling survives untouched — `List` fires
980
+ `on_item_chosen` only for `line < @lines.size`, so a click below the last row
981
+ toggles nothing.
982
+ - **No header row, no tri-state, no select-all.** A header is the only plausible
983
+ consumer of `D-boolean-fields`' settled-but-unbuilt `indeterminate` flag, and
984
+ it is also where every policy question lives: which children it governs,
985
+ whether checking it selects all, one change event or N, whether it scrolls with
986
+ the rows. That entry already rules a header *app policy*, so building one here
987
+ would mean inventing that policy with no consumer. Select-all likewise gets no
988
+ key (`Ctrl+D` is a `List` scroll key, `Ctrl+A` is HOME-ish in readline terms)
989
+ and no chrome; `cg.value = cg.items` is the app's one-liner. **Forcing
990
+ function:** if the sampler pane ever wants an "All" row, build the flag then
991
+ and keep the header app-composed there — that demonstrates the app-policy
992
+ claim on one real case instead of asserting it for all of them.
993
+
994
+ **Alternatives rejected.**
995
+ - *Store selected **indices** (a `Set<Integer>`) and map to items on read:* the
996
+ first design, and it forces a reconcile policy onto `items=` that has no good
997
+ answer. All three candidates lose: *clamp* silently reinterprets a selection as
998
+ whatever now occupies that index; *re-map by `==`* is the honest one but still
999
+ can't preserve intent across duplicates and must decide whether to fire; *clear*
1000
+ discards the user's work when items merely gained a row. Storing items deletes
1001
+ the question rather than answering it — see `D-combobox`'s matching rejection.
1002
+ - *The `ListDropdown::Menu` shape — a non-focusable `List` subclass, focus on the
1003
+ wrapper, movement keys hand-forwarded:* the design forced by taking Enter away
1004
+ from the list. Correct, and about 15 lines of forwarding plus a subclass, all
1005
+ to protect a promise nothing relied on (see `D-boolean-fields`' rejected Enter
1006
+ reservation). Reach for it only if a driver genuinely needs Enter for itself.
1007
+ - *Paint the rows directly (`< Component`, `draw_line` per row):* wrong here.
1008
+ The cursor-distinct-from-selection structure *is* `List`, a checkbox group is
1009
+ the one most likely to be long enough to scroll, and painting rows means
1010
+ re-implementing the cursor, the viewport, the scrollbar and the mouse
1011
+ arithmetic. This was left explicitly open for a radio group, on the grounds
1012
+ that three rows and a selection-follows-cursor model would need almost none of
1013
+ it; `D-radio-group` then closed it the same way, because dropping that model
1014
+ removed the friction that made painting attractive.
1015
+ - *An `Array`-valued `value` in `items` order:* would make ordering meaningful and
1016
+ so make it a contract to maintain, plus `==` would then treat two identical
1017
+ selections as different when toggled in a different order — breaking the
1018
+ seam's no-op detection.
1019
+ - *A shared base with `RadioGroup`/`MultiSelectComboBox`:* speculative folding of
1020
+ shallow commonality. The set bookkeeping is small enough to duplicate when the
1021
+ multi-select combo lands, and it inherits the chrome/value rule for free
1022
+ because that rule is `ComboBox`'s already (the `cop` duplicate-rather-than-fold
1023
+ rule).
1024
+ - *Public `CHECKED`/`UNCHECKED` glyph constants shared with `Checkbox`:* declined
1025
+ again here for the reason `D-boolean-fields` gives — the group paints its own
1026
+ rows and never instantiates a `Checkbox`, so importing a constant would read as
1027
+ a dependency that isn't there. Drift between the two copies surfaces as a
1028
+ `region_text` mismatch, not a silent bug.
1029
+
1030
+ **Consequences a contributor will trip over.** A bare `List` has **no cursor** —
1031
+ `Cursor::None` at position `-1` — so a future `List`-composer must install
1032
+ `List::Cursor.new` or arrows, Enter and the row highlight are all silently dead.
1033
+ `List` also pads a **one-column gutter**, so rows paint at `rect.left + 1`; that
1034
+ offset is baked into the spec's `region_text` assertions and the rdoc's example.
1035
+ Items need stable `#hash`/`#eql?` (a `Set`), so an item mutated after selection
1036
+ becomes unfindable — accepted, and the same constraint Vaadin's `HashSet`-backed
1037
+ group carries. Two `==`-equal items therefore share one selection and their rows
1038
+ toggle together, while two *distinct* items rendering the same label stay
1039
+ independent.
1040
+
1041
+ ---
1042
+
1043
+ ## D-radio-group — `RadioGroup`: cursor roams, selection commits; the cursor is chrome (2026-07-31)
1044
+
1045
+ **Status:** Accepted; `Component::RadioGroup` implemented 2026-07-31, demoed in
1046
+ the sampler. Builds on `D-has-value`, `D-combobox` (the chrome/value split),
1047
+ `D-integer-field` (the composed-field taxonomy), `D-checkbox-group` (the
1048
+ `List`-composing shape it copies) and `D-ambiguous-width` (the glyphs). Most of
1049
+ this component was settled by those five; what it owns is the **interaction
1050
+ model**, which reverses both the desktop convention and this note's own first
1051
+ design.
1052
+
1053
+ **Context.** Single-select from a handful of typed items, one `(*) label` row
1054
+ each — `ComboBox`'s job when the set is small enough to show at once. Every
1055
+ graphical radio group ever built (HTML, Vaadin, Windows dialogs, GTK) moves the
1056
+ *selection* with the arrow keys: focus and choice are one thing, and Down means
1057
+ "I have now chosen the next option." This component's design note originally
1058
+ adopted that, on the strength of the convention, and called it "the one real
1059
+ design call."
1060
+
1061
+ **Decision — the cursor roams; Space, Enter or a click selects.** Cursor and
1062
+ selection are two pieces of state, exactly as in `CheckboxGroup`. Two reasons:
1063
+
1064
+ - **Framework consistency.** "A cursor roams, Enter chooses" is the idiom in
1065
+ `List`, `ListDropdown`, `PickerWindow` and `CheckboxGroup`. Two group widgets
1066
+ one Tab apart in the same form must not answer Down differently, and the
1067
+ convention being imported is a *GUI* convention — a TUI has no per-row focus
1068
+ ring to make it read naturally.
1069
+ - **Selection-follows-arrows fires `on_value_change` once per row traversed.**
1070
+ Arrowing from row 1 to row 5 fires four times, so a listener that resorts a
1071
+ pane, refetches a page or writes a config does that work four times, three of
1072
+ them for choices the user never made. HTML radio groups carry this wart and
1073
+ apps debounce around it. This is the argument that decides it; consistency
1074
+ alone would have been a preference.
1075
+
1076
+ **Decision — the cursor is *chrome*.** It joins `items` on the presentation
1077
+ side of the chrome/value split, which makes the independence symmetric:
1078
+ committing leaves the cursor alone, and `value=` (and the `value:` ctor kwarg)
1079
+ does **not** move it. This is not a new rule — it is what `CheckboxGroup`
1080
+ already does, unnamed, by installing a bare `List::Cursor.new` whatever the
1081
+ seeded value was; naming it is what stops `RadioGroup` diverging by accident.
1082
+ The `(*)` glyph carries the selection at all times, and the row highlight
1083
+ carries the cursor and correctly vanishes when the group goes inactive
1084
+ (`show_cursor_when_inactive` stays at its `false` default). An app that wants
1085
+ the cursor parked on the selection parks it through the public `content`.
1086
+
1087
+ **Decision — `items=` clamps the cursor**, the one place chrome touches chrome.
1088
+ Not tidiness: `List#lines=` deliberately leaves a stale cursor alone, so a
1089
+ shrinking `items=` strands it off-content (no highlight, dead Enter), and Space
1090
+ in that window resolves `items[stale]` to `nil` and *silently clears the
1091
+ selection*, firing `on_value_change(nil)`. The clamp goes through
1092
+ `Cursor#go_to_last`, mirroring `List`'s own one-sided-clamp idiom, so an empty
1093
+ list floors at 0 and a `Cursor::Limited` keeps its own notion of "last". The
1094
+ `index.between?` guard on the select path is still required — it covers
1095
+ `Cursor::None` — which is what `CheckboxGroup` survives on today.
1096
+
1097
+ **Alternatives rejected.**
1098
+ - *Selection == cursor (the desktop convention), the first design:* above. Worth
1099
+ recording what it also dragged in, since each looked like an independent
1100
+ problem at the time: an `on_cursor_changed` → `value=` → `lines=` →
1101
+ `notify_cursor_changed` re-entrancy loop terminated only by `HasValue`'s no-op
1102
+ guard; `List`'s PgUp/PgDn moving the viewport rather than the cursor, which
1103
+ scrolls the selection off-screen; Enter swallowed by the inner list for no
1104
+ gain; and `show_cursor_when_inactive` needing to be flipped so an unfocused
1105
+ group still showed its selection. Four frictions, one cause — they evaporated
1106
+ together when the models split, which is the tell that the model was wrong
1107
+ rather than the framework awkward.
1108
+ - *Park the cursor on the selected row on `value=`:* the intuitive nicety, and
1109
+ the reason to decline it is that it is *asymmetric* — a programmatic write
1110
+ moving a piece of user-facing navigation state. It also does not scroll into
1111
+ view (`move_viewport_to_cursor` is private to `List`'s own key/mouse paths),
1112
+ so on a scrolling group it parks the cursor off-screen. Left to the app.
1113
+ - *Paint the rows directly (`< Component` + `draw_line`), the fallback the idea
1114
+ note held open:* it existed to escape the four frictions above, which the
1115
+ interaction model removes. Composing a `List` then costs nothing and keeps the
1116
+ cursor, viewport, scrollbar and mouse arithmetic in one place.
1117
+ - *A `glyphs=` knob for `(•)`:* `D-ambiguous-width` blesses an opt-in knob but
1118
+ doesn't demand one, and `Checkbox`/`CheckboxGroup` both ship literals. Adding
1119
+ it here alone would create symmetry pressure for a third. Ship `(*)`/`( )`;
1120
+ add the knob to all three the day someone wants the bullet.
1121
+ - *A shared base with `CheckboxGroup`:* declined for the third time (see
1122
+ `D-checkbox-group`). The two differ in exactly one line — `Set` membership vs
1123
+ `==` — and the `cop` duplicate-rather-than-fold rule covers the rest.
1124
+
1125
+ **Consequences.** Space on the already-selected row is a no-op, not a deselect:
1126
+ `value=`'s no-op guard swallows it, so `nil` is reachable only programmatically
1127
+ — an app wanting "none" gives it a row. Two `==`-equal items share one
1128
+ selection and *both* rows render `(*)`, while two distinct items sharing a label
1129
+ stay independent (a row resolves to an item by index). The sampler pane reports
1130
+ value and cursor side by side, which is the cheapest way to see the split.
1131
+
1132
+ ## D-text-field-axes — `TextField`: two axes, horizontal scrolling, a logical cap (2026-07-31)
1133
+
1134
+ **Status:** Accepted; `Component::TextField` rewritten 2026-07-31. Builds on
1135
+ `D-ambiguous-width` (which already asserted that "every rect, caret column and
1136
+ clip derives from `StyledString#display_width`" — a claim `TextField` was quietly
1137
+ violating). Scoped to `TextField`; `TextArea` carries the same bug and is *not*
1138
+ fixed here.
1139
+
1140
+ **Context.** `TextField` treated its caret index and its terminal column as one
1141
+ number. That is correct for ASCII and wrong for everything else, and it failed in
1142
+ four separate places at once: the hardware cursor landed at `rect.left + caret`
1143
+ (with `"日本語"` and the caret at the end, column 3 — the middle of the second
1144
+ glyph — instead of column 6); `repaint` padded with `rect.width - text.length`
1145
+ spaces, so the field's background well overran its rect by one column per wide
1146
+ glyph (columns 0..12 of a 10-wide field, breaking the never-draw-outside-your-rect
1147
+ invariant); the capacity check counted characters against a column budget, so a
1148
+ 10-wide field accepted 18 columns of CJK; and a mouse click mapped its column
1149
+ straight onto a character index, misplacing the caret from the second glyph on.
1150
+ Combining marks broke the same conversions from the other side — a decomposed
1151
+ `"é"` is two characters and one column.
1152
+
1153
+ **Decision — name the two axes and convert explicitly.** An **index** counts
1154
+ characters into `text` (the axis of `caret`, `max_text_length`, every edit); a
1155
+ **column** counts terminal cells (the axis of `rect`, `left_column`,
1156
+ `cursor_position`, `MouseEvent`). Every crossing goes through one private pair,
1157
+ `column_at(index)` / `index_at(column)`; the class rdoc states that adding an
1158
+ index to a column anywhere else is the bug they exist to prevent. Keeping the
1159
+ caret on the index axis was never in question — edits, word jumps and
1160
+ `text[i]` all want it — so the fix is the *missing conversion*, not a
1161
+ redefinition.
1162
+
1163
+ **Decision — scroll horizontally instead of capping to the width.** `left_column`
1164
+ follows the caret by the minimum needed, mirroring `TextArea#top_display_row`.
1165
+ This deletes the width-derived capacity rule rather than fixing its arithmetic:
1166
+ the old `rect.width - 1` cap existed to reserve a column for the caret parked
1167
+ past the last glyph, and that reservation now lives in the scroll clamp
1168
+ (`text_columns - rect.width + 1`) where it belongs. Consequence: `text=` no
1169
+ longer silently trims, and a printable key is now *always* consumed — previously
1170
+ a full field let typing fall through to a scope-wide binding, contradicting the
1171
+ book's own claim that a focused field consumes every printable key.
1172
+
1173
+ **Decision — `left_column` snaps *forward* to a glyph boundary.** The window must
1174
+ never open on a wide glyph's right half. Forward is the only safe direction, and
1175
+ the reason is not "it shows more": the caret's own column is always a glyph
1176
+ boundary, so the next boundary at or after `left_column` cannot overshoot it.
1177
+ Snapping backward pulls the window's right edge inward and strands the caret
1178
+ outside it whenever wide glyphs exactly fill a narrow field (width 4, `"日本語"`,
1179
+ caret at end: the window becomes exactly `本語` with no column left for the
1180
+ caret). A glyph straddling the *right* edge is dropped and its cell padded, never
1181
+ half-painted.
1182
+
1183
+ **Decision — `max_text_length` returns as an app-set logical bound.** Optional
1184
+ (`nil` by default), counted **in characters** — a wide glyph counts once — and it
1185
+ gates *typing only*: at the cap a printable key does nothing and is still
1186
+ consumed. It deliberately does not police `text=`, which stays authoritative as
1187
+ it is for `ComboBox#value` and `CheckboxGroup#value` (`D-combobox`,
1188
+ `D-checkbox-group`), so lowering the cap under an existing value leaves that
1189
+ value intact instead of silently trimming it. A cap in *columns* was rejected: it
1190
+ would make the maximum text depend on which characters were typed, which is
1191
+ exactly the width-vs-length confusion this note removes.
1192
+
1193
+ **Alternatives rejected.**
1194
+
1195
+ - **Redefine `caret` as a column.** Every edit operation (`insert`, `slice!`,
1196
+ the word jumps in `AbstractStringField`) is index-native, so this pushes the
1197
+ conversion into more places rather than fewer, and the shared base would have
1198
+ to carry two meanings for one ivar.
1199
+ - **Fix the arithmetic but keep reject-on-overflow.** Cheaper, and it keeps a
1200
+ cap whose value silently depends on the user's script — a field that holds 9
1201
+ Latin characters and 4 CJK ones. Scrolling is what every real text input does.
1202
+ - **Grapheme-cluster caret stepping.** Out of scope here, and it is a change to
1203
+ `AbstractStringField` (arrows, backspace) that `TextArea` shares. The
1204
+ conversions tolerate a mid-cluster caret today by displaying it at the column
1205
+ just past the cluster, which is the direction the arrow key was pressed.
1206
+ - **Cache the index↔column mapping.** A single line of text is short and
1207
+ `Buffer.display_width` is memoized per grapheme, so each walk is a few hash
1208
+ reads. A cache would need invalidating on every mutation — `TextArea`'s
1209
+ `@display_rows` hazard — for no measured gain.
1210
+
1211
+ **Consequences.** `TextField` no longer has a maximum length by default;
1212
+ an app that wants one sets `max_text_length`. `ComboBox` and `IntegerField`
1213
+ inherit scrolling for free through the `TextField` they compose, so a long query
1214
+ or a long number is now reachable instead of rejected. `TextArea` is now the
1215
+ only component still conflating the axes — its wrap computation measures
1216
+ characters against a column width, so CJK prose overflows every row.
1217
+
1218
+ ## D-text-area-columns — `TextArea`: a cluster-iterating wrap over two axes (2026-07-31)
1219
+
1220
+ **Status:** Accepted; `Component::TextArea` wrap rewritten 2026-07-31. The second
1221
+ half of `D-text-field-axes`, which fixed `TextField` and recorded this as open.
1222
+ Deliberately does **not** touch how the caret *steps* — that is
1223
+ `D-cluster-caret`.
1224
+
1225
+ **Context.** `compute_display_rows` filled each row by counting **characters**
1226
+ against `rect.width`, a **column** budget. So CJK prose wrapped at roughly twice
1227
+ the visible width and overflowed every row; `caret_to_display` returned a
1228
+ character offset that `cursor_position` consumed as a column; and `repaint`
1229
+ padded with `rect.width - row[:length]` spaces, overrunning the rect exactly as
1230
+ `TextField` did. Same three symptoms, same cause.
1231
+
1232
+ Two things surfaced only once the rewrite was underway.
1233
+
1234
+ **The old wrap could hang the UI thread.** Any whitespace that is neither space,
1235
+ tab nor newline — `\r`, `\v`, `\f` — dead-looped it: the character matches
1236
+ `/\s/`, so the word scan measured length zero and `pos` never advanced; it fails
1237
+ `/[ \t]/`, so the whitespace branch was skipped; and it is not `"\n"`, so the
1238
+ loop never broke. `area.text = File.read(crlf_file)` was enough to wedge the
1239
+ event loop forever. Reproduced by replaying the old loop on `"ab\r\ncd"`,
1240
+ `"ab\vcd"` and `"ab\fcd"`. This was never a reported bug, which is why it is
1241
+ recorded here: a character wrap has no structural reason to advance, so
1242
+ termination was accidental rather than guaranteed.
1243
+
1244
+ **`"\r\n"` is one grapheme cluster.** Verified. A cluster-iterating wrap
1245
+ therefore cannot test `c == "\n"` for a hard break.
1246
+
1247
+ **Decision — rows carry both counts; the wrap walks clusters.** A row is
1248
+ `{start: <char index>, length: <chars>, columns: <cols>}`: the wrap fills to a
1249
+ column budget while recording a character span, so the index axis and the column
1250
+ axis each stay authoritative for what they address. Iterating **grapheme
1251
+ clusters** rather than characters is required twice over — a combining mark must
1252
+ add zero columns *and* must not be split from its base across a row break — and
1253
+ it makes termination structural: `measure_word` and `hard_wrap` advance on any
1254
+ cluster that is neither blank nor a newline, so the `\r` / `\v` / `\f` class of
1255
+ hang cannot recur. `hard_wrap` consumes a glyph even when that single glyph is
1256
+ wider than the entire row, for the same reason; such a row reports more columns
1257
+ than the rect holds and `padded_row` drops the glyph — a 2-column glyph in a
1258
+ 1-column area is unpaintable either way, but the wrap must still finish.
1259
+
1260
+ **Decision — one shared measurement primitive.** `AbstractStringField#columns_of`
1261
+ (per-cluster, over the memoized `Buffer.display_width`) is the only place either
1262
+ input measures a width; `TextField#column_at` collapsed into a call to it. A
1263
+ second copy in `TextArea` was the alternative and is exactly how the two classes
1264
+ would drift apart again.
1265
+
1266
+ **Decision — vertical movement preserves the *column*.** Up/Down used to carry a
1267
+ character offset into the target row, which put the caret in a visually different
1268
+ place whenever the two rows had different glyph widths. It now converts the
1269
+ column back to a character offset in the target row. This is a behavior change,
1270
+ not just a bug fix, and it matches every editor.
1271
+
1272
+ **Alternatives rejected.**
1273
+
1274
+ - **Iterate characters, summing per-character widths.** Gets the column totals
1275
+ right (a mark measures 0, a wide glyph 2) and is a smaller diff, but it can
1276
+ split a cluster across a row break — leaving a bare base letter on one row and
1277
+ a mark with no base on the next, which `Buffer#set_line` drops entirely. It
1278
+ also keeps termination accidental.
1279
+ - **Wait for the cluster-caret redesign and do both at once.** The redesign is
1280
+ parked, and this fix does not depend on it: the caret stays a character index
1281
+ and only the conversions change. Waiting would have left a UI-thread hang in
1282
+ place.
1283
+ - **Store columns only, deriving char offsets on demand.** Every edit
1284
+ (`insert`, `slice!`) needs a character offset, so this trades one stored
1285
+ integer per row for a conversion on every mutation.
1286
+
1287
+ **Consequences.** A row's `start` and `length` stay **character** counts, and
1288
+ `D-cluster-caret` kept them that way — boundary-locking the caret needed no
1289
+ change here at all, precisely because this wrap is already cluster-iterating and
1290
+ `chars_for_column` / `caret_to_display` already return boundary-aligned counts.
1291
+ The cluster-**width** question this entry left open was closed separately by
1292
+ `D-cluster-width`.
1293
+
1294
+ ## D-cluster-width — Emoji width policy `:rgi`; a cluster may exceed two columns (2026-07-31)
1295
+
1296
+ **Status:** Accepted; implemented 2026-07-31. Completes the width story begun in
1297
+ `D-ambiguous-width` and continued through `D-text-field-axes` /
1298
+ `D-text-area-columns`, which fixed *where* widths were measured while this fixes
1299
+ *what a width is*.
1300
+
1301
+ **Context.** Two independent bugs, both about the grapheme cluster as the unit a
1302
+ terminal actually draws.
1303
+
1304
+ **(1) Sequences summed their parts.** `Unicode::DisplayWidth.of` defaults to no
1305
+ emoji handling, so `"👍🏽"` (thumbs-up + skin-tone modifier — one cluster, one
1306
+ glyph, 2 columns) measured **4**, and a ZWJ family measured **6**. Every rect,
1307
+ caret column and clip derives from that number, so an emoji in a label overran
1308
+ its cell, shifted the rest of the row and desynced the cursor. Worse, the
1309
+ measurement *unit* was inconsistent: `Buffer` measured per cluster while
1310
+ `StyledString`'s slice and wrap internals walked `each_char`. A per-character
1311
+ walk cannot see a sequence at all, and it cuts clusters apart — `slice(0, 3)` of
1312
+ `"abé"` (decomposed) returned `"abe"`, silently stripping the accent off a
1313
+ letter that was entirely inside the slice, because the zero-width mark fell past
1314
+ the slice end.
1315
+
1316
+ **(2) `Buffer` could not model a cluster wider than two columns.** `put_char`
1317
+ special-cased `w == 2` and wrote exactly one continuation cell. A cluster
1318
+ measuring 4 wrote its origin, no continuations, and left the next three cells
1319
+ holding whatever was there before — while `set_line` advanced the column by 4.
1320
+ Stale cells plus a cursor the flush positions from a wrong model.
1321
+
1322
+ **Decision — `emoji: :rgi`, in one named constant, at every call site.**
1323
+ `StyledString::EMOJI_WIDTH` is the single policy and all five
1324
+ `Unicode::DisplayWidth.of` calls pass it. `:rgi` credits width 2 only to
1325
+ [RGI](https://www.unicode.org/reports/tr51/#def_rgi_set) sequences — the ones
1326
+ vendors actually ship a single glyph for — and sums the parts of everything
1327
+ else.
1328
+
1329
+ The choice follows from an **asymmetry, not a preference**: under-measuring lets
1330
+ a glyph overrun its cell, which shifts the row, desyncs the cursor and escapes
1331
+ the component's rect; over-measuring leaves one blank column. Corruption versus
1332
+ cosmetics. `:rgi` is the only setting never wrong in the corrupting direction —
1333
+ for a sequence it is exact when the terminal draws the parts and over-measures
1334
+ when the terminal combines them, and it treats VS16 emoji presentation as 2.
1335
+
1336
+ Note this bets the *opposite* way from `D-ambiguous-width`, deliberately. That
1337
+ note bets narrow because the glyphs at stake are Tuile's **own chrome** — box
1338
+ drawing, the scrollbar block — which the framework controls and needs at one
1339
+ column. Here the glyphs are **app content**, where the framework controls
1340
+ nothing and the asymmetry above governs.
1341
+
1342
+ **Decision — a cluster may occupy any number of cells.** `put_char` writes its
1343
+ origin plus `w - 1` continuations, and the flank repairs walk the whole run:
1344
+ `blank_left_partner` climbs to the glyph's head instead of assuming `x - 1`, and
1345
+ `blank_right_partner` blanks every trailing continuation instead of one. The
1346
+ pre-existing rule that a multi-column glyph which would overflow the row is
1347
+ *blanked* rather than clipped now applies at any width — a terminal cannot draw
1348
+ a partial cluster.
1349
+
1350
+ **Decision — keep two measurement routes, and pin them with a spec.**
1351
+ `StyledString#display_width` keeps its single whole-string gem call;
1352
+ `Buffer.display_width` stays per-cluster and memoized. Measured: for an ASCII
1353
+ row — the common case — summing clusters is **~11x slower** than one gem call,
1354
+ because the gem has a dedicated ASCII fast path. Unifying on cluster-summing
1355
+ would therefore regress the documented repaint hot spot. The two routes agree
1356
+ (whole-string == sum-over-clusters under `:rgi`, verified over a corpus of ZWJ
1357
+ sequences, tag flags, keycaps, VS16 and decomposed Latin), and
1358
+ `styled_string_spec` asserts that agreement so the invariant is test-enforced
1359
+ rather than assumed.
1360
+
1361
+ **Alternatives rejected.**
1362
+
1363
+ - **`emoji: :all` or `:possible`.** Both credit width 2 to malformed or
1364
+ non-RGI sequences, which terminals draw as separate parts — under-measuring,
1365
+ the corrupting direction.
1366
+ - **`emoji: :rgi_at` / `:all_no_vs16` / the `:none` status quo.** All treat a
1367
+ VS16 emoji-presentation sequence as its East-Asian width (often 1) where
1368
+ most terminals draw 2. Same corrupting direction, narrower blast radius.
1369
+ - **`emoji: :auto`.** The gem can sniff the terminal and pick per environment.
1370
+ Rejected: it makes layout arithmetic non-reproducible across machines and
1371
+ makes the spec suite depend on whoever's `$TERM_PROGRAM` runs it — and Tuile's
1372
+ whole width strategy is one global answer with a small, enumerable inventory
1373
+ (`D-ambiguous-width`). An app that needs its terminal's exact answer is better
1374
+ served by a future explicit override than by ambient detection.
1375
+ - **Clamp any cluster to 2 columns.** Would have avoided touching `put_char`,
1376
+ and is simply wrong for a non-RGI sequence the terminal really does draw
1377
+ 4 columns wide.
1378
+ - **Make `StyledString#display_width` sum clusters for one unified path.** The
1379
+ ~11x ASCII regression above.
1380
+
1381
+ **Consequences.** `Buffer.display_width` of an RGI sequence changed from the sum
1382
+ of its parts to 2, so any app that hard-coded the old number will disagree.
1383
+ `slice`/`ellipsize`/`wrap` now keep clusters whole, which means a slice can
1384
+ return *fewer* columns than asked when a wide glyph straddles the boundary — it
1385
+ drops the glyph rather than halving it, as it already did for CJK. Unaffected: a
1386
+ cluster spanning two style spans takes the first span's style rather than being
1387
+ split. The caret stepped by character when this landed; `D-cluster-caret` fixed
1388
+ that separately.
1389
+
1390
+ ---
1391
+
1392
+ ## D-screen-lifecycle — UI thread confinement, and three named screen states (2026-08-01)
1393
+
1394
+ **Status:** Accepted; implemented 2026-08-01. First step of the tree-first
1395
+ sequencing (`D-tree-first`), and independent of the rest of it.
1396
+
1397
+ **Context.** `Screen` carried a two-valued, unnamed state machine:
1398
+ `@pretend_ui_lock = true` in `initialize`, flipped to `false` on
1399
+ `run_event_loop`'s first line and **never restored**. `check_locked` was
1400
+ `@pretend_ui_lock || @event_queue.locked?` (where `locked?` was
1401
+ `Mutex#owned?`). That has a hole with a decided end and an accidental one:
1402
+ pre-loop mutation was *deliberately* blessed, but once `run_event_loop`
1403
+ returned nobody held the mutex and the pretend flag was gone, so **every
1404
+ UI call raised "UI lock not held" during teardown** — a rule nobody chose.
1405
+ There was also no vocabulary for the phases, so "is this legal here?" had
1406
+ no answer to appeal to, and post-`close` mutation failed as
1407
+ `NoMethodError for nil` from inside a nil pane.
1408
+
1409
+ **Decision.** Two orthogonal concepts, named separately.
1410
+
1411
+ 1. **Thread confinement** — the UI belongs to one thread at a time: *the
1412
+ loop's thread while a loop runs, the thread that created the screen when
1413
+ none does.* `check_locked` asks `EventQueue#running?` (is a loop active
1414
+ on any thread) and then either `#on_loop_thread?` or
1415
+ `Thread.current.equal?(@ui_thread)`. `@pretend_ui_lock` is deleted; the
1416
+ post-loop hole closes because "no loop is running" is now an expressible
1417
+ state rather than the absence of a flag. `EventQueue#locked?` was renamed
1418
+ `#on_loop_thread?` — `locked?`-meaning-`owned?` was the misnomer that hid
1419
+ the bug.
1420
+ 2. **`Screen#state`** — `:idle` / `:running` / `:closed`, derived, with
1421
+ `@closed` the only stored phase. `:closed` is terminal and is the sole
1422
+ state that changes *what* is legal.
1423
+
1424
+ `FakeScreen#check_locked`'s no-op override is deleted too:
1425
+ `FakeEventQueue#running?` is `false`, so the *real* check admits the example
1426
+ thread on its own. Two overlapping fakes became one honest fact.
1427
+
1428
+ **Alternatives rejected.**
1429
+ - **Confine to the creating thread, unconditionally** — one identity check,
1430
+ no `running?`, the simplest possible rule; `run_event_loop` would raise
1431
+ unless called on the creating thread. Rejected on evidence: the gem's own
1432
+ `screen_spec` drives `event_loop` from a spawned thread against a screen
1433
+ built on the example thread (three examples), and that is a legitimate
1434
+ embedding pattern, not a spec hack. The two-question check costs one
1435
+ branch and keeps it working.
1436
+ - **Four states (`building` / `running` / `stopped` / `closed`).** The
1437
+ original instinct, and `stopped` is where the post-loop teardown window
1438
+ wanted to live. Rejected once confinement was factored out: `building` and
1439
+ `stopped` have *identical* rules, so distinguishing them means storing a
1440
+ `@ran` flag purely to name two things that behave the same — and a named
1441
+ state with no distinct rule is an invitation to invent one. `:idle`
1442
+ covering both ends is the honest merge.
1443
+ - **Leave the fake's lock bypass in place.** Convenient, but it means specs
1444
+ cannot observe the rule they're supposed to protect, and it hid the
1445
+ post-loop hole for as long as it existed.
1446
+ - **Let `close` work from `:running`.** Today it nils the pane the loop is
1447
+ still painting and dies confusingly on the next repaint. Now it raises,
1448
+ pointing at `event_queue.stop`. Verified no caller does it (all three
1449
+ `examples/` and every spec `after` close from `:idle`).
1450
+ - **Rename `check_locked`.** It is now a misnomer twice over — it checks
1451
+ state *and* affinity, and never checked a lock. Deferred anyway: it's
1452
+ public, called from `List`/`TextView`, and possibly by downstream apps;
1453
+ not worth the churn in the same change that fixes the semantics.
1454
+
1455
+ **Consequences.** `EventQueue#locked?` is gone — callers use
1456
+ `#on_loop_thread?`. A background thread that mutated UI during the pre-loop
1457
+ window still can (that was blessed before and stays blessed), but one that
1458
+ does so from a *non-creating* thread now raises where it used to pass; that
1459
+ is the hole closing, and it can surface in existing app startup code.
1460
+ `submit` outside `:running` is a silent no-op (before the loop it defers;
1461
+ after it, `run_loop`'s `ensure` has cleared the queue), which is why
1462
+ `check_locked`'s two messages differ — advising `submit` with no loop
1463
+ running would advise nothing happening. A background thread can still slip
1464
+ through by reading `running?` in the instant before the loop starts;
1465
+ inherent, and `:idle` is single-threaded by construction. Finally,
1466
+ `run_event_loop`'s guard had to move *outside* its `begin`/`ensure`: a
1467
+ refusal that ran the terminal teardown restored echo on a non-TTY stdin and
1468
+ raised `ENOTTY`, masking the real error.
1469
+
1470
+ ---
1471
+
1472
+ ## D-tree-api — `@children` is authoritative; `add_child`/`remove_child` are the only path (2026-08-01)
1473
+
1474
+ **Status:** Accepted and implemented 2026-08-01. No `children` override
1475
+ remains in `lib/`; the only `parent =` assignments left are the two inside
1476
+ `add_child` / `detach_child`.
1477
+
1478
+ **Context.** Five call sites used to hand-wire `child.parent = …` alongside
1479
+ their own child bookkeeping, each in its own order. That is where the
1480
+ transient tree inconsistency and the focus-repair ordering accident came
1481
+ from (`D-tree-first`), and it is what the attach/detach hooks would
1482
+ have to fire *through*. Two shapes fix it, and they are not equivalent:
1483
+
1484
+ - **A** — `Component` owns an `@children` array; `children` is a plain
1485
+ reader; protected `add_child(child, at:)` / `remove_child(child)` write the
1486
+ array *and* the parent pointer. Containers keep slot ivars (`@content`,
1487
+ `@popups`, `@footer`) as references and choose an insert index.
1488
+ - **B** — containers keep deriving `children` from their slots (as they do
1489
+ today), and only the *wiring* moves into shared mutators.
1490
+
1491
+ B is tempting because the hooks don't need A: they fire from `parent=` inside
1492
+ the mutator either way, and B costs no duplication and no index arithmetic.
1493
+
1494
+ **Decision.** **A.** The deciding argument is not aesthetics but that the
1495
+ hook feature reads *two different structures*: `attached?` walks the **parent
1496
+ chain**, while the subtree fire walks **`children`**. If those can disagree,
1497
+ hooks fire for the wrong set of components — a component can be `attached?`
1498
+ yet never walked. Under A one call writes both, so
1499
+ `children.include?(c) ⟺ c.parent == self` holds by construction. Under B they
1500
+ are independent per container, and every container has to keep them in
1501
+ agreement by hand, forever, with nothing checking it.
1502
+
1503
+ That failure mode is not hypothetical — it is *live* mid-migration, and
1504
+ `Window` demonstrates it exactly:
1505
+
1506
+ ```ruby
1507
+ w.footer = label
1508
+ label.parent.equal?(w) # => true
1509
+ w.children.include?(label) # => true (Window derives it)
1510
+ w.instance_variable_get(:@children) # => [] ← the authoritative list is a lie
1511
+ ```
1512
+
1513
+ **Alternatives rejected.**
1514
+ - **B (derived `children`, mutators for wiring only).** Above: leaves the two
1515
+ structures the hook walk depends on independent. Also gives up a measured
1516
+ 0-vs-6 objects per `children` read — and `on_tree` reads `children` once per
1517
+ node on every repaint, so it is a per-node, per-frame path.
1518
+ - **Derive `popups` from `@children`** to avoid the one real duplication A
1519
+ costs (`@popups` and `@children` both carry popup order). Every spelling is
1520
+ worse: an index slice (`@children[offset..-2]`) is fragile and allocates on
1521
+ the hot path where `popups` is read, and `grep(Popup)` breaks the moment a
1522
+ popup is used as tiled content. `@popups` stays, guarded by a drift
1523
+ assertion in `screen_pane_spec`.
1524
+ - **`size - 1` for the popup insert index.** Works, but silently assumes the
1525
+ status bar is last. `at: @children.index(@status_bar)` names the anchor.
1526
+
1527
+ **Consequences.** Migrating the two slot containers forced a third mutator:
1528
+ `HasContent#content=` and `Window#footer=` must notify `on_child_removed`
1529
+ *after* the new occupant is wired (the default focus repair cascades into
1530
+ whatever fills the slot now — `window_spec` pins that a content swap lands
1531
+ focus on the new content), so `detach_child` does delete-plus-unwire without
1532
+ notifying and `remove_child` is `detach_child` + notify. A container swapping
1533
+ a slot uses the quiet one and owes the notification.
1534
+
1535
+ The invariant is *maintained by the sane path*, not
1536
+ unbreakable: `parent=` has to stay `protected` (Ruby won't dispatch a private
1537
+ writer through an explicit receiver, which `child.parent = self` needs), so a
1538
+ subclass can still hand-wire and desynchronize. AGENTS.md carries the rule.
1539
+ Ordering moved from recomputed-per-read to maintained-at-insert, so it needs
1540
+ specs rather than being true by inspection. Every `Component` subclass must
1541
+ call `super` in `initialize` or `@children` is nil — all 20 currently do.
1542
+ A container needing `children` order to be a function of state that changes
1543
+ *without* a tree mutation (a z-index sort) would have to re-sort `@children`
1544
+ in that setter; none does today, and that is the one thing that would argue
1545
+ for B.
1546
+
1547
+ ---
1548
+
1549
+ ## D-attach-hooks — `on_attached` / `on_detached`: an edge trigger on the component (2026-08-01)
1550
+
1551
+ **Status:** Accepted and implemented 2026-08-01. Last step of the tree-first
1552
+ sequencing (`D-tree-first`); both `ideas/` notes it was designed in are retired.
1553
+
1554
+ **Context.** Tuile had two thirds of a tree lifecycle: `attached?` (a computed
1555
+ predicate) and `on_child_removed` (a *container-side* notification used for
1556
+ focus repair). Missing was an **edge trigger on the component itself**, so a
1557
+ component could not own a resource whose lifetime is its own mounted lifetime
1558
+ — a ticker, a subscription, a tailed file handle. Note the asymmetry that made
1559
+ this a real gap: `invalidate` is already attachment-gated, so the framework
1560
+ quietly handles the one resource it knows about, while anything the *app*
1561
+ acquires has no such gate. The general consumer is COP's listener inversion —
1562
+ a component subscribes to a service, and there was no symmetric place to
1563
+ unsubscribe, so every app either leaked for the process lifetime or hand-rolled
1564
+ teardown at each call site that closes a window.
1565
+
1566
+ **Decision.** Two `protected` no-op hooks on `Component`, fired from the
1567
+ protected `parent=` writer — the sole reparenting choke point, provably so now
1568
+ that `add_child` / `detach_child` are its only callers. `parent=` measures
1569
+ `attached?` either side of the pointer write and fires `fire_lifecycle` across
1570
+ the whole subtree only on a genuine transition. Past-tense `on_` names match
1571
+ the local convention (`on_child_removed`, `on_theme_changed`) rather than
1572
+ Vaadin's imperative `onAttach`. Contract: **`on_attached` starts what
1573
+ `on_detached` stops; both cheap and idempotent**, and whatever a hook acquires
1574
+ it must release in the mirror, because nothing else will.
1575
+
1576
+ **Alternatives rejected.**
1577
+ - **`!attached?` self-cancel inside the ticker block.** Stops the leak but
1578
+ never *restarts*: a component moved between parents silently loses its
1579
+ animation forever. The objection isn't the transient detachment, it's that
1580
+ there is no edge to restart on — which is exactly what a hook is.
1581
+ - **A Screen-owned animation registry** (`screen.animate(component, fps)`,
1582
+ auto-cancelled on detach). Fixes the same leak with no new `Component` API,
1583
+ but it doesn't restart either, it puts an animation concern into `Screen`,
1584
+ and it does nothing for the subscription case, which is the general one.
1585
+ - **Firing from the five reparenting sites**, or now from the two mutators.
1586
+ Rejected for the reason the whole tree-first arc exists: one site, one
1587
+ correct order. Attach must be measured after the pointer is wired, detach
1588
+ before — spread across sites that is five chances to get it wrong.
1589
+ - **`parent.equal?(self)` as the recursion re-check.** This was the design, and
1590
+ implementing it proved it wrong: a child a hook removes *during a detach
1591
+ walk* is already detached, so its own `parent=` saw no transition and stayed
1592
+ silent — and the parentage check then skips it too, so it never hears
1593
+ `on_detached` at all. Re-checking `attached? == attached` fixes it. The
1594
+ reverse case (removed during an *attach* walk) gets an unpaired
1595
+ `on_detached`, which the idempotence requirement makes harmless — whereas
1596
+ firing `on_attached` at a component that is no longer attached would start a
1597
+ ticker nothing ever stops.
1598
+ - **`on_attached=` / `on_detached=` writer pair** (the composition-style
1599
+ alternative to subclassing, as `on_theme_changed=`). Deferred: shipping four
1600
+ members when two are unproven is how a seam ends up wider than its need.
1601
+ **Re-grow rule:** add the writers the first time an assembly-style app needs
1602
+ a subscription without subclassing.
1603
+ - **Leaving `Screen#close` silent** (the shape shipped for one commit, then
1604
+ lifted the same day). The argument for silence was that a Tuile screen dies
1605
+ with the process, unlike Vaadin's UI, which closes inside a long-lived JVM
1606
+ that goes on serving other sessions — so a missed `onDetach` there leaks into
1607
+ a *surviving* process and here it does not. That still holds, and it is why
1608
+ teardown-detach was never *urgent*; what overrode it is that `attached?`
1609
+ became a type test (`D-tree-api`), so a tree rooted at a nilled `@pane` went
1610
+ on claiming to be attached forever and touching it raised "Screen not
1611
+ initialized". Firing is also just cheaper than explaining that. So
1612
+ `Screen#close` now calls `ScreenPane#detach_all`.
1613
+ - **Swallowing a raise during teardown** (rescue-and-log), which the deferred
1614
+ design had specified on the grounds that teardown must not be abortable.
1615
+ Rejected: a raising `on_detached` is a programming error, and the framework
1616
+ guarding it would hide the bug — Vaadin does not guard here either. The real
1617
+ concern behind that rider survives without a rescue, by putting the teardown
1618
+ flags in an **`ensure`**: the exception propagates loudly, but `@closed` and
1619
+ the singleton slot are still cleared, so one buggy hook stays one failure
1620
+ instead of cascading through every later example that inherits a half-closed
1621
+ screen.
1622
+ - **A generic `Component#remove_all_children`** as the unmount primitive.
1623
+ Unsafe: a slot container calling it would empty `@children` while `#content`
1624
+ / `#footer` still pointed at detached components — exactly the desync
1625
+ `D-tree-api` exists to prevent. Unmounting also has to clear the pane's own
1626
+ slots, so it is not a generic tree operation. Named `detach_all` rather than
1627
+ `close` because `Popup#close` already means "remove *me* from the pane".
1628
+
1629
+ **Consequences.** `Screen#close` fires `on_detached` for everything still
1630
+ mounted; a process that exits *without* closing fires nothing, and no `at_exit`
1631
+ is installed to change that. A cross-container move fires `on_detached` then
1632
+ `on_attached`, because between `remove` and `add` the component genuinely *is*
1633
+ detached, for arbitrarily long — honest, and strictly better than a heuristic
1634
+ that never restarts. A hook may not read `rect` (`on_attached` runs before the
1635
+ parent assigns it), may still see `Screen#focused` pointing into the subtree
1636
+ being detached (repair runs after), and must not inspect the ex-parent's
1637
+ bookkeeping. A raising hook propagates and leaves the tree undefined —
1638
+ durably so on the detach path, where the container's remaining work is skipped.
1639
+ Finally, hooks fire during `:idle` on the normal app path (a tree is assembled
1640
+ before `run_event_loop`), which `D-screen-lifecycle` made a decision rather
1641
+ than an accident.
1642
+
1643
+ ---
1644
+
1645
+ ## D-tree-first — `Screen` is the service, `ScreenPane` is the UI (2026-08-01)
1646
+
1647
+ **Status:** Accepted and implemented 2026-08-01, in five steps
1648
+ (`D-screen-lifecycle`, the one-axis `attached?`, `D-tree-api` in two parts,
1649
+ `D-attach-hooks`). The `ideas/` note it was designed in is retired.
1650
+
1651
+ **Context.** Designing two no-op lifecycle hooks
1652
+ (`Component#on_attached` / `#on_detached`) took *ten* documented corner cases:
1653
+ a predicate that raises, a traversal that double-fires, a transiently
1654
+ inconsistent tree, an exception policy that inverts during teardown, two
1655
+ hard-wired exceptions, and a "second axis" framing invented purely to make the
1656
+ exception list provable. Ten edges for two hooks is not a hook problem.
1657
+
1658
+ Six of them traced to one flaw: `attached?` was `root == screen.pane`, reading
1659
+ one property of the **component** (its parent chain) and one of a **mutable
1660
+ pointer inside a global singleton**. A seventh source was `children` being
1661
+ overridable, so five sites hand-wired the parent pointer alongside their own
1662
+ bookkeeping, each in its own order.
1663
+
1664
+ **Decision.** Model the tree as a tree, and keep the runtime out of it.
1665
+
1666
+ - **`Screen` stays machinery and stays out of the tree** — Vaadin's
1667
+ `VaadinService`, roughly. It may remain a process-singleton; nothing here
1668
+ required killing it.
1669
+ - **`ScreenPane` is the tree root and defines attachedness** — Vaadin's `UI`.
1670
+ `attached?` became `root.is_a?(ScreenPane)`: one axis, no `Screen`
1671
+ reference, so it never raises and a tree can be assembled with no screen in
1672
+ the process.
1673
+ - **The tree API is final** (`D-tree-api`), and `parent=` — reachable only
1674
+ through it — is the sole lifecycle firing site (`D-attach-hooks`).
1675
+
1676
+ Deleting the second axis deleted six edges outright rather than documenting
1677
+ them: the raise, the status-bar exception, the two-`@pane`-writes framing, the
1678
+ transient inconsistency, the focus-repair ordering accident, and the teardown
1679
+ exception (which then *inverted* — `Screen#close` now unmounts the tree).
1680
+
1681
+ **Alternatives rejected.**
1682
+ - **A DOM-style `Node`/`Element` split** (`Screen < Node`, `Component < Node`),
1683
+ with `Node` carrying `parent`/`children`/`on_child_removed`. DOM needs it
1684
+ because DOM has non-Element nodes — Text, Comment, DocumentFragment. Tuile
1685
+ has none; every node is a paintable `Component`, so the base would have
1686
+ exactly one subclass family and would not earn its place. `Node` is justified
1687
+ *only* if `Screen` itself joins the tree, which this shape declines.
1688
+ - **`Screen < Component`** — collapses `Screen` and `ScreenPane` into one
1689
+ class. Rejected: a runtime owner would inherit `rect`, `bg_color`,
1690
+ `focusable?`, `handle_key`, `repaint`, surface it has no use for. That mixed
1691
+ bag is what the split undoes.
1692
+ - **An `owning_screen` pointer on the pane** (`attached? =
1693
+ !root.owning_screen.nil?`). Strictly worse than the type test: it puts a
1694
+ screen reference back into the predicate for no gain, and it is a pointer
1695
+ someone eventually nils — which is the original bug.
1696
+ - **Killing the singleton to allow multiple screens.** Multiple screens is a
1697
+ *consequence* some designs permit, never a motivation: one terminal is one
1698
+ screen. `lib/` has exactly one `Screen.instance` call site, so removing it
1699
+ there is a one-line change — but the cost lands on the 27-of-42 spec files
1700
+ built on `Screen.fake` / `Screen.instance`. Keeping the singleton is what
1701
+ made the whole redesign affordable.
1702
+
1703
+ **Consequences.** `attached?` is now answerable with no `Screen` at all, which
1704
+ is what lets `parent=` consult it. `ScreenPane` gained the ordering discipline
1705
+ that `children` used to recompute per read, and `Screen#close` gained a real
1706
+ unmount step. The natural next question this shape *doesn't* answer: `Screen`
1707
+ is still reached as a singleton from `Component#screen`, so a component's
1708
+ screen is ambient rather than derived from its root — fine while one terminal
1709
+ means one screen, and the one-line change if that ever stops being true.
1710
+
1711
+ ---
1712
+
1713
+ ## D-color-slots — A component color slot, not a new chrome token (2026-08-01)
1714
+
1715
+ **Status:** Accepted; first applied by `Component::ProgressBar#bar_color`
1716
+ (implemented 2026-08-02). Binds Slider and Badge when they land — the question
1717
+ was cross-component from the start, so it is settled once here rather than
1718
+ re-argued per widget. Builds on `D-bg-inherit` (accents-only theme, no global
1719
+ bg/fg token) and `D-theme-ref` (the live-resolved slot machinery this reuses).
1720
+
1721
+ **Context.** {Theme} carries four chrome tokens — `active_bg_color`,
1722
+ `active_border_color`, `input_bg_color`, `hint_color` — and a component
1723
+ eventually needs a color none of them covers: the filled run of a progress
1724
+ bar, a slider's thumb and track, a badge's severity tint. The fork looks
1725
+ binary: grow the theme a token, or give the component its own color property.
1726
+
1727
+ **Decision — the slot, and the two were never alternatives.** Because a slot
1728
+ accepts a `Theme::Ref`, it is a *superset* of a token: a token would not remove
1729
+ the need for `bar_color=` (threshold coloring — green under 50 %, red over 90 %
1730
+ — is per-instance and app-owned), but `bar_color=` removes the need for the
1731
+ token. There are three surfaces, not two, and `custom` is the one that
1732
+ dissolves the argument:
1733
+
1734
+ | Surface | Read by | Right when |
1735
+ |---|---|---|
1736
+ | chrome token (a `Theme` `Data` member) | framework chrome, no app involvement | ≥2 built-ins share it *and* there is no app API |
1737
+ | component slot (`Color \| Theme::Ref`) | the component, resolved at paint | the app might brand or vary it |
1738
+ | `custom` token | the app's own slot values | the app wants *its* color to follow dark/light |
1739
+
1740
+ > A component adds a **slot** to give the app a color. A chrome token is added
1741
+ > only when the framework needs the color *with no app involvement*, in *more
1742
+ > than one place*.
1743
+
1744
+ That rule is descriptive rather than invented: all four existing tokens pass it
1745
+ and none has a slot (`active_bg_color` → List cursor + TextField well + Button;
1746
+ `active_border_color` → Window border; `input_bg_color` → both text inputs;
1747
+ `hint_color` → status-bar hints).
1748
+
1749
+ **Decision — a slot defaults to `nil`, the terminal default.** Not to a chrome
1750
+ token whose meaning is something else, and not to a hardcoded color unless the
1751
+ component is meaningless without one. Rejected defaults for `bar_color`, each
1752
+ of which looked right until checked against both built-in themes:
1753
+
1754
+ - **`Theme.ref(:active_bg_color)`** (this component's own first design) — a
1755
+ *background*-role token used as a foreground. `GREY37` (#5f5f5f) is muddy on a
1756
+ dark terminal and `GREY82` (#d0d0d0) is effectively **invisible** on a light
1757
+ one. The bug the rule exists to prevent.
1758
+ - **`Theme.ref(:active_border_color)`** — legible in both (it is the named ANSI
1759
+ green, remapped by the terminal), but the same mistake made invisible: that
1760
+ token means "border of a *focused window*", so a theme author recoloring
1761
+ borders would silently recolor every progress bar in the app.
1762
+ - **`Color::GREEN`** — legible and uncoupled, but a built-in asserting a color
1763
+ when it needs none. `nil` degrades identically and claims less.
1764
+
1765
+ **Decision — Badge starts as a slot too, with a promotion trigger.** Badge is
1766
+ the case that looks like it wants tokens, since info/success/warning/error
1767
+ *are* semantic — but only one built-in paints them today, so it gets a frozen
1768
+ `SEVERITY_COLORS` map of named ANSI colors picked by `severity=`, plus a
1769
+ `color=` slot that overrides. **Promote the map to chrome tokens when a second
1770
+ built-in needs the same semantic color** (a toast, a log-level row): at that
1771
+ moment the framework itself is sharing it, which is precisely what a token is
1772
+ for. The asymmetry is what makes starting at the slot safe — adding a `Data`
1773
+ member is additive, removing one is not.
1774
+
1775
+ **Consequences.**
1776
+
1777
+ - **Slots stay per-purpose and few.** A component sprouting five color slots
1778
+ has a theming problem, not a slot problem. `ProgressBar` therefore has *one*:
1779
+ `░` paints in `bar_color` too, so density distinguishes filled from empty and
1780
+ hue never does — which also keeps the bar readable with no color support at
1781
+ all. A `track_color` would have doubled the surface to weaken that.
1782
+ - **A slot's `Ref` is validated eagerly** (KeyError at assignment, as
1783
+ `bg_color=` does) and re-resolved at paint, never cached — same rules as
1784
+ `D-theme-ref`, including riding the invalidate-everything pass on `theme=`.
1785
+ - **This licenses no global bg/fg token.** `D-bg-inherit` stands: a slot's
1786
+ `Ref` can only point at a color the theme *already* carries.
1787
+
1788
+ ---
1789
+
1790
+ ## D-progress-bar — A value that is not a field; no text on the bar (2026-08-01)
1791
+
1792
+ **Status:** Accepted; `Component::ProgressBar` implemented 2026-08-02, demoed in
1793
+ the sampler. Color is `D-color-slots`; the glyph pair rides `D-ambiguous-width`;
1794
+ the ticker rides `D-attach-hooks`. What this entry owns is the *shape*.
1795
+
1796
+ **Context.** The first component with a `value` that is emphatically **not** an
1797
+ input: nothing focuses it, nothing types into it, and its number comes from the
1798
+ app's own work loop rather than a user.
1799
+
1800
+ **Decision — no `HasValue`.** Tempting (it has a `value`), but that mixin is the
1801
+ *input-field* seam: it carries `focusable? = true`, so including it would make a
1802
+ display widget a focus target and then need an override to undo that, and it
1803
+ would put a read-only report into the seam a future forms layer iterates over.
1804
+ Plain accessors instead. Vaadin's `ProgressBar` likewise has `setValue` without
1805
+ implementing `HasValue`.
1806
+
1807
+ **Decision — no text on the bar; compose a `Label`.** An earlier draft had a
1808
+ `caption` slot (`:percentage | :fraction | String | nil`, centered and overlaid
1809
+ on the fill). Three reasons it went:
1810
+
1811
+ - **The overlay is the entire complexity budget.** Without it `repaint` is a
1812
+ handful of lines; with it you slice a {StyledString} at the fill boundary and
1813
+ merge per-span fg so the text stays legible on both sides, plus centering
1814
+ arithmetic through `display_width`, plus specs at every fill level. More code
1815
+ than the bar it decorates, all of it formatting.
1816
+ - **Composition is strictly better here, not merely adequate.** A sibling
1817
+ {Component::Label} gets styling, theming and `on_theme_changed` free, and the
1818
+ app can put any words anywhere; an overlay can only ever be "centered, one
1819
+ line, clipped to the bar".
1820
+ - **The component-oriented toolkits agree.** Vaadin 25.2's `ProgressBar` has no
1821
+ text API at all and its own docs compose a label beside it; JavaFX exposes
1822
+ only `progressProperty()` with the same convention. The toolkits that *do*
1823
+ carry text are older and landed on either a boolean-plus-override-string
1824
+ (Swing `setStringPainted`/`setString`, GTK `show_text`/`set_text`) or a printf
1825
+ template (Qt `setFormat("%p%")`). Nobody ships a closure.
1826
+
1827
+ **Re-grow rule.** If text-on-bar ever earns its way in, it arrives as
1828
+ `label = ->(bar) { … }` — a closure over the bar, `nil` for bare — mirroring
1829
+ `ComboBox#item_label`. Never an enum (fuses a mode with literal text in one
1830
+ slot), never a Qt-style template string, and never a rich context object: a
1831
+ `ProgressValue` exposing `percent` / `value_slash_max` was considered and
1832
+ rejected as a whole new public type (rdoc + `sig` + spec) to shorten a
1833
+ 25-character interpolation. The honest cost of the decision, so a revisit has
1834
+ something to weigh: **an overlay cannot be composed on a TTY** — there are no
1835
+ overlapping tiled components, so a sibling label always takes its own row. A
1836
+ bar in a `Window`'s bottom border (`window.footer = bar`, which already works)
1837
+ therefore has nowhere to put one, and stays bare.
1838
+
1839
+ **Decision — one atomic `range=`, no `min=` / `max=` writers.** *Any* pairwise
1840
+ validation makes two setters order-dependent, rejecting an intermediate state
1841
+ the app never intended: `bar.min = 10` raises while `max` is still the default
1842
+ `1.0`, and writing the two lines the other way round works. That is a coin-flip
1843
+ API, which is why Swing and GTK both ship an atomic `setRange`. One writer means
1844
+ the invalid intermediate state cannot exist. (Re-adding the pair would break
1845
+ nothing a spec asserts — hence this note.)
1846
+
1847
+ **Decision — `min == max` is legal and reads as complete.** Only `max < min`
1848
+ raises. A zero-length job has nothing outstanding — the vacuous truth that makes
1849
+ `[].all?` true — so `bar.range = 0..files.size` needs no special case for an
1850
+ empty list. Raising there would blow up an app during setup for having no work
1851
+ to do; painting an empty bar forever would be the other wrong answer. Callers
1852
+ split cleanly: unknown total → `indeterminate = true`; zero total → a full bar;
1853
+ nonsense total → `ArgumentError` at the call site that got it wrong. Non-finite
1854
+ endpoints are refused for the same reason — `0..Float::INFINITY` would paint
1855
+ 0 % forever, and that caller wanted indeterminate mode.
1856
+
1857
+ **Decision — indeterminate mode animates itself, at a rate that is not a knob.**
1858
+ The ticker's lifetime is *synced from an invariant* rather than toggled by the
1859
+ attach hooks (see AGENTS.md, which owns that rule as a general one). The frame
1860
+ rate is a constant: an `indeterminate_fps=` setter would need a force-restart
1861
+ punched through `sync_ticker`'s idempotence check — a second writer of
1862
+ `@ticker`, which is the invariant the design rests on. If it is ever needed, add
1863
+ it as cancel-then-sync and keep `sync_ticker` the sole starter. Rejected with
1864
+ it: an app-driven `pulse`, which existed only to dodge the pre-hooks lifecycle
1865
+ gap and would have been a second way to animate one widget.
1866
+
1867
+ **Consequences.** `fraction` and `percent` are load-bearing public API rather
1868
+ than sugar, since the composed label is what reads them — which is why both
1869
+ scale through one helper with exact endpoints (a full bar means done, and
1870
+ anything above zero lights a cell). And the bar is the first *animated*
1871
+ component, which is what turned an ordinary `super` in `repaint` into a
1872
+ measurable wire-traffic bug: `super` clears the background first, so
1873
+ `Cell#set` saw a real change on every cell of the bar and `flush` re-emitted
1874
+ the *entire* row five times a second instead of the one or two cells that had
1875
+ moved — **976 block glyphs per 1.2 s on the wire, versus 18** once the clear
1876
+ was scoped to the unpainted tail. That measurement is the evidence for the
1877
+ rule; AGENTS.md carries the rule itself ("never blank a cell you are about to
1878
+ paint over").
1879
+
1880
+ ---
1881
+
1882
+ ## D-cluster-caret — The caret is boundary-locked; edits step by cluster (2026-08-02)
1883
+
1884
+ **Status:** Accepted; implemented 2026-08-02 in `AbstractStringField`, so it
1885
+ landed on `TextField`, `PasswordField` and `TextArea` at once. Closes the gap
1886
+ `D-text-field-axes` / `D-text-area-columns` / `D-cluster-width` each recorded as
1887
+ open.
1888
+
1889
+ **Context.** `@caret` indexed **codepoints** while the terminal draws **grapheme
1890
+ clusters**, and every edit stepped by one codepoint. Three symptoms, all
1891
+ reachable by *typing* (`Keys.printable?` admits combining marks, regional
1892
+ indicators, variation selectors and skin-tone modifiers):
1893
+
1894
+ | symptom | evidence | operation at fault |
1895
+ |---|---|---|
1896
+ | RIGHT stalls | decomposed `"éx"`, 3× RIGHT → columns `[0, 1, 1, 2]` | LEFT/RIGHT |
1897
+ | BACKSPACE mutilates | `"é"` → `"e"` — a valid, *wrong* letter; `"🇯🇵"` → `"🇯"` | `delete_before_caret` |
1898
+ | DELETE orphans | `"é"` caret 0 + DELETE → a lone U+0301: not `empty?`, paints as `""` | `delete_at_caret` |
1899
+
1900
+ That right-hand column is the whole finding: **only movement and deletion were
1901
+ wrong.** Insertion was already right (`String#insert` merges a typed combining
1902
+ mark into its base for free), painting was already cluster-native, and every
1903
+ index↔column conversion already walked clusters after the three decisions above.
1904
+
1905
+ **Decision — keep `caret` in character space; teach four operations about
1906
+ clusters.** LEFT/RIGHT move to the adjacent cluster boundary; BACKSPACE and
1907
+ DELETE remove a whole cluster. Three private single-walk primitives on
1908
+ `AbstractStringField` (`snap_to_cluster`, `cluster_boundary_before`,
1909
+ `cluster_boundary_after`) — no cache, no new state, no invalidation rule.
1910
+
1911
+ **Decision — snap at both write sites, making a mid-cluster caret
1912
+ unrepresentable.** `caret=` and `text=`'s clamp both snap to the smallest
1913
+ boundary `>= index`, so *the caret is always on a cluster boundary* is a real
1914
+ invariant with exactly two enforcement points. Snapping **forward** is
1915
+ display-preserving: `column_at` already measured a mid-cluster index as the
1916
+ whole cluster, so the snap moves nothing on screen. Consequence: the movement
1917
+ and deletion helpers may assume a boundary caret and carry no snap step, and the
1918
+ DELETE-orphan bug is unreachable rather than patched.
1919
+
1920
+ Both sites are load-bearing. `text=` is not redundant: typing a regional
1921
+ indicator *ahead of* an existing flag re-segments the neighborhood, so `insert`'s
1922
+ `@caret += 1` lands inside a cluster of the **new** text — only the `text=` snap
1923
+ can catch that. Pinned by "snaps the caret when the insertion re-segments its
1924
+ neighborhood".
1925
+
1926
+ **Decision — deletion is uniformly whole-cluster, with no per-script rules.**
1927
+ Unicode defines cluster boundaries (UAX #29) but not what Backspace means, and
1928
+ editors diverge: a ZWJ family may shed one member per press, and most Korean
1929
+ IMEs delete the last *jamo* rather than the syllable. Tuile deletes the whole
1930
+ cluster in every case. The cost is real and accepted — a Korean typist loses
1931
+ "one press, one jamo" — but per-script deletion would put a table of exceptions
1932
+ back into a design whose entire value is not having one, and it is exactly what
1933
+ makes the orphan bug unreachable.
1934
+
1935
+ **Alternatives rejected.**
1936
+
1937
+ - **Reinterpret `caret` as an index into a cached boundary table** (one row per
1938
+ cluster carrying `{offset:, column:}`; stepping becomes `± 1`). The original
1939
+ design, parked 2026-07-31 and rejected on implementation. It pays globally to
1940
+ fix four methods, and the snap above recovers its one real guarantee for five
1941
+ lines. Three concrete costs: (1) **it moves the axis, so every
1942
+ `caret = <something>.length` breaks silently** — five sites in `lib/` plus
1943
+ `examples/sampler.rb`'s `area.caret = start + command.length + 1`, all correct
1944
+ for ASCII and wrong otherwise, which is the failure mode `D-text-field-axes`
1945
+ deleted, relocated from the framework to its callers; it then forced an open
1946
+ question about a loud rename migration purely to convert those silent breaks
1947
+ into `NoMethodError`s. (2) `max_text_length` would silently change meaning,
1948
+ characters → clusters. (3) It adds a second invalidated cache to a class that
1949
+ already carries one (`TextArea`'s `@display_rows`), for state a per-keystroke
1950
+ walk recomputes in 62µs.
1951
+ - **Store an `Array` of clusters instead of a `String`.** Insertion is where
1952
+ cluster-native storage bites back: typing a combining mark after `e` would
1953
+ yield `["e", "◌́"]` — two clusters, the second a lone mark painting as nothing
1954
+ — so every keystroke would re-segment its neighborhood. **String storage gets
1955
+ insertion right and stepping wrong; cluster storage inverts exactly that.**
1956
+ - **Snap backward, to the enclosing cluster's start.** Would move the cursor on
1957
+ screen, since a mid-cluster index already displayed past its cluster.
1958
+ - **Tolerate mid-cluster carets and snap only inside the edit operations.** The
1959
+ cheapest version, and what the four operations would need anyway. Rejected for
1960
+ the two write-site lines: an invariant enforced once beats a tolerance
1961
+ repeated at every reader, and `caret=` already adjusts by clamping, so
1962
+ snapping there is not a new kind of surprise.
1963
+ - **Move `max_text_length` to counting clusters** alongside this. Deliberately
1964
+ not bundled: it stays character-counting and stays `D-text-field-axes`'s
1965
+ decision. Now a knowing choice rather than an untouched default — a decomposed
1966
+ `é` burns 2 of 10, and a field at its cap refuses an accent on its last letter
1967
+ because `insert`'s check fires before the mark can merge.
1968
+
1969
+ **Consequences.** ASCII behavior is bit-identical, so this is not a breaking
1970
+ change in practice; for non-ASCII the visible differences are the three bug
1971
+ fixes plus `caret=` reading back snapped. `TextArea` needed no changes at all —
1972
+ its row records keep character offsets and `chars_for_column` /
1973
+ `caret_to_display` already return boundary-aligned counts — so the two-commit
1974
+ plan the parked note assumed collapsed to one. Still out of scope and unfixed: a
1975
+ lone combining mark remains constructible via `text=` or by typing a mark into
1976
+ an empty field, which is input validation, not an axis question.
1977
+
1978
+ ---
1979
+
1980
+ ## D-float-field — `FloatField`: named for its Ruby type, and a deliberate copy of `IntegerField` (2026-08-07)
1981
+
1982
+ **Status:** Accepted; implemented 2026-08-07 (`Component::FloatField`). The
1983
+ `Float` half of `D-integer-field`'s "derived parse" case — same wrapper shape,
1984
+ same taxonomy slot, so only what *differs* is recorded here.
1985
+
1986
+ **Context.** Vaadin calls this a *Number Field*; the survey in
1987
+ `ideas/new-components.md` filed it as an "`IntegerField` twin". A second numeric
1988
+ field is where the naming rule and the shared-base temptation both had to be
1989
+ settled, because a third (`BigDecimalField`) is foreseeable.
1990
+
1991
+ **Decision — name a typed field after the Ruby class its `value` is.**
1992
+ `FloatField#value` is a `Float`, so `FloatField`; `IntegerField#value` is an
1993
+ `Integer`. The name is then derivable rather than remembered, it says the
1994
+ precision out loud at the call site (`Float` is a binary double — the wrong type
1995
+ for money), and it leaves the obvious room for `BigDecimalField` /
1996
+ `RationalField`. `NumberField` was rejected: it names Vaadin's *widget*
1997
+ category, not this field's value, and it would force the eventual sibling to be
1998
+ "the other number field."
1999
+
2000
+ **Decision — duplicate `IntegerField` rather than grow a base.** The two share
2001
+ ~90% of their body (the `HasContent` shell, the `on_key` filter interceptor, the
2002
+ `fire_if_changed` guard) and differ in exactly the three places that matter: the
2003
+ filter, the parse, and the format. An `AbstractNumericField` with abstract
2004
+ `parse`/`format` hooks **is** the converter strategy `D-integer-field` kept out,
2005
+ reached through inheritance instead of a setter — and the `cop` rule is to
2006
+ duplicate rather than fold a shallow commonality into a base. The duplication is
2007
+ visible and boring; the base would be machinery.
2008
+
2009
+ **Decision — the parse is lenient about partial buffers, the input filter is
2010
+ shallow.** `value` is a regexp-gated `String#to_f` — the private `NUMERIC`
2011
+ pattern: an optional sign, digits with an optional fractional part (either side
2012
+ may be empty, not both), an optional exponent. Not `Float()`, which raises on
2013
+ both `"1."` and `".5"`, so a `Float()`-based parse would blink the value to `nil` and back
2014
+ on the single keystroke between `"1"` and `"1.5"` — one spurious `nil` per
2015
+ decimal point, straight into every `on_value_change` listener. The regexp gate
2016
+ is what makes `to_f`'s garbage-tolerance harmless (it never sees garbage). The
2017
+ filter is correspondingly shallow — a digit anywhere, `-` only at index 0, `.`
2018
+ only if the buffer has none — so it keeps the buffer *typeable*, not always
2019
+ valid; `value` decides what parses. (`IntegerField` already worked this way: it
2020
+ lets a digit be typed before a leading `-`.)
2021
+
2022
+ **Decision — the exponent is parseable but not typeable.** `Float#to_s` writes
2023
+ `1.0e-05` for extreme magnitudes, so `value = 1e-5` must read back — the parse
2024
+ accepts an exponent. No key types an `e`, though: admitting one would drag in
2025
+ "`-` after `e`" and break the "`-` only at index 0" rule for a notation nobody
2026
+ types into a form.
2027
+
2028
+ **Decision — `value=` coerces with `Float()` and refuses a non-finite.**
2029
+ `Float::NAN.to_s` is `"NaN"`, which nothing parses, so writing one would make
2030
+ the field silently read back `nil` — a lost value with no error. It raises
2031
+ instead. Coercion also means `field.value = 3` shows `"3.0"`, which is the
2032
+ honest display of a `Float`-valued field.
2033
+
2034
+ **Decision — Up/Down step by exactly `1.0`; there is no `step=`.** Same fixed
2035
+ spinner as `IntegerField`. A settable step is not free on a binary float:
2036
+ stepping by `0.1` accumulates `0.30000000000000004` straight into the visible
2037
+ buffer, so the knob would need a rounding policy (decimals? significant
2038
+ digits?), and rounding is formatting — a forms concern, parked with `min`/`max`
2039
+ in `D-integer-field`.
2040
+
2041
+ **Alternatives rejected.**
2042
+ - *`BigDecimal` as the value type:* correct for money, but it needs the
2043
+ `bigdecimal` gem, a decimals/scale policy, and `"0.1"` → `BigDecimal("0.1")`
2044
+ string-round-tripping — a different field with a different name, not this one.
2045
+ - *Normalize the buffer on parse (`"007"` → `"7"`, `".5"` → `"0.5"`):*
2046
+ rejected for the same reason as in `IntegerField` — canonicalizing needs a
2047
+ blur/commit point a TUI lacks, and rewriting the buffer under the caret while
2048
+ typing is worse than an ugly buffer.
2049
+ - *A locale decimal comma:* no locale seam exists in Tuile, and inventing one
2050
+ for a single field would put i18n in the wrong layer.
2051
+
2052
+ ---
2053
+
2054
+ ## D-bigdecimal-field — `BigDecimalField`, and Tuile's first optional dependency (2026-08-07)
2055
+
2056
+ **Status:** Accepted; implemented 2026-08-07 (`Component::BigDecimalField`).
2057
+ The third numeric field, so it inherits `D-float-field` wholesale (named for
2058
+ its Ruby value type, a deliberate copy rather than a shared base) — only the
2059
+ two things that are new are recorded here: exactness, and the packaging.
2060
+
2061
+ **Context.** `D-float-field` closes with "the wrong field for money — hold that
2062
+ as `Integer` cents"; this is the field that makes the honest answer available.
2063
+ `BigDecimal`, though, is not a language built-in: it was a *default* gem
2064
+ through Ruby 3.3 and became a **bundled** gem in 3.4, so from 3.4 on a Bundler
2065
+ app must name it in its `Gemfile` or `require "bigdecimal"` raises.
2066
+
2067
+ **Decision — ship it as an optional dependency, not a gemspec entry.**
2068
+ RubyGems has no optional/extras scope (no Maven `provided`, no Python extras),
2069
+ so the mechanism is convention: `lib/tuile/component/big_decimal_field.rb`
2070
+ carries the `require` itself, and Zeitwerk's laziness confines the cost — an
2071
+ app that never names the constant never executes the file. Three pieces make
2072
+ that hold, and all three are load-bearing:
2073
+ - The `require` is wrapped in a `rescue LoadError` that re-raises with the
2074
+ actual fix (`gem "bigdecimal"`), since the bare message ("cannot load such
2075
+ file") explains nothing about a gem that *is* installed but unbundled.
2076
+ - `loader.do_not_eager_load` on that one file, so a host app calling
2077
+ `Zeitwerk::Loader.eager_load_all` — which Rails-shaped apps do — doesn't
2078
+ raise on a component it never asked for. Pinned by a subprocess spec that
2079
+ eager-loads everything and asserts `$LOADED_FEATURES` stays free of it.
2080
+ - The `require` **must not** be hoisted into `lib/tuile.rb` with the other
2081
+ gem-level requires; that would impose the load on every user and defeat the
2082
+ whole arrangement. This is the exception AGENTS.md's no-requires rule is
2083
+ worded for.
2084
+ The accepted cost, stated plainly: the failure moves from `bundle install` to
2085
+ first use, so a missing gem surfaces mid-render in a raw-mode terminal rather
2086
+ than at boot. Worth it for one opt-in component; **not** a licence to make
2087
+ this Tuile's default posture — a second optional dependency needs its own
2088
+ argument.
2089
+
2090
+ **Decision — normalize and format on both ends, rather than trusting
2091
+ `bigdecimal`.** Two of the three inputs behave differently across the versions
2092
+ Tuile supports: `bigdecimal` 3.1 (Ruby 3.3's default gem) *rejects*
2093
+ `BigDecimal("1.")` and `BigDecimal(0.1)`, while 4.x accepts both. So the field
2094
+ does its own work: a half-typed buffer is normalized (`".5"`→`"0.5"`,
2095
+ `"1."`→`"1"`) before parsing, and display goes through `to_s("F")` — plain
2096
+ notation, since `BigDecimal#to_s` writes `"0.1999e2"` for `19.99` and would put
2097
+ engineering notation in a form. The field's behavior is therefore identical on
2098
+ both, instead of tracking whichever parser the host resolved. Honest gap: the
2099
+ `Gemfile` resolves 4.x, so CI only ever exercises that one — 3.1 was verified
2100
+ by hand, and the normalization is what makes the difference unreachable rather
2101
+ than merely tested.
2102
+
2103
+ **Decision — a `Float` is refused, not converted.** `field.value = 19.99`
2104
+ raises with a message naming the fix (`BigDecimal("19.99")`). The literal has
2105
+ already lost the decimal by the time it reaches the setter, and a field whose
2106
+ entire purpose is exactness should not be the place that quietly papers over
2107
+ it. That 4.x *would* accept it (via a shortest-round-trip conversion) and 3.1
2108
+ would not is the second reason: silently version-dependent precision is worse
2109
+ than a loud refusal. `Integer` and `String` coerce as normal.
2110
+
2111
+ **Decision — the buffer is still never rewritten.** `"19.90"` keeps its
2112
+ trailing zero and `"007"` its leading ones, exactly as in the other two numeric
2113
+ fields: a display *scale* (pad to 2 decimals) is formatting, and formatting is
2114
+ the forms layer's, parked with `min`/`max`. Note the one place this shows
2115
+ through the value seam: `"1.0"`→`"1.00"` fires nothing, because the two
2116
+ `BigDecimal`s compare equal.
2117
+
2118
+ **Alternatives rejected.**
2119
+ - *A hard `spec.add_dependency "bigdecimal"`:* makes every Tuile app carry a
2120
+ gem for a component most won't use — and Tuile's dependency list is
2121
+ otherwise TTY primitives and a loader.
2122
+ - *Accept a `Float` by converting through `to_s`:* that is a precision policy
2123
+ ("shortest decimal that round-trips") hidden inside a setter. If it is ever
2124
+ wanted, it belongs at the call site, where it is visible.
2125
+ - *A `scale=` / `decimals=` knob to pad the display:* it would have to rewrite
2126
+ the buffer under the caret while typing (`19.9` → `19.90` mid-edit), which
2127
+ needs a blur/commit point a TUI lacks — the same reason `D-integer-field`
2128
+ gave for not normalizing.
2129
+ - *A settable `step=`:* `D-float-field` rejected it over binary-float noise,
2130
+ which genuinely doesn't apply here (`BigDecimal` steps exactly). Kept out
2131
+ anyway, so the three numeric fields stay one shape; this is the field to
2132
+ revisit first if the knob is ever wanted.
2133
+
2134
+ ---
2135
+
2136
+ ## D-box-layouts — `Vertical` / `Horizontal`: declarative sugar with no `Auto` (2026-08-07)
2137
+
2138
+ **Status:** Accepted; implemented 2026-08-07 (`Component::Layout::Box`,
2139
+ `::Vertical`, `::Horizontal`, and the `Fixed` / `Percent` / `Expand` / `Insets`
2140
+ value types on `Layout`). Book ch3 pre-approved the shape and named the
2141
+ acceptance criterion — "added if and when the convenience pays for itself" —
2142
+ so what this entry records is that it did, and every choice inside it.
2143
+
2144
+ **Context.** `Layout::Absolute` was the only container: you override `rect=`
2145
+ and compute each child's rectangle. That is right for genuinely
2146
+ two-dimensional geometry and tedious for a stack. `examples/sampler.rb` carried
2147
+ **59 `Rect.new` sites**, dominated by vertical stacks with hand-accumulated
2148
+ offsets (`inner.top + 1`, `+ 4`, `+ 6`, `+ 8`, `+ 10`, `+ 12` in the
2149
+ PasswordField pane alone — renumbered by hand whenever a prompt gained a line),
2150
+ plus hand-rolled expansion (`[inner.height - 8, 2].max`) and hand-rolled cross
2151
+ clamps (`[inner.width, 30].min`). The cost was not that hand-rolling is
2152
+ impossible but that the code newcomers read to *learn* Tuile demonstrated the
2153
+ tedious version. The port took the sampler to 7 `Rect.new`.
2154
+
2155
+ **Decision — the vocabulary is `Fixed` / `Percent` / `Expand`, and there is no
2156
+ `Auto`.** Shrink-to-fit is the bottom-up `content_size` channel deleted in
2157
+ v0.9.0, and AGENTS.md's re-grow rule allows measurement back only as an
2158
+ optional, caller-side query. So urwid's `PACK`, CSS `auto`, FTXUI's non-`flex`
2159
+ default and Swing's `GroupLayout.PREFERRED_SIZE` are all out by construction.
2160
+ **This single omission is what keeps the feature sugar rather than a reopened
2161
+ wound:** a box is an `Absolute` subclass with a `rect=` override — no new
2162
+ dispatch phase, no framework hook, no child consultation — so it deletes
2163
+ cleanly if it ever fails to earn its place.
2164
+
2165
+ **Decision — alignment is legal because the cross extent is caller-supplied.**
2166
+ `align: :start | :center | :end` *looks* like it needs the child's width, which
2167
+ would be `content_size` again. It doesn't: it needs *a* width, and a `cross:`
2168
+ constraint provides one, so there is nothing to measure. This is the
2169
+ reframing that unblocked the cross axis after it had been parked as
2170
+ undesignable. Corollary: `:start/:center/:end` rather than
2171
+ `:left/:right` + `:top/:bottom`, because one concept should not have two
2172
+ vocabularies across the two classes.
2173
+
2174
+ **Decision — `Expand`, not `Fill`.** Every toolkit that models *both* concepts
2175
+ reserves *fill* for cross-axis stretch, not for claiming slack: GTK's
2176
+ `pack_start(child, expand, fill, padding)` takes them as separate booleans and
2177
+ `fill` only acts when `expand` is already true; Swing splits them as `weightx`
2178
+ vs `fill`; JavaFX as `setHgrow` vs `fillHeight`. Vaadin 8 names only the first
2179
+ and calls it `setExpandRatio`. Naming our main-axis constraint `Fill` would
2180
+ therefore use the industry's word for cross-axis stretch — sitting right next to
2181
+ `Percent[100]`, the thing that actually stretches. `Expand` also leaves `Fill`
2182
+ permanently free, so it can never return as a confusing near-synonym. (ratatui
2183
+ does call it `Fill` and CSS `flex-grow`; neither models the stretch concept
2184
+ separately, so neither had the collision to avoid.)
2185
+
2186
+ **Decision — defaults are `Fixed[1]` on the main axis and `Percent[100]`
2187
+ across it.** `Fixed[1]` because forms are the use case and almost every field is
2188
+ one row tall — the same reason Vaadin 8 bumps everything to the top by default.
2189
+ `Percent[100]` rather than `Expand[1]` because the cross axis holds exactly one
2190
+ child per slot, so nothing competes and a weight has nothing to mean there;
2191
+ **`Expand` therefore raises when passed as `cross:`**, which makes "what would
2192
+ `Expand[2]` mean across the axis?" unaskable rather than merely undocumented.
2193
+ JavaFX reached both defaults independently (`VBox.fillWidth` is `true`,
2194
+ alignment is `Pos.TOP_LEFT`).
2195
+
2196
+ **Decision — `spacing` and `padding` are box-global, never per-child.** Beyond
2197
+ brevity: *a gap between two items is a property of the sequence, not of either
2198
+ child*, so a per-child gap has an unresolvable ownership question — does child N
2199
+ own the gap after it, or child N+1 the gap before it? Both conventions exist and
2200
+ both confuse. Non-uniform gaps are expressed by **nesting** instead: a
2201
+ `Vertical.new(spacing: 0)` inside a `Vertical.new(spacing: 1)` groups rows
2202
+ tightly within a looser stack, which *states* the grouping rather than faking it.
2203
+ `GridBagConstraints.ipadx`/`ipady` is the per-child version, and that class —
2204
+ eleven fields, and the layout manager everyone agrees is hardest to learn — is
2205
+ the named tripwire for this tuple growing past three.
2206
+
2207
+ **Decision — `Percent` and `Expand` divide space that is actually available**
2208
+ (`extent - padding - spacing * (children - 1)`), so two `Percent[50]` children
2209
+ fit exactly instead of overflowing by the gap between them.
2210
+
2211
+ **Decision — the weighted-`Expand` remainder goes to the earliest children, one
2212
+ cell each.** Five equal `Expand`s in 12 rows give `3,3,2,2,2`. Auditable in one
2213
+ sentence, exact sum structural (`base * n + remainder == total`), and leftmost-
2214
+ first is the ecosystem convention (CSS `flex-grow`, ratatui `Fill`, urwid
2215
+ `weight`) so a user coming from elsewhere guesses right.
2216
+
2217
+ **Decision — over-subscription starves in declaration order; it never raises.**
2218
+ `Fixed` and `Percent` clamp to what is unassigned, so a child with nothing left
2219
+ gets an empty rect and paints nothing (`Rect#empty?` already covers zero *and*
2220
+ negative). Padding wider than the layout does the same to every child. No error,
2221
+ no solver, no reflow.
2222
+
2223
+ **Decision — `Insets` is keyword-only.** `java.awt.Insets` orders the four
2224
+ numbers top-left-bottom-right and `javafx.geometry.Insets` top-right-bottom-left
2225
+ — the same class name and the same four numbers, silently different: a live
2226
+ migration bug between two toolkits *in the same language*. `Insets[top: 1]` has
2227
+ no order to get wrong. `Data`'s inherited `[]` never dispatches through a `new`
2228
+ override, so both class methods carry the guard (found by the spec, not by
2229
+ reading).
2230
+
2231
+ **Decision — `Box` is a shared base, against the duplicate-don't-DRY rule.**
2232
+ `D-float-field` says duplicate rather than fold a *shallow* commonality into a
2233
+ base. This isn't shallow: the greedy pass is substantial and byte-for-byte
2234
+ identical except for which of `(left, top)` / `(width, height)` it reads, so
2235
+ `Box` parameterizes it behind two private hooks and `Vertical` / `Horizontal`
2236
+ are ~10-line concretes. That is the sanctioned cohesive base
2237
+ (`AbstractMasterDetail`), not an `AbstractView` junk drawer.
2238
+
2239
+ **Alternatives rejected.**
2240
+ - *A constraint attribute on `Component` (`child.layout_constraint = …`):*
2241
+ `content_size` wearing a hat. Even with the parent still doing the arithmetic,
2242
+ it re-establishes "the child declares its size wish", and every non-layout
2243
+ parent would have to ignore it. The constraint belongs to the parent–child
2244
+ *relationship*, which is why it lives at the `add` call. **JavaFX is this
2245
+ option in production and confirms the cost:** `HBox.setHgrow(node, …)` stores
2246
+ the constraint on the node (hence `HBox.clearConstraints`), so you must recall
2247
+ which container's static setter applies and a reparented node silently keeps
2248
+ stale constraints.
2249
+ - *A block-valued cross constraint (`Left { |avail| [avail, 30].min }`):*
2250
+ permitted by the re-grow rule, but no case needs it — `Fixed` already clamps to
2251
+ available, which is exactly the `[inner.width, 30].min` the sampler wrote by
2252
+ hand. A block is un-inspectable, awkward to spec, and `Absolute` remains the
2253
+ escape hatch for a genuinely computed width.
2254
+ - *"Last `Expand` absorbs the remainder":* matches ch3's hand-written idiom and
2255
+ guarantees an exact sum structurally, but degrades badly past two children —
2256
+ five equal `Expand`s in 12 rows floor to 2 each and dump **4** on the last, a
2257
+ visible 2× discrepancy, which is ch3's "one cell off is plainly visible on a
2258
+ character grid" amplified rather than avoided.
2259
+ - *Trailing-first one-at-a-time (`2,2,2,3,3`):* same fairness, and it would match
2260
+ ch3's remainder-to-the-right for the two-child case. Genuinely close; lost to
2261
+ ecosystem convention. **Known consequence:** for two children the layout gives
2262
+ the spare cell to the left/top while ch3's hand-written example gives it to the
2263
+ right. Different mechanisms, no shared code; ch3 says so.
2264
+ - *Largest-remainder / Hare quota:* fairest, least auditable — reverse-
2265
+ engineering which child got the extra cell is precisely the solver opacity ch3
2266
+ rejects.
2267
+ - *Priority tiers instead of weights (JavaFX `Priority.ALWAYS/SOMETIMES/NEVER`):*
2268
+ sidesteps remainder arithmetic entirely, but cannot express a 1:2 split, which
2269
+ is what a sidebar wants. Vaadin 8's ratio and CSS `flex-grow` both chose
2270
+ weights.
2271
+ - *`Min` / `Max` constraints:* deliberately not shipped, and the sampler shows
2272
+ what that costs — its main split (`(width / 3).clamp(20, 40)`) and its two
2273
+ sidebars (`min(16, width / 3)`) are caps on a *proportion*, unsayable in three
2274
+ constraints, so they keep a rect-callback `Absolute`. That is the intended
2275
+ division of labour: only the part needing arithmetic has any. Revisit only if
2276
+ capped proportions turn out to be common.
2277
+ - *`BorderLayout` / `BorderPane` / Textual's `dock:`:* nesting
2278
+ `Vertical(Fixed, Expand, Fixed)` covers it, and `ScreenPane` (content + status
2279
+ bar) already *is* one, hard-coded.
2280
+ - *Swing glue (`Box.createVerticalGlue`, `createRigidArea`, struts):* invisible
2281
+ filler *components*, needed only because `BoxLayout` has no per-child weight
2282
+ and doesn't pack from the start. Packing from the start plus `Expand` needs
2283
+ none, and grouped gaps are handled by nesting.
2284
+ - *Baseline alignment (Swing's `anchor` has `BASELINE`, `ABOVE_BASELINE_LEADING`,
2285
+ …):* a text-*rendering* concept. Every row of a character grid shares one
2286
+ baseline, so it is meaningless here.
2287
+ - *A full engine (Textual's CSS, Ink's embedded Yoga, ratatui's Cassowary
2288
+ solver):* those frameworks must ship one — Ink and Textual are retained-mode
2289
+ declarative, where the author never sees a rect, and ratatui's `Layout::split`
2290
+ is the only way to obtain one. Tuile hands the author coordinates, so **once
2291
+ `rect=` exists a layout is strictly optional sugar**, declinable per component,
2292
+ which none of them can offer. (This nuances ch3's "validated by the ecosystem":
2293
+ simple layout is validated by TUI *app architecture*, not by framework feature
2294
+ sets.)
2295
+
2296
+ **Consequences.**
2297
+ - Vaadin 8's perennial support question — *"`setExpandRatio` does nothing"*,
2298
+ answered by "the child also needs `setSizeFull()`" — exists precisely because a
2299
+ Vaadin 8 component has **both** its own size and an expand ratio: two size
2300
+ channels that must agree. Tuile cannot have that bug, because there is no
2301
+ component-side size to disagree with the constraint. The most common confusion
2302
+ in the toolkit we took `Expand` from is a direct consequence of the channel
2303
+ v0.9.0 deleted.
2304
+ - A future `Layout::Grid` should reuse `Fixed`/`Percent`/`Expand` verbatim per
2305
+ row and column, as JavaFX's `ColumnConstraints(percentWidth, hgrow)` does,
2306
+ rather than inventing a second vocabulary. That is also the path to the Form
2307
+ Layout `ideas/new-components.md` wants — which is blocked on a field
2308
+ label/helper seam, not on layout.
2309
+
2310
+ ---
2311
+
2312
+ ## D-wrap-leading-space — An indent is content; no flag, and no hanging indent (2026-08-12)
2313
+
2314
+ **Status:** Accepted; implemented 2026-08-12 (`StyledString#wrap_one`). Fixes
2315
+ [issue #2](https://github.com/mvysny/tuile/issues/2). The continuation half —
2316
+ hanging indent — is deliberately deferred, see the last section.
2317
+
2318
+ **Context.** `wrap_one` dropped a leading whitespace run whenever `line_w` was
2319
+ zero, which is equally true at the start of the *first* row as at the start of
2320
+ a continuation. So an indent never survived, even when the line fit the width
2321
+ and no wrapping happened at all. Since every `TextView` line goes through
2322
+ `wrap`, indented text could not be displayed: the downstream report was a
2323
+ nested agent/tool tree flattened into an ambiguous list, siblings and children
2324
+ indistinguishable and repeated leaf names reading as duplicates.
2325
+
2326
+ **Decision — this is a bug, patched in place; no opt-in flag.** `wrap`'s own
2327
+ rdoc already promised the fixed semantics ("leading whitespace dropped on
2328
+ wrapped *continuations*"), so the code was not implementing a design, it was
2329
+ missing a condition. Every widely-used wrapper agrees, and they differ only on
2330
+ what happens to continuations — the half Tuile already had right:
2331
+
2332
+ | Implementation | First-line indent | Continuation |
2333
+ |---|---|---|
2334
+ | Python `textwrap` (`drop_whitespace`) | kept — the docs carve it out explicitly | dropped |
2335
+ | CSS `pre-wrap` | kept | hangs past the margin |
2336
+ | GNU `fmt`, Emacs adaptive-fill | kept | **reused as the prefix** |
2337
+ | `fold -s` | kept (whitespace untouched) | kept |
2338
+ | Rust `textwrap`, Go wordwrap | kept (`initial_indent`) | `subsequent_indent` |
2339
+
2340
+ CSS `white-space: normal` is the one that looks like a counter-example and is
2341
+ not: eating the indent happens in the **collapsing** stage, which also squashes
2342
+ every interior run to a single space. Tuile does not collapse (`"one two"`
2343
+ keeps both spaces when they fit), so it is in the `pre-wrap` family, and doing
2344
+ half of collapsing — eat the indent, keep interior runs — was the incoherence.
2345
+
2346
+ **A flag was rejected on three counts.** It has no defensible default
2347
+ (default-preserve is the patch plus dead config; default-drop keeps the bug
2348
+ reachable and makes every caller learn a piece of trivia); `TextView` calls
2349
+ `wrap` itself with the viewport width, so a flag on `StyledString#wrap` is
2350
+ useless until mirrored as a `TextView` setter, turning one wart into two knobs
2351
+ across two layers; and the blast radius of just fixing it is confined to
2352
+ strings whose first row opens with space or tab, with `TextView` the sole
2353
+ in-gem caller.
2354
+
2355
+ **Decision — an over-wide indent is dropped, not given a row.** An indent that
2356
+ alone exceeds `width` folds into the same guard
2357
+ (`line_w.zero? && (!result.empty? || w > width)`) rather than falling through
2358
+ to the flush branch, which emitted an empty leading row. An indent wider than
2359
+ the viewport conveys no nesting, so losing it beats spending a row on it.
2360
+
2361
+ **Decision — whitespace-only input is preserved, diverging from Python.**
2362
+ `plain(" ").wrap(5)` now returns `[" "]` rather than `[""]`. Python drops
2363
+ it (its rule is "not dropped *if non-whitespace follows*"), but matching that
2364
+ needs a lookahead and buys nothing visible: `TextView#pad_to` pads to width, so
2365
+ the two render identically. The simpler rule — the first row keeps its leading
2366
+ run, period — wins.
2367
+
2368
+ **Deferred: the hanging indent.** A continuation still starts at column 0, so a
2369
+ leaf long enough to wrap re-lies about the tree — worse than the flattening,
2370
+ since a wrapped fragment of a deep leaf looks exactly like a new top-level
2371
+ entry. There is no app-side workaround (`TextView` owns the width and calls
2372
+ `wrap` internally, so a caller cannot wrap at `width - indent` and prefix). It
2373
+ is left out of this entry because it is a genuine behavior decision of its own:
2374
+ auto-inherit the first row's whitespace run as the continuation prefix, à la
2375
+ `fmt`/Emacs, versus an explicit knob that would again need mirroring on
2376
+ `TextView`. Current lean is auto with no flag — prose carries no leading space,
2377
+ so it is a no-op there, and the indented case is the only one with an opinion.
2378
+
2379
+ ---
2380
+
2381
+ ## D-select — `Select`: the enum field, claiming no printable key but Space (2026-08-12)
2382
+
2383
+ **Status:** Accepted; `Component::Select` implemented 2026-08-12, demoed in the
2384
+ sampler. Builds on `D-has-value`, `D-combobox` (the chrome/value split and the
2385
+ resolve-don't-store-an-index rule, both adopted verbatim), `D-radio-group` (the
2386
+ cursor-is-chrome rule) and `D-ambiguous-width`.
2387
+
2388
+ **Context.** A one-row closed-choice field: a face showing the selected item's
2389
+ label plus a `▾`, dropping open a `ListDropdown` of the options. `D-combobox`
2390
+ deferred it once ("filterable first"), on the assumption that it needed the
2391
+ read-only-field axis `D-has-value` parked for the forms layer. That assumption
2392
+ was an artifact of picturing a read-only `TextField` as the face; nothing gates
2393
+ this component.
2394
+
2395
+ **Decision — the criterion is enum vs. data, not item count.** A Select is for
2396
+ labels the *developer* authored: a closed set, stable order, known when the code
2397
+ is written (log level, sort order, line endings, Yes/No/Ask). A `ComboBox` is for
2398
+ items the app supplies at runtime, open-ended, with labels you don't control
2399
+ (countries, users, branches). Count is a *symptom*: a 12-value enum is still a
2400
+ Select, and a three-row country list from a DB is still a ComboBox, because next
2401
+ release it is 200 rows and the widget choice must not have to change. The
2402
+ discarded rule — "≤ 7 items → Select" — is actively harmful: it invites that
2403
+ country list in, which is how the type-ahead hole below was found.
2404
+
2405
+ **Decision — it claims no printable key but Space.** Enter, Space, ESC,
2406
+ `ListDropdown::MOVE_KEYS` and the mouse; *every other* printable bubbles past it
2407
+ to the app (key-dispatch rung 3). That is the capability unreachable by
2408
+ configuring a `ComboBox`, whose field eats printables unconditionally, and it is
2409
+ worth more than the type-ahead it replaces: a form's `s`-to-save and a layout's
2410
+ `1`/`2`/`3` pane jumps keep working while focus sits in a Select. Combined with
2411
+ having no caret — the strongest affordance a TTY has, not to be spent promising
2412
+ free-text entry over a four-value enum — that is the whole case for the
2413
+ component existing next to `RadioGroup`.
2414
+
2415
+ Space is safe as the single exception because **it was never available as a
2416
+ bubble key anyway**: `Button`, `Checkbox` and `RadioGroup` all already claim it,
2417
+ so no app can rely on it reaching past an interactive widget. Contrast a letter
2418
+ like `g`, which reaches the app from every one of those and is exactly what the
2419
+ rule protects. Space mirrors Enter throughout (opens when closed, commits when
2420
+ open), as on `Button`/`Checkbox`; `RadioGroup` claiming Space but not Enter is
2421
+ inherent — it has no open/closed state to move between — not an inconsistency.
2422
+
2423
+ **Decision — Home/End are declined, and `MOVE_KEYS` is unchanged.** They stay
2424
+ reaching the app, which `Screen::EDITING_KEYS` deliberately allows ("binding them
2425
+ app-wide to scroll the log pane is a real use case"). The PgUp/PgDn asymmetry is
2426
+ principled: those arrive *free* inside `MOVE_KEYS` and do real work on a dropdown
2427
+ that scrolls, whereas Home/End would need Select-side branches to do what a second
2428
+ arrow press already does. This also resolves what read as an open question in
2429
+ `ListDropdown`'s rdoc: the *exclusion* survives, the *rationale* doesn't — "they
2430
+ belong to the driving field, for caret movement" is a ComboBox policy, not a
2431
+ property of dropdowns. The driver decides, and both drivers decline.
2432
+
2433
+ **Decision — `Select` paints its own row; it composes no field.** A leaf widget
2434
+ (`< Component` + `HasValue`, `tab_stop? = true`, no children) that owns the
2435
+ dropdown as an overlay. Two consequences worth naming:
2436
+
2437
+ - **The face is *derived* from `value` at paint time, never a synced copy.** A
2438
+ `Label` child would have meant a second copy of the face text to keep in step
2439
+ from `value=`, `item_label=` and construction — the drift `ComboBox` pays for
2440
+ only because its field is genuinely editable and holds a *query*. Nothing here
2441
+ needs that, so nothing here has it. The well is read from the theme each paint
2442
+ for the same reason.
2443
+ - **The tab-stop rule stays ordinary.** The composing wrappers (`ComboBox`,
2444
+ `IntegerField`, the groups) leave `tab_stop?` false because their inner widget
2445
+ carries the stop; a Select has no inner widget, so it claims the stop itself,
2446
+ exactly as `Checkbox` does. Had the face been an (inert, non-tab-stop) `Label`
2447
+ child, Select would have been the first composing wrapper needing to claim the
2448
+ stop anyway — the letter of the rule reversed to preserve its purpose. Not
2449
+ having the child removes the wrinkle instead of documenting it.
2450
+
2451
+ **Decision — promote `ComboBox#anchor` to `ListDropdown#anchor_to`.** Select needs
2452
+ byte-identical vertical geometry, and `D-float-field`'s duplicate-don't-DRY rule
2453
+ **does not apply**: that licensed copying a *shell* around three genuine
2454
+ differences, whereas this is the same computation with zero differences, so a
2455
+ later fix to the flip rule would land in one copy and silently not the other —
2456
+ and the symptom appears only near a screen edge, which is invisible under test.
2457
+ The promotion threshold is the project's existing one (`D-color-slots`: "a
2458
+ *second* built-in needing the same thing"). Two rulings ride along:
2459
+
2460
+ - **Width stays a caller-supplied parameter** (defaulting to the anchor's), so
2461
+ `ComboBox` keeps its lines-up-with-the-field policy and Select keeps its
2462
+ measured one, and `anchor_to` never measures content itself. Same shape as
2463
+ `D-box-layouts`' "`align:` is legal only because the cross extent is
2464
+ caller-supplied", and it keeps Select's measuring within the top-down re-grow
2465
+ rule: an optional, caller-side query feeding a rect the caller then assigns.
2466
+ - **Horizontally we slide, vertically we flip.** Covering the driver would hide
2467
+ the value being chosen, so vertically there are only above and below; sharing
2468
+ the driver's columns is exactly what's wanted, so an overrun slides left and
2469
+ keeps the left edges aligned. A horizontal flip would either overlap the face
2470
+ or leave a gap. A label wider than the screen clips — `List` has no horizontal
2471
+ scrolling.
2472
+
2473
+ This is deliberately *not* the full anchored-Popover extraction, which wants
2474
+ generalizing for callers whose anchoring genuinely differs (a context menu
2475
+ anchors to a *point*, a submenu to a right edge with horizontal flipping). Build
2476
+ Popover when the second *kind* of anchoring appears, not the second caller of the
2477
+ same kind; `anchor_to` then moves down to it with nothing thrown away.
2478
+
2479
+ **Decision — a scrolling `ListDropdown` gets a scrollbar** (a `ComboBox` fix
2480
+ shipped in the same work, and the only non-additive part of it). `anchor_to` owns
2481
+ the toggle, being the one place that knows both the row count and the height it
2482
+ just chose. *Rejected: an `:auto` mode on `List`.* It looks like the general fix
2483
+ and carries a silent corruption — visibility would become a function of
2484
+ `rect.height`, but the padded-line cache is rebuilt from `on_width_changed`, a
2485
+ width-only hook, so a height-only resize would flip the scrollbar, shrink
2486
+ `content_width`, and leave every row padded to the old width: one column off,
2487
+ no exception, nothing in the diff to notice. Making it safe means a height-change
2488
+ hook and a wider cache-invalidation surface for every `List` in the gem, to serve
2489
+ two callers that already know the answer.
2490
+
2491
+ **Alternatives rejected.**
2492
+ - *Prefix type-ahead, single-key* (`g` jumps to the first item starting with
2493
+ `g`): silently wrong. With Finland / Fiji / Jamaica, typing `fij` selects
2494
+ *Jamaica* — each key is a fresh single-char match — and nothing tells the user
2495
+ anything went wrong.
2496
+ - *Prefix type-ahead, timed accumulating buffer* (the standard GUI fix: `JList`,
2497
+ GTK, Finder): it **is** the ComboBox query, hidden. A buffer that filters the
2498
+ candidate set is a query string; concealing it and clearing it on a timer makes
2499
+ it worse, not lighter, and reintroduces the second piece of state Select exists
2500
+ to avoid. If you are holding query state, showing it is strictly better — and
2501
+ showing it is a ComboBox. Worse here than in a GUI for a TUI-specific reason:
2502
+ the timeout leans on inter-keystroke timing, and a terminal degrades exactly
2503
+ that signal (bytes arriving in one read burst merge into a single key; a paste
2504
+ has no gaps at all). Retiring type-ahead also retires the "make labels
2505
+ prefix-unique" workaround that existed only to rescue it.
2506
+ - *Cycle-in-place* (`◂ Dark ▸`, Space/Left/Right, no popup) for 2–4 options: you
2507
+ select blindly. The values you are choosing *between* are never on screen — you
2508
+ discover them one at a time by cycling, with no way to see the set or know how
2509
+ many there are. The dropdown is better at every item count, so the
2510
+ `ListDropdown` face is the only face, and the vocabulary does not grow a fourth
2511
+ closed-choice widget (cf. `D-box-layouts`' "there is no `Auto`"). *Re-grow
2512
+ rule:* if it returns it is a **face** on this component (a `dropdown: false`
2513
+ knob over the identical value seam), never a separate component, and it needs a
2514
+ real argument about visibility rather than a row-budget one.
2515
+ - *A read-only `TextField` as the face* (the survey's framing): a read-only text
2516
+ field is still a text field — the inherent-bg well, the caret machinery, the
2517
+ horizontal scroll window, and an opt-out from `bg_color` inheritance. None of
2518
+ it is wanted, and none of it has to be reasoned about once the widget paints
2519
+ one row itself.
2520
+ - *A shared base with `RadioGroup`* (`AbstractClosedChoiceField`): the ~15-line
2521
+ `items=` / `item_label=` / `label_for` shell is duplicated instead, per
2522
+ `D-float-field`. The test is whether the commonality is a *shell around genuine
2523
+ differences* or the *same computation* — `anchor_to` is the latter (extract), the
2524
+ items shell is the former (duplicate). The three differences a base would have
2525
+ to paper over with hooks: row rendering (`(*) label` glyphs vs. a bare label,
2526
+ since a Select shows its selection on the *face*), cursor semantics (roams and
2527
+ Space commits the row it's on, vs. the highlight *being* the pending selection),
2528
+ and where the rows live (always, in the component's own rect, vs. only while
2529
+ open, in a `Popup`'s). Three hooks over fifteen lines, reached through
2530
+ inheritance, is the converter-strategy-by-inheritance shape `D-float-field`
2531
+ rejected — and it would couple two widgets that should stay free to diverge.
2532
+ This is the third copy of that shell, the same count `IntegerField` /
2533
+ `FloatField` / `BigDecimalField` reached; a *fourth* is when to re-argue it.
2534
+
2535
+ **Consequences.**
2536
+ - *Empty value* (`value = nil`, items present) is legal and normal — the optional
2537
+ enum field — so there is no placeholder string: a blank face plus the `▾`, and
2538
+ the dropdown opens with the highlight on row 0.
2539
+ - *Empty items* does not open a dropdown at all, keeping `ComboBox`'s auto-close
2540
+ behavior: a 10-row empty tinted panel reads as a broken list rather than as
2541
+ "nothing to pick". An item-less Select is almost always a programming bug, not
2542
+ a state to design a UI for, so nothing is spent on it beyond not misleading the
2543
+ user — no placeholder row, no "(no items)" label, no status hint. A
2544
+ `Tuile.logger.warn` on the open attempt was considered and declined: the
2545
+ attempt is keystroke-driven, so it would flood a host's log on autorepeat, and
2546
+ an app may legitimately pass through item-less while loading. Enter/Space/Down
2547
+ are claimed either way — one rule, no branch. (An item-less Select is arguably
2548
+ a *disabled* field, which touches the read-only/disabled axis `D-has-value`
2549
+ parked for the forms layer. Not designed here, not foreclosed either.)
2550
+ - The dropdown is measured to the widest label plus `List`'s **two** row gutters
2551
+ (`pad_to_row` ellipsizes to `content_width - 2`, one leading and one trailing
2552
+ column), plus the scrollbar column when the items outnumber the visible rows —
2553
+ and never narrower than the Select itself. The field width is a *floor* rather
2554
+ than an alternative to measuring: a panel narrower than its own face reads as an
2555
+ unrelated widget instead of as that field's menu (a ~8-column menu under a
2556
+ 30-column field, in the sampler), so the common case lines both edges up exactly
2557
+ as a `ComboBox`'s does and only an over-long label pushes it wider. A dropdown
2558
+ the *screen* clamps shorter still scrolls without having bought the scrollbar
2559
+ column, so its labels ellipsize one early — the `ComboBox` trade, in the one
2560
+ case measuring cannot predict.
2561
+ - A second driver **confirms** three of `ListDropdown`'s speculative rulings
2562
+ rather than straining them: ESC and Enter really do carry driver-specific tails
2563
+ (Select's ESC closes without committing and has no query to revert), the
2564
+ non-focusable `Menu` really does give the same re-entrancy safety
2565
+ `ComboBox#active=` leans on, and filtering / row rendering / the commit action
2566
+ really do vary.