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