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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +150 -37
- data/COMPARISON.md +101 -0
- data/DECISIONS.md +4266 -226
- data/README.md +44 -24
- data/TERMINOLOGY.md +22 -7
- data/book/03-layout.md +17 -10
- data/book/05-focus.md +67 -3
- data/book/06-theming.md +153 -7
- data/book/07-components.md +643 -67
- data/book/08-testing.md +94 -0
- data/book/09-styled-text.md +3 -3
- data/book/10-locale.md +216 -0
- data/book/README.md +14 -5
- data/examples/file_commander.rb +1 -1
- data/examples/sampler.rb +402 -62
- data/ideas/arrow-key-navigation.md +2 -2
- data/ideas/binder.md +177 -0
- data/ideas/composite-field.md +77 -0
- data/ideas/focus-accent.md +116 -0
- data/ideas/form-layout.md +151 -0
- data/ideas/hover/probe.rb +241 -0
- data/ideas/hover/probe_spec.rb +82 -0
- data/ideas/hover.md +909 -0
- data/ideas/modal-backdrop.md +24 -0
- data/ideas/new-components.md +49 -29
- data/lib/tuile/buffer.rb +51 -3
- data/lib/tuile/color.rb +143 -0
- data/lib/tuile/color_depth.rb +80 -0
- data/lib/tuile/component/abstract_string_field.rb +106 -58
- data/lib/tuile/component/abstract_wrapping_field.rb +243 -0
- data/lib/tuile/component/big_decimal_field.rb +52 -79
- data/lib/tuile/component/button.rb +3 -3
- data/lib/tuile/component/checkbox.rb +3 -3
- data/lib/tuile/component/checkbox_group.rb +36 -20
- data/lib/tuile/component/combo_box.rb +68 -33
- data/lib/tuile/component/confirm_window.rb +442 -0
- data/lib/tuile/component/date_field.rb +322 -0
- data/lib/tuile/component/float_field.rb +57 -82
- data/lib/tuile/component/has_bad_input.rb +88 -0
- data/lib/tuile/component/has_caption.rb +8 -0
- data/lib/tuile/component/has_content.rb +43 -11
- data/lib/tuile/component/has_placeholder.rb +62 -0
- data/lib/tuile/component/has_validation.rb +115 -0
- data/lib/tuile/component/has_value.rb +28 -1
- data/lib/tuile/component/info_window.rb +64 -16
- data/lib/tuile/component/integer_field.rb +51 -78
- data/lib/tuile/component/label.rb +6 -38
- data/lib/tuile/component/layout/box.rb +87 -19
- data/lib/tuile/component/layout.rb +13 -13
- data/lib/tuile/component/list.rb +11 -6
- data/lib/tuile/component/list_dropdown.rb +22 -10
- data/lib/tuile/component/log_text_view.rb +71 -0
- data/lib/tuile/component/log_window.rb +13 -48
- data/lib/tuile/component/menu_bar/cascade.rb +3 -3
- data/lib/tuile/component/menu_bar.rb +5 -5
- data/lib/tuile/component/notification.rb +16 -34
- data/lib/tuile/component/overlay.rb +209 -0
- data/lib/tuile/component/popup.rb +59 -187
- data/lib/tuile/component/progress_bar.rb +1 -1
- data/lib/tuile/component/radio_group.rb +39 -22
- data/lib/tuile/component/select.rb +26 -10
- data/lib/tuile/component/slot.rb +54 -0
- data/lib/tuile/component/tab_sheet.rb +0 -11
- data/lib/tuile/component/tabs.rb +5 -5
- data/lib/tuile/component/text_area.rb +14 -8
- data/lib/tuile/component/text_field.rb +42 -15
- data/lib/tuile/component/text_view.rb +25 -8
- data/lib/tuile/component/time_field.rb +454 -0
- data/lib/tuile/component/window.rb +48 -59
- data/lib/tuile/component.rb +580 -54
- data/lib/tuile/event_queue.rb +21 -1
- data/lib/tuile/fake_screen.rb +37 -3
- data/lib/tuile/final.rb +75 -0
- data/lib/tuile/keys.rb +7 -0
- data/lib/tuile/locale.rb +851 -0
- data/lib/tuile/screen.rb +251 -55
- data/lib/tuile/screen_pane.rb +50 -44
- data/lib/tuile/styled_string.rb +40 -7
- data/lib/tuile/terminal_background.rb +74 -16
- data/lib/tuile/testing.rb +198 -0
- data/lib/tuile/theme.rb +100 -10
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile/vertical_scroll_bar.rb +88 -12
- data/lib/tuile.rb +1 -0
- data/sig/tuile.rbs +4545 -770
- 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).
|