tuile 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +150 -37
  3. data/COMPARISON.md +101 -0
  4. data/DECISIONS.md +4266 -226
  5. data/README.md +44 -24
  6. data/TERMINOLOGY.md +22 -7
  7. data/book/03-layout.md +17 -10
  8. data/book/05-focus.md +67 -3
  9. data/book/06-theming.md +153 -7
  10. data/book/07-components.md +643 -67
  11. data/book/08-testing.md +94 -0
  12. data/book/09-styled-text.md +3 -3
  13. data/book/10-locale.md +216 -0
  14. data/book/README.md +14 -5
  15. data/examples/file_commander.rb +1 -1
  16. data/examples/sampler.rb +402 -62
  17. data/ideas/arrow-key-navigation.md +2 -2
  18. data/ideas/binder.md +177 -0
  19. data/ideas/composite-field.md +77 -0
  20. data/ideas/focus-accent.md +116 -0
  21. data/ideas/form-layout.md +151 -0
  22. data/ideas/hover/probe.rb +241 -0
  23. data/ideas/hover/probe_spec.rb +82 -0
  24. data/ideas/hover.md +909 -0
  25. data/ideas/modal-backdrop.md +24 -0
  26. data/ideas/new-components.md +49 -29
  27. data/lib/tuile/buffer.rb +51 -3
  28. data/lib/tuile/color.rb +143 -0
  29. data/lib/tuile/color_depth.rb +80 -0
  30. data/lib/tuile/component/abstract_string_field.rb +106 -58
  31. data/lib/tuile/component/abstract_wrapping_field.rb +243 -0
  32. data/lib/tuile/component/big_decimal_field.rb +52 -79
  33. data/lib/tuile/component/button.rb +3 -3
  34. data/lib/tuile/component/checkbox.rb +3 -3
  35. data/lib/tuile/component/checkbox_group.rb +36 -20
  36. data/lib/tuile/component/combo_box.rb +68 -33
  37. data/lib/tuile/component/confirm_window.rb +442 -0
  38. data/lib/tuile/component/date_field.rb +322 -0
  39. data/lib/tuile/component/float_field.rb +57 -82
  40. data/lib/tuile/component/has_bad_input.rb +88 -0
  41. data/lib/tuile/component/has_caption.rb +8 -0
  42. data/lib/tuile/component/has_content.rb +43 -11
  43. data/lib/tuile/component/has_placeholder.rb +62 -0
  44. data/lib/tuile/component/has_validation.rb +115 -0
  45. data/lib/tuile/component/has_value.rb +28 -1
  46. data/lib/tuile/component/info_window.rb +64 -16
  47. data/lib/tuile/component/integer_field.rb +51 -78
  48. data/lib/tuile/component/label.rb +6 -38
  49. data/lib/tuile/component/layout/box.rb +87 -19
  50. data/lib/tuile/component/layout.rb +13 -13
  51. data/lib/tuile/component/list.rb +11 -6
  52. data/lib/tuile/component/list_dropdown.rb +22 -10
  53. data/lib/tuile/component/log_text_view.rb +71 -0
  54. data/lib/tuile/component/log_window.rb +13 -48
  55. data/lib/tuile/component/menu_bar/cascade.rb +3 -3
  56. data/lib/tuile/component/menu_bar.rb +5 -5
  57. data/lib/tuile/component/notification.rb +16 -34
  58. data/lib/tuile/component/overlay.rb +209 -0
  59. data/lib/tuile/component/popup.rb +59 -187
  60. data/lib/tuile/component/progress_bar.rb +1 -1
  61. data/lib/tuile/component/radio_group.rb +39 -22
  62. data/lib/tuile/component/select.rb +26 -10
  63. data/lib/tuile/component/slot.rb +54 -0
  64. data/lib/tuile/component/tab_sheet.rb +0 -11
  65. data/lib/tuile/component/tabs.rb +5 -5
  66. data/lib/tuile/component/text_area.rb +14 -8
  67. data/lib/tuile/component/text_field.rb +42 -15
  68. data/lib/tuile/component/text_view.rb +25 -8
  69. data/lib/tuile/component/time_field.rb +454 -0
  70. data/lib/tuile/component/window.rb +48 -59
  71. data/lib/tuile/component.rb +580 -54
  72. data/lib/tuile/event_queue.rb +21 -1
  73. data/lib/tuile/fake_screen.rb +37 -3
  74. data/lib/tuile/final.rb +75 -0
  75. data/lib/tuile/keys.rb +7 -0
  76. data/lib/tuile/locale.rb +851 -0
  77. data/lib/tuile/screen.rb +251 -55
  78. data/lib/tuile/screen_pane.rb +50 -44
  79. data/lib/tuile/styled_string.rb +40 -7
  80. data/lib/tuile/terminal_background.rb +74 -16
  81. data/lib/tuile/testing.rb +198 -0
  82. data/lib/tuile/theme.rb +100 -10
  83. data/lib/tuile/version.rb +1 -1
  84. data/lib/tuile/vertical_scroll_bar.rb +88 -12
  85. data/lib/tuile.rb +1 -0
  86. data/sig/tuile.rbs +4545 -770
  87. metadata +25 -1
data/ideas/binder.md ADDED
@@ -0,0 +1,177 @@
1
+ # A Binder for Tuile — the pattern to copy, and the vocabulary that comes with it
2
+
3
+ **Status:** filed 2026-09-03 as a **placeholder plus a settled vocabulary**.
4
+ Nothing is designed here and nothing is implemented; the forms layer is not
5
+ started. Two things earn the file: (1) Vaadin's `Binder` is the pattern to
6
+ copy, and it is worth writing down *which parts* before anyone improvises one,
7
+ and (2) the four-layer terminology below was settled while designing
8
+ `HasBadInput` and has nowhere else to live until a `D_` entry exists — it is
9
+ the reason that channel is called `bad_input` and not `presentation_error`.
10
+
11
+ `D_has_value` is the standing authority on what belongs above the field:
12
+ "Model-mapping (presentation ⟷ domain) is left to a future forms/binder layer
13
+ *above* the field, never baked into field state", and it parks converters,
14
+ `read_only` and the required-indicator there too. Nothing here overrides that.
15
+
16
+ ## The terminology (settled — this is the file's real content)
17
+
18
+ Four layers, and the two arrows that matter. A `Date`-valued field bound to
19
+ `Person#birth_date`:
20
+
21
+ | layer | example | who speaks it |
22
+ |---|---|---|
23
+ | **model** | `Person#birth_date` | Binder only |
24
+ | **transformations** | `birth_year` + `birth_month` + `birth_day` → a `Date`; a `birth_date_iso_string` → a `Date` | Binder only |
25
+ | **value** | the `Date`, or `nil` — `HasValue#value` | **both** — the shared layer, which is why `HasValue` is *the* seam |
26
+ | **input** | the glyphs the user typed, a calendar click, a mask's partial fill: `"2020-05-01"`, `"xyz"` | the field owns it; the Binder must be able to *ask about* it |
27
+
28
+ | arrow | word | note |
29
+ |---|---|---|
30
+ | input → value | **parse** | *partial* — it can fail, and that failure is **bad input** (`D_bad_input`) |
31
+ | value → input | **format** | *total* — formatting a `Date` into glyphs cannot fail |
32
+ | the parse/format pair, inside a field | the field's **converter** | already the house word (`D_integer_field`: "the converter stays private and hardcoded"; `DECISIONS.md:2010`: a `parse`/`format` hook pair *is* the converter strategy) |
33
+ | model ⟷ value, in the Binder | a **transformation** / the Binder's converters | a *chain*, possibly several steps |
34
+
35
+ Why these words and not Vaadin's, in three lines:
36
+
37
+ - **`presentation` is unavailable.** Vaadin's `Converter<PRESENTATION, MODEL>`
38
+ uses it for the **value** layer — `convertToModel` "receives a value that
39
+ originates from the user", `convertToPresentation` one "that originates from
40
+ the business object" — so it never names the glyph layer at all, and
41
+ `D_has_value` already adopted the same axis in prose. `Date` is the tell: it
42
+ *is* "the presentation" in Binder-speak while saying nothing about
43
+ formatting.
44
+ - **A pair cannot name a chain.** model→value may be several transformations
45
+ (an old schema splitting a date across three columns), so naming one arrow
46
+ after the endpoints of a different chain is what made this confusing.
47
+ - **`input` had to be freed.** Tuile's rdoc used "an input" for the *widget*
48
+ (~40 real sites, plus `Theme#input_bg_color`). The rule going forward:
49
+ **"input" alone is what the user put in; the widget is always a `field`**,
50
+ never "an input" — Tuile already says `field` 188 times, and TERMINOLOGY
51
+ already calls that background a **well**, so the token name is grandfathered
52
+ legacy. The sweep is a standalone mechanical pass, deliberately not bundled
53
+ with an unimplemented design. Note ~30 further hits are the ordinary English
54
+ sense ("a renderer whose *inputs* changed", "both shorter and longer *inputs*
55
+ are bugs") and must **not** change.
56
+
57
+ **Reserved:** `model`, `transformations` and `presentation` belong to the
58
+ layers above `value`; `domain` is the word `D_has_value` uses for the topmost
59
+ one. Don't spend them on a field-level concept. At graduation the layer words
60
+ go to TERMINOLOGY.md (one line each, beside `text` and `caption`) and the
61
+ choice to a `D_` entry — a nomenclature ruling in the `D_scroll_nomenclature`
62
+ mould.
63
+
64
+ ## Why Vaadin's `Binder` is the pattern
65
+
66
+ It is the only one of the surveyed toolkits that keeps *form validity* as a
67
+ single source of truth without pushing rules into the widgets — which is
68
+ exactly the split Tuile has already committed to — the field reports what its
69
+ own parse could not represent and never judges (`D_bad_input`), and the verdict
70
+ lives in a slot only an outside validator writes (`D_has_validation`). The parts
71
+ worth copying, from v25.2:
72
+
73
+ - **`forField(field).withValidator(pred, message).bind(getter, setter)`** — a
74
+ builder per binding, rules declared beside the binding and nowhere else;
75
+ `asRequired("msg")` as the shorthand.
76
+ - **`withConverter`** for the model⟷value transformations, including a
77
+ conversion-error message, and chains where each step sees the previous
78
+ step's output.
79
+ - **`readBean` / `writeBean` / `writeBeanIfValid`**, with a write that refuses
80
+ when anything is invalid.
81
+ - **`isValid` / `hasChanges` / `validate` → `BinderValidationStatus`** as the
82
+ aggregate the app asks.
83
+ - **`binding.validate()`** for cross-field revalidation ("cannot return before
84
+ departing"), driven from the other field's value-change listener.
85
+ - **Escape hatches that exist for real reasons:** `setValidatorsDisabled`,
86
+ `setDefaultValidatorsEnabled(false)` / `withDefaultValidator(false)`, and
87
+ `setIsAppliedPredicate` for a binding that shouldn't participate at all.
88
+
89
+ What Tuile should *not* copy: `HasValidator#getDefaultValidator` and
90
+ `addValidationStatusChangeListener`. Both exist to repair a *shared*
91
+ `invalid`/`errorMessage` cell on the component; Tuile puts the two facts in two
92
+ places instead, so the repair has nothing to fix (`D_bad_input`, and
93
+ `Component::HasValidation`, shipped 2026-09-03 — `D_has_validation`: one stored
94
+ `error_message` the field never writes, so the Binder is its sole writer and
95
+ sets-or-clears it on every validate pass). Ruby also deletes most of the
96
+ ceremony: a validator is a proc returning a message or `nil`, so there is no
97
+ `Validator` interface, no `ValidationResult`, and no `Result.ok`.
98
+
99
+ ## What the Binder must consume from a field
100
+
101
+ - **`is_a?(HasValue)` is the marker** for "this is bindable" — `D_integer_field`
102
+ says so explicitly.
103
+ - **`field.respond_to?(:bad_input?) && field.bad_input?`** is the bad-input
104
+ question — **this half exists today** (`D_bad_input`) — asked at bind, at
105
+ click, at write, and whenever a sibling forces a revalidation. The *capability* is a class fact and may be cached at bind
106
+ time; the *status* may never be cached.
107
+ - **Bad input must block the write even for an optional field.** This is the
108
+ failure Vaadin names and the whole reason the channel exists — an optional
109
+ field means "may be empty", not "may be garbage". And it cannot be reached
110
+ through `empty?`, which reports `true` for a field full of glyphs the value
111
+ cannot represent.
112
+ - **A push notice (`on_bad_input_change`) is deliberately *not* built** — it
113
+ is needed only by a consumer that must react *between* clicks, which a Binder
114
+ gated at the click is not (`D_bad_input`, `D_on_blur`).
115
+
116
+ ## The Tuile-specific part: gating Save
117
+
118
+ **The gate goes at the click, not on the button's enabled state.** Save asks
119
+ the Binder when pressed and, on "no", opens an alert naming the problems
120
+ (`ConfirmWindow.alert` exists — `D_confirm_window`). Vaadin's idiom is the
121
+ other one (v25.2 `flow/binding-data/components-binder-load.md`):
122
+
123
+ ```java
124
+ binder.addStatusChangeListener(event -> {
125
+ saveButton.setEnabled(event.getBinder().hasChanges()
126
+ && event.getBinder().isValid());
127
+ });
128
+ ```
129
+
130
+ Three reasons not to copy it, ascending:
131
+
132
+ - **`Button` has no disabled state** — no `enabled` axis on `Component`, no
133
+ `:disabled` in `BG_STATES` (AGENTS.md is explicit that the key is absent
134
+ because the *state* is absent). The enabled design needs framework work
135
+ first; the click design needs none.
136
+ - **A disabled control says nothing about why**, and a TUI has no channel to
137
+ explain it: no tooltip, and hover is not even received (mode 1000 is
138
+ press-only — `ideas/hover.md`).
139
+ - **It removes the only *continuous* consumer of the bad-input signal**, so
140
+ nothing needs a settling policy: a Binder asked only at the click sees one
141
+ settled state, and the flicker `D_bad_input` describes never arises on this
142
+ side. (The *well* still owes one, now that it ORs `bad_input?` — that debt is
143
+ unassigned and belongs to the first continuous consumer, per
144
+ `D_has_validation`.)
145
+
146
+ If a settling policy is ever wanted here anyway, copy Vaadin's display rule
147
+ rather than inventing one: errors count only after the user has edited a field
148
+ and submitted.
149
+
150
+ ## Open, and deliberately not designed here
151
+
152
+ Where a rule's message is *stored* and *shown* is answered by
153
+ `D_has_validation`: stored on the field as `HasValidation#error_message`, shown
154
+ as the field's own red *well* plus text in whatever cells surround it (the
155
+ layout's inline-right message is still unbuilt — `ideas/form-layout.md`). The
156
+ Binder writes it, and does not hold a per-binding cell of its own. Two
157
+ consequences for the port: the write is a plain `field.error_message = msg_or_nil`
158
+ per pass, and the Binder must **subscribe nothing** to show it — but it does
159
+ compete for the single `on_error_message_change` slot with a `FormLayout` and
160
+ with the app, which that note flags. Whether Tuile grows a `Signal` type to
161
+ mirror Vaadin 25's `validationStatusSignal()` is untouched — Tuile's listener
162
+ idiom is a plain proc, and nothing has asked for more.
163
+
164
+ ## Related
165
+
166
+ `D_bad_input` (the field-side channel this consumes, already shipped),
167
+ `D_on_blur` (the commit point that shipped, and why the push notice stays
168
+ deferred), `D_has_validation` (the
169
+ verdict slot this Binder is the sole writer of; where a message lives and who
170
+ paints it), `ideas/form-layout.md` (the unbuilt container that would paint it),
171
+ `ideas/new-components.md` (Tier 2 Form Layout, Custom Field; infra items 2–3),
172
+ `D_has_value` (the forms layer owns converters, `read_only`, the
173
+ required-indicator; the typed-value survey), `D_integer_field` (the field's own
174
+ converter stays private; `is_a?(HasValue)` is the Binder's marker),
175
+ `D_scroll_nomenclature` (the nomenclature ruling this vocabulary copies),
176
+ `D_confirm_window` (the alert the Save button opens), `D_status_bar` (why the
177
+ framework places no error row).
@@ -0,0 +1,77 @@
1
+ # `CompositeField`: several fields behind one value
2
+
3
+ **Status:** filed 2026-09-04, as what was left over when
4
+ `Component::AbstractWrappingField` shipped and its note graduated — read
5
+ `D_wrapping_field` first, this note assumes it. Nothing here is built, and it
6
+ waits for a **real consumer**: the two questions it turns on have no answer
7
+ that today's components would test.
8
+
9
+ The shape: a `DateTimeField` over a `DateField` plus a `TimeField`, i.e. several
10
+ fields arranged in a layout behind one typed value. `AbstractWrappingField` is
11
+ deliberately *one editor, full stop*, and was built as the prototype this learns
12
+ from.
13
+
14
+ What is already known about its shape:
15
+
16
+ - **A sibling of `AbstractWrappingField`, not a subclass of it.** That base's
17
+ whole value is that one child removes the layout and the ordering; inheriting
18
+ from it would put both back.
19
+ - **No auto-discovery of the fields — ever.** A tree walk for "the fields inside
20
+ me" would descend *through* a wrapping field into the private editor it exists
21
+ to hide. Registration is explicit.
22
+ - **`active=` already gives it the right commit semantics** — see
23
+ `D_wrapping_field`, where that seam is chosen partly *because* it survives
24
+ here: focus moving between two of a composite's own fields keeps the composite
25
+ active, so it does not spuriously commit. The one hard part it inherits solved.
26
+ - **The abstract pair generalizes by pluralizing.** `value` / `value=` already
27
+ mean "read the value out of my field(s)" and "apply the value into my
28
+ field(s)"; a composite changes nothing else about that contract, which is the
29
+ evidence the prototype transfers.
30
+ - **It cannot inherit the "the base adds the child" guarantee**, and that is
31
+ another reason it is a sibling. `AbstractWrappingField` calls `add_child`
32
+ itself, which is what makes *own and hide* a guarantee rather than a
33
+ convention. A composite must let its subclass populate a layout, so something
34
+ else has to replace it — explicit registration of which descendants are its
35
+ fields.
36
+ - **What it must solve, and a wrapping field never had to:** assembling `value`
37
+ from several children with a diff guard; deciding whether `bad_input?` is "any
38
+ child" or "the combination"; which child takes focus on `on_focus`; and how
39
+ the layout is expressed without becoming a container.
40
+
41
+ **The hard one: which component wears the error, and it is already half
42
+ answered.** Picture `Date: [DateField] Time: [TimeField]` — a `Horizontal` of
43
+ four children, two of them labels. Two strategies: the composite marks *itself*
44
+ invalid, or it marks each of its *fields*. **They are not symmetric — the first
45
+ is already broken by the background chain.** `error_bg_color` sits at the top of
46
+ the same chain a child walks, so a child inherits its parent's *error* level,
47
+ not merely its normal well. Verified:
48
+
49
+ ```ruby
50
+ f = Component::IntegerField.new
51
+ f.error_message = "nope"
52
+ label_under_it.effective_bg_color # => Color 88 — the error well
53
+ ```
54
+
55
+ So a composite that marks itself reddens its `Date:` and `Time:` labels, which
56
+ is wrong for the same reason `D_caption_ownership` keeps a caption off a field:
57
+ that text is chrome, and chrome is not the thing that failed. That points at
58
+ marking the fields — but it leaves the genuinely hard case open, and it is the
59
+ case a composite exists for: a **combination** error (`start > end`) where no
60
+ single field is wrong. Marking one is a lie, marking all of them is loud, and
61
+ marking none loses the signal. Unsolved, and the reason the whole area waits for
62
+ a real consumer.
63
+
64
+ **BG_INHERIT is the same question wearing a different hat** — does a composite's
65
+ whole subtree inherit its well (and the labels sit in it), or only the fields
66
+ (and the layout's gaps show terminal default, looking patchy)? Both readings are
67
+ defensible, neither has a consumer, so nothing guesses yet.
68
+
69
+ ## Related
70
+
71
+ `D_wrapping_field` (the one-editor base this generalizes — its admission test,
72
+ its forwarding test, and `active=` as the commit point), `D_has_validation` and
73
+ `D_bad_input` (the two error channels a composite has to combine),
74
+ `D_caption_ownership` (why an inner label is chrome, and chrome is not what
75
+ failed), `D_bg_surface` (the background chain that makes "mark self" redden the
76
+ labels), `D_date_field` (`DateTimeField` is the plausible first
77
+ consumer).
@@ -0,0 +1,116 @@
1
+ # The focus accent — should `Button` / `Checkbox` / `Tabs` / `MenuBar` / `List` move onto `default_bg_color`?
2
+
3
+ **Status:** measured and parked, 2026-09-01. Spun off from `D_bg_surface`,
4
+ which introduced `Component#default_bg_color` and migrated the *well* widgets
5
+ (`AbstractStringField`, `Select`, `ComboBox`) onto it while explicitly leaving
6
+ this family alone. That note records the decision; this one records the
7
+ *measurement*, so whoever revisits it doesn't re-run the experiment.
8
+
9
+ ## The question
10
+
11
+ Five widgets highlight themselves on focus by stomping a background onto their
12
+ painted content at repaint time:
13
+
14
+ ```ruby
15
+ label = label.with_bg(screen.theme.active_bg_color) if active? # Button, Checkbox
16
+ segment.with_bg(screen.theme.active_bg_color) # Tabs, MenuBar
17
+ is_cursor ? base.with_bg(screen.theme.active_bg_color) : base # List
18
+ ```
19
+
20
+ `default_bg_color` can express the per-component half of that —
21
+ `{ active: screen.theme.active_bg_color }`, or the allocation-free
22
+ `active? ? … : nil`, with no `:normal` key so an unfocused widget falls through
23
+ to the ambient. So: should it?
24
+
25
+ ## Measured, not argued (Checkbox, 2026-09-01)
26
+
27
+ The migration was written and run. It is:
28
+
29
+ ```diff
30
+ label = (StyledString.plain(value ? "[x] " : "[ ] ") + caption).ellipsize(rect.width)
31
+ - label = label.with_bg(screen.theme.active_bg_color) if active?
32
+ draw_text(rect.left, rect.top, label)
33
+ end
34
+ +
35
+ + # @return [Color, nil]
36
+ + def default_bg_color = active? ? screen.theme.active_bg_color : nil
37
+ ```
38
+
39
+ **Net +2 lines.** Nothing is deleted, because these widgets have no *well* —
40
+ there is no reach-around-the-chain to remove, which is exactly what the
41
+ `AbstractStringField` migration did delete (`#background`). So it is not a
42
+ simplification.
43
+
44
+ **The whole suite passed: 2787 examples, 0 failures.** Both behavior changes
45
+ below are unpinned by any spec — see *The missing guard*.
46
+
47
+ Probed directly, migrated vs. original, on a focused `Checkbox` at 20×1:
48
+
49
+ | case | original | migrated |
50
+ |---|---|---|
51
+ | caption span carries `bg: BLUE` | `59, 59` — accent covers it | `59, :blue` — **the span survives** |
52
+ | `bg_color = 52` | `59, 59` — accent stomps the tint | `52, 52` — tint wins, no focus shade |
53
+
54
+ ## What the measurement says
55
+
56
+ **A surface and an accent are different things, and the hook is only right for
57
+ one.** A *surface* is what your cells sit on; it is legitimately the app's to
58
+ override, which is the whole point of `bg_color` winning over
59
+ `default_bg_color`. An *accent* is a signal painted **over** whatever is there,
60
+ and it must be unconditional — the moment it can be selectively suppressed
61
+ (by a caption span, by an app tint) it stops being a reliable indicator.
62
+
63
+ Row 1 is therefore a regression: `with_bg` is override-all, `under_bg` (what
64
+ `draw_text` applies) is fill-unset, so a caption carrying its own background
65
+ punches a hole in the highlight.
66
+
67
+ Row 2 is worse than it looks: `Checkbox` paints no caret, so a flat `bg_color`
68
+ removes its only focus affordance. Recoverable with `{ normal:, active: }`, but
69
+ the *default* got worse. This is the same objection that made `Select` the
70
+ awkward case in `D_bg_surface` — there it was accepted because a `Select` really
71
+ does paint a well, and the flat surface is a real thing to want.
72
+
73
+ **And for three of the five it isn't even expressible.** `Tabs`, `MenuBar` and
74
+ `List` accent a *segment or row*, not the component. A per-component hook has
75
+ nothing to say about "the third tab" or "the cursor row". Only `Button` and
76
+ `Checkbox` are candidates at all, which makes a partial migration a
77
+ consistency *loss*, not a gain.
78
+
79
+ ## Options, when this is picked up
80
+
81
+ - **(A) Leave it, pin it.** The status quo plus the specs below. Cheapest, and
82
+ the measurement above says the status quo is right.
83
+ - **(B) Migrate `Button` + `Checkbox` only.** Costs +4 lines, splits the family
84
+ three ways, and takes both regressions. Hard to justify on this evidence.
85
+ - **(C) Name the accent as its own concept.** The honest generalisation: a
86
+ paint-time `over_bg` / accent layer that is *not* part of the `bg_color`
87
+ chain and is applied after it, with an explicit `with_bg` (override-all)
88
+ contract. That would cover all five *and* the segment/row cases, because the
89
+ layer is applied to a `StyledString` rather than declared per component. It
90
+ is a second colour channel, though — weigh against `D_theme_ref`'s "not a
91
+ third colour channel" reasoning before adding one.
92
+ - **(D) Make focus indication a framework concern.** `Screen` knows the focus
93
+ chain; it could accent the focused component's extent centrally. Almost
94
+ certainly wrong — it would need per-widget opt-out (a focused `TextArea` must
95
+ not have its whole rect stomped) and reintroduces a framework-owned paint
96
+ pass the top-down layout rule keeps out.
97
+
98
+ ## The missing guard (do this regardless of the outcome)
99
+
100
+ Neither behavior in the table is asserted anywhere, which is how a future
101
+ migration would land silently. `checkbox_spec` and `button_spec` want:
102
+
103
+ - the focus accent covers a caption span that carries its own background
104
+ (pins `with_bg`, not `under_bg`);
105
+ - `bg_color` set on the widget does **not** suppress the focus accent
106
+ (pins the accent as unconditional).
107
+
108
+ Eight lines each, and they convert `D_bg_surface`'s "deliberately not migrated"
109
+ from recorded to enforced.
110
+
111
+ ## Related
112
+
113
+ `D_bg_surface` (the chain, the state map, the surface/accent line),
114
+ `D_bg_inherit` (`under_bg` vs `with_bg`), `D_boolean_fields` (why the extent
115
+ arithmetic must not vary with `bg_color`), `D_theme_ref` (why there is not a
116
+ third colour channel).
@@ -0,0 +1,151 @@
1
+ # `FormLayout`: where the caption, the message and the required marker go
2
+
3
+ **Status:** filed 2026-09-03 as `ideas/caption-and-error-ownership.md`, which
4
+ asked whether a field or the container around it owns the caption and the error
5
+ state. **That question is settled, and the field half has shipped** — read
6
+ `D_caption_ownership` (a field carries no caption; the container does) and
7
+ `D_has_validation` (the field stores the verdict in
8
+ `HasValidation#error_message` and paints it as a red *well*; whoever owns the
9
+ cells paints the *message*, subscribing to `on_error_message_change`) before
10
+ this note. Nothing here re-argues either. Note in particular that an invalid
11
+ field shows **no ink at all**: it gets a slight red *well*, never the red
12
+ foreground on the glyphs that the first draft recommended.
13
+
14
+ What is left — and all this note now holds — is the *container* half: the
15
+ `FormLayout` that has the cells. Unbuilt, and still what infra item 2 of
16
+ `ideas/new-components.md` is blocked on. Graduates into a `D_form_layout`, the
17
+ component's rdoc, a book ch7 section, a README Components row and a CHANGELOG
18
+ line.
19
+
20
+ ## What the layout has to place
21
+
22
+ Three pieces of chrome, none of which the field carries or can carry:
23
+
24
+ - **the caption** — `FormLayout#add(field, caption: "Username")`; the string
25
+ lives in the layout's per-child map, never on the field.
26
+ - **the message** — read off `child.error_message`, painted in
27
+ `Theme#error_color`.
28
+ - **the required marker** — a red dot beside the caption; chrome like the
29
+ caption, so it rides the container too (below).
30
+
31
+ ## Recommended shape: one row per field, inline on both sides
32
+
33
+ ```
34
+ Username ∙ [________________] Required
35
+ Password ∙ [________________] Required
36
+ ```
37
+
38
+ (the `∙` is the required marker, red; whether it sits before or after the
39
+ caption text, and whether it widens the caption column, goes with the caption
40
+ geometry below.)
41
+
42
+ - **Inline right for the message.** One row, no growth, nothing bottom-up —
43
+ which is the whole reason to prefer it. The message is bounded by the row, so
44
+ the layout truncates with an ellipsis (`InfoWindow#lines=` is the model).
45
+ - **A form-level status row showing the first error is the *app's*** to build
46
+ from the same data, not the layout's — `D_status_bar` from the other side: the
47
+ framework reserves no row it was not asked for.
48
+
49
+ ## Wiring the message
50
+
51
+ The layout paints the message in cells the *field* does not invalidate, so it
52
+ has to be told: subscribe to `on_error_message_change` at `add`, unsubscribe at
53
+ `remove`. That notice exists for exactly this consumer, and `D_has_validation`
54
+ records why it is plain listener inversion rather than the push notice
55
+ `D_bad_input` withheld (this fact is discrete, that one is continuous).
56
+
57
+ **Open, and a real conflict:** `on_error_message_change` is a single
58
+ `attr_accessor` slot, and its rdoc says the container painting the message
59
+ claims it — *"an app painting its own takes it instead."* A `FormLayout` that
60
+ claims it silently at `add` therefore disables an app that already set it,
61
+ which is the one-callback-slot failure `D_no_key_interceptor` names (all four
62
+ composed fields hit it). Decide with the component: either the layout refuses
63
+ to overwrite a non-nil slot, or it chains the previous callable, or the notice
64
+ grows a subscriber list. Do not just assign it.
65
+
66
+ ## Facts it rests on, so they don't get re-derived
67
+
68
+ - **A per-child attribute map is a solved shape.** `Box` keeps constraints in an
69
+ identity-keyed per-child map that is explicitly *not* a second copy of
70
+ ordering (`D_box_layouts`); a `FormLayout` holding `{field => caption}` copies
71
+ it. `Component::Slot` is the tree-native answer for a swappable region
72
+ (`D_slots`).
73
+ - **It owes an `on_child_visibility_changed` override**, like `Box` — it is the
74
+ rule for any container with layout arithmetic (`D_visibility`). A conditional
75
+ form field is *the* consumer that brought `visible=` in, so a `FormLayout`
76
+ that skipped this would leave the hole in exactly the place it was built for:
77
+ the caption row and the message cells of a hidden field must go with it.
78
+ - **Measuring captions does not reopen bottom-up sizing.** Aligning a caption
79
+ column needs the *container's own* strings measured — caller-side arithmetic,
80
+ the same move `Select` makes when it measures its labels and assigns the rect
81
+ (`D_select`; `D_box_layouts`' "`align:` is legal only because the cross extent
82
+ is caller-supplied"). It looks like the banned channel and isn't.
83
+ - **The layout is the authority for caption↔field lookup**, since it holds the
84
+ only copy of the association — so it owes a locator
85
+ (`form.field_for(caption: "Name")`), which is what Karibu-Testing does against
86
+ Vaadin 25 form items. `Component#id` + `Tuile::Testing.get`
87
+ (`D_component_lookup`) is a *different* handle, not a replacement:
88
+ an id is a tag the app assigns.
89
+ - **Top-down layout is the heaviest prior**, and it is what makes both halves
90
+ inline: a field is handed one row and cannot grow a second for a caption or a
91
+ message.
92
+
93
+ ## Still open
94
+
95
+ - **Caption geometry** — caption column left (aligned, measured caller-side)
96
+ versus a row above. Inline-left is the natural pair to the inline-right
97
+ message; the row-above shape wants the message on a third row and reopens the
98
+ growth question.
99
+ - **Required indicator — decided in shape, open in glyph.**
100
+ `FormLayout#add(field, caption:, required: true)` paints a **red dot beside
101
+ the caption**, as Vaadin does. `D_has_value` parked the indicator; this is
102
+ where it lands, and the field still does not know it is required.
103
+
104
+ Three things checked against Vaadin 25.2 rather than remembered, since they
105
+ shape the TUI version:
106
+
107
+ - Vaadin marks a required field *"with an indicator next to the label"* — so
108
+ the marker rides the **caption**, which in Tuile means it rides the
109
+ container, exactly as `D_caption_ownership` puts the caption there. The
110
+ precedent lines up; nothing to re-argue.
111
+ - Both the glyph and its color are style properties
112
+ (`--vaadin-input-field-required-indicator` /
113
+ `-color`, `::part(required-indicator)`), i.e. Vaadin treats *which* mark as
114
+ a theme choice, not a semantic. Tuile's equivalent question is whether the
115
+ dot's red is a reused `Theme#error_color` or a token of its own — a required
116
+ field is not (yet) *invalid*, and every `Theme` member is validated
117
+ `is_a?(Color)` with a hand-rolled `Theme.new` having to pass all of them, so
118
+ a new token is a breaking change to weigh, not a freebie.
119
+ - The docs also recommend *"an instruction text at the top of the form
120
+ explaining the required indicator"* — worth knowing that even Vaadin does
121
+ not consider the marker self-explanatory. In Tuile that legend is the app's
122
+ row, not the layout's (`D_status_bar`).
123
+
124
+ **The glyph is the one real Tuile problem, and it is the ambiguous-width bet
125
+ (`D_ambiguous_width`).** Measured with the gem: `•` U+2022 and `●` U+25CF and
126
+ `·` U+00B7 are all East-Asian **Ambiguous** — 1 column under Tuile's policy, 2
127
+ under ambiguous-as-wide, so each would enlarge the inventory that keeps the
128
+ bet cheap to reverse. `∙` U+2219 BULLET OPERATOR and `◦` U+25E6 measure **1
129
+ under both policies** and are the dots that cost nothing. So: default to `∙`
130
+ (or ASCII `*` with the dot as an opt-in knob, per the rule that a new
131
+ component defaults to ASCII when the pretty glyph is Ambiguous), and if the
132
+ marker becomes a knob it validates at assignment that it took one cluster one
133
+ column wide (`D_scrollbar_ink`).
134
+ - **Helper text**, the other half of the seam `ideas/new-components.md` infra
135
+ item 2 names, is undesigned — and the inline-right cells are already spoken
136
+ for by the message.
137
+
138
+ ## Related
139
+
140
+ `D_caption_ownership` and `D_has_validation` (**the two entries this note's
141
+ first half graduated into** — read them first), `D_bad_input` (the field's own
142
+ report, which reddens the same well), `ideas/binder.md` (the writer of
143
+ `error_message`; the four-layer vocabulary), `D_on_blur` (the commit point a
144
+ field can canonicalize from; the bad-input push notice that is still unbuilt),
145
+ `D_date_field` (a field whose
146
+ input outruns its value), `ideas/new-components.md` (infra item 2; Tier 2 Form
147
+ Layout, Custom Field), `D_box_layouts` (the per-child attribute map;
148
+ caller-supplied cross extent), `D_slots`, `D_select` (caller-side measurement),
149
+ `D_status_bar` (no framework-reserved row), `D_no_key_interceptor` (one callback
150
+ slot cannot be shared), `D_has_value` (the parked required indicator),
151
+ AGENTS.md "Input values" (caption is chrome, text is value).