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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +81 -36
- data/DECISIONS.md +2566 -0
- data/README.md +37 -24
- data/book/03-layout.md +153 -8
- data/book/04-event-loop.md +86 -0
- data/book/05-focus.md +84 -51
- data/book/06-theming.md +53 -1
- data/book/07-components.md +458 -9
- data/book/08-testing.md +21 -8
- data/book/09-styled-text.md +132 -0
- data/book/README.md +22 -14
- data/examples/sampler.rb +632 -67
- data/ideas/arrow-key-navigation.md +205 -0
- data/ideas/new-components.md +118 -0
- data/ideas/per-component-buffers.md +55 -0
- data/lib/tuile/buffer.rb +52 -51
- data/lib/tuile/color.rb +4 -10
- data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
- data/lib/tuile/component/big_decimal_field.rb +199 -0
- data/lib/tuile/component/button.rb +25 -21
- data/lib/tuile/component/checkbox.rb +134 -0
- data/lib/tuile/component/checkbox_group.rb +188 -0
- data/lib/tuile/component/combo_box.rb +263 -0
- data/lib/tuile/component/float_field.rb +161 -0
- data/lib/tuile/component/has_caption.rb +44 -0
- data/lib/tuile/component/has_content.rb +5 -5
- data/lib/tuile/component/has_value.rb +64 -0
- data/lib/tuile/component/integer_field.rb +135 -0
- data/lib/tuile/component/label.rb +13 -10
- data/lib/tuile/component/layout/box.rb +316 -0
- data/lib/tuile/component/layout/horizontal.rb +40 -0
- data/lib/tuile/component/layout/vertical.rb +41 -0
- data/lib/tuile/component/layout.rb +149 -15
- data/lib/tuile/component/list.rb +8 -7
- data/lib/tuile/component/list_dropdown.rb +157 -0
- data/lib/tuile/component/password_field.rb +105 -0
- data/lib/tuile/component/popup.rb +10 -14
- data/lib/tuile/component/progress_bar.rb +278 -0
- data/lib/tuile/component/radio_group.rb +188 -0
- data/lib/tuile/component/select.rb +251 -0
- data/lib/tuile/component/text_area.rb +189 -65
- data/lib/tuile/component/text_field.rb +170 -32
- data/lib/tuile/component/text_view.rb +57 -114
- data/lib/tuile/component/window.rb +32 -47
- data/lib/tuile/component.rb +250 -87
- data/lib/tuile/event_queue.rb +14 -17
- data/lib/tuile/fake_event_queue.rb +11 -2
- data/lib/tuile/fake_screen.rb +4 -5
- data/lib/tuile/fraction.rb +6 -10
- data/lib/tuile/screen.rb +202 -104
- data/lib/tuile/screen_pane.rb +51 -41
- data/lib/tuile/styled_string.rb +125 -86
- data/lib/tuile/theme.rb +78 -41
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +4 -0
- data/sig/tuile.rbs +2962 -680
- 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.
|