tuile 0.14.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 +69 -0
- data/DECISIONS.md +3181 -41
- data/README.md +25 -5
- data/TERMINOLOGY.md +17 -3
- data/book/05-focus.md +63 -2
- data/book/06-theming.md +55 -7
- data/book/07-components.md +474 -48
- data/book/08-testing.md +78 -0
- data/book/10-locale.md +216 -0
- data/book/README.md +14 -5
- data/examples/sampler.rb +265 -25
- 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/new-components.md +26 -6
- 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/checkbox_group.rb +36 -20
- data/lib/tuile/component/combo_box.rb +59 -31
- 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 +29 -10
- 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 +27 -0
- 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 -3
- data/lib/tuile/component/list.rb +11 -6
- data/lib/tuile/component/list_dropdown.rb +4 -0
- data/lib/tuile/component/overlay.rb +17 -0
- data/lib/tuile/component/radio_group.rb +39 -22
- data/lib/tuile/component/select.rb +12 -4
- 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 +26 -13
- data/lib/tuile/component.rb +469 -73
- data/lib/tuile/fake_screen.rb +11 -1
- data/lib/tuile/final.rb +75 -0
- data/lib/tuile/locale.rb +851 -0
- data/lib/tuile/screen.rb +131 -17
- data/lib/tuile/screen_pane.rb +13 -9
- 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 +3398 -412
- metadata +18 -1
data/DECISIONS.md
CHANGED
|
@@ -117,9 +117,10 @@ chain. Self-painters route the effective bg through a single choke point,
|
|
|
117
117
|
(`ideas/background-fill-color.md`) is retired; its invariants graduated to
|
|
118
118
|
AGENTS.md ("Background color") and its reader-half to book ch6 ("Backgrounds
|
|
119
119
|
are opt-in"). {Component::Label} already carried its own `#bg` (override-all
|
|
120
|
-
via `with_bg`); it
|
|
121
|
-
`under_bg`, so `#bg`
|
|
122
|
-
|
|
120
|
+
via `with_bg`); it composed with `bg_color` (explicit span bgs survive
|
|
121
|
+
`under_bg`, so `#bg` won locally), and the two-knob overlap was flagged here as
|
|
122
|
+
a wart pending a consolidation decision — taken in `D_bg_surface`, which
|
|
123
|
+
deleted it. The theme-token variant that
|
|
123
124
|
surfaced during design landed separately — see `D_theme_ref`.
|
|
124
125
|
|
|
125
126
|
---
|
|
@@ -254,7 +255,7 @@ values *and* a uniform seam for free.
|
|
|
254
255
|
|
|
255
256
|
**Alternatives rejected.**
|
|
256
257
|
- *String-only value on every input:* fails "pick a domain object, get the
|
|
257
|
-
object," and bakes a `String` assumption a future `IntegerField`/`
|
|
258
|
+
object," and bakes a `String` assumption a future `IntegerField`/`DateField`
|
|
258
259
|
would fight. Kept only as a theoretical fallback.
|
|
259
260
|
- *A full Vaadin-shaped `HasValue`* (read-only, required-indicator,
|
|
260
261
|
old-value/`isFromClient` event payload, converters/validators): every one of
|
|
@@ -398,12 +399,17 @@ moment to settle the input taxonomy while still pre-1.0.
|
|
|
398
399
|
- **Value is a derived parse, fired eagerly.** `value` is recomputed from the
|
|
399
400
|
buffer on read; `on_value_change` fires per keystroke but only on a real
|
|
400
401
|
*value* change (`"7"`→`"07"` is silent). No normalization in v1 (`"007"`
|
|
401
|
-
shows as typed)
|
|
402
|
+
shows as typed): rewriting the buffer under the caret while typing is worse
|
|
403
|
+
than an ugly buffer, so it would have to wait for a commit point. `on_blur`
|
|
404
|
+
is now that point (`D_on_blur`), which makes this re-openable on the merits —
|
|
405
|
+
it is no longer blocked on a missing hook.
|
|
402
406
|
- **Up/Down are a built-in ±1 spinner**, treating an empty/un-parseable field
|
|
403
|
-
as `0
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
+
as `0`. (Wired to the inner field's `on_key` interceptor originally; to its
|
|
408
|
+
`on_key_up`/`on_key_down` since `D_no_key_interceptor`.) `IntegerField`
|
|
409
|
+
therefore does *not* expose `on_key_up`/`on_key_down` *on its own face*
|
|
410
|
+
(`on_enter`, a submit hook, stays delegated) — on a numeric field the arrows
|
|
411
|
+
have a native meaning, so surfacing them as app callbacks would fight the
|
|
412
|
+
spinner.
|
|
407
413
|
- **Both composed fields include `HasContent`.** `ComboBox` and `IntegerField`
|
|
408
414
|
hold their inner `TextField` as their single `HasContent` child rather than
|
|
409
415
|
hand-rolling `children`/`rect=`/`on_focus`. This reuses an *existing* mixin
|
|
@@ -442,8 +448,9 @@ what Java needs a class for, and `is_a?(HasValue)` is the Binder's marker.
|
|
|
442
448
|
- `content`/`content=` are public on `ComboBox`/`IntegerField` (from
|
|
443
449
|
`HasContent`) — a structural accessor, distinct from the typed `value` seam
|
|
444
450
|
that stays the intended domain API.
|
|
445
|
-
- The digit filter is the inner field's `
|
|
446
|
-
so a rejected key never moves the
|
|
451
|
+
- The digit filter is the inner field's `insert_text` (originally its `on_key`,
|
|
452
|
+
which a paste bypassed — `D_input_filters`), so a rejected key never moves the
|
|
453
|
+
caret and a rejected paste lands nothing.
|
|
447
454
|
- Empty is per-component: `nil` for `IntegerField`, `""` for a text input.
|
|
448
455
|
|
|
449
456
|
---
|
|
@@ -949,7 +956,8 @@ much of `List` to reuse and what the value should be. (A single-select group
|
|
|
949
956
|
|
|
950
957
|
**Decision.**
|
|
951
958
|
- **Compose a plain `List`, unmodified.** `CheckboxGroup` holds one as its single
|
|
952
|
-
|
|
959
|
+
child — read-only as `list` since 0.15.0 (`D_wrapping_field`), an app tunes it
|
|
960
|
+
but never supplies it — which supplies the cursor, scrolling, the scrollbar and
|
|
953
961
|
per-row hit-testing. The group's own code is four lines of wiring: rebuild
|
|
954
962
|
`lines=` on any change to items/labels/selection, claim **Space** in
|
|
955
963
|
`handle_key`, and toggle from `on_item_chosen`. That one callback covers Enter
|
|
@@ -2003,7 +2011,7 @@ category, not this field's value, and it would force the eventual sibling to be
|
|
|
2003
2011
|
"the other number field."
|
|
2004
2012
|
|
|
2005
2013
|
**Decision — duplicate `IntegerField` rather than grow a base.** The two share
|
|
2006
|
-
~90% of their body (the `HasContent` shell, the
|
|
2014
|
+
~90% of their body (the `HasContent` shell, the nested filtering `Field`, the
|
|
2007
2015
|
`fire_if_changed` guard) and differ in exactly the three places that matter: the
|
|
2008
2016
|
filter, the parse, and the format. An `AbstractNumericField` with abstract
|
|
2009
2017
|
`parse`/`format` hooks **is** the converter strategy `D_integer_field` kept out,
|
|
@@ -2048,11 +2056,20 @@ in `D_integer_field`.
|
|
|
2048
2056
|
`bigdecimal` gem, a decimals/scale policy, and `"0.1"` → `BigDecimal("0.1")`
|
|
2049
2057
|
string-round-tripping — a different field with a different name, not this one.
|
|
2050
2058
|
- *Normalize the buffer on parse (`"007"` → `"7"`, `".5"` → `"0.5"`):*
|
|
2051
|
-
rejected for the same reason as in `IntegerField` —
|
|
2052
|
-
|
|
2053
|
-
|
|
2059
|
+
rejected for the same reason as in `IntegerField` — rewriting the buffer under
|
|
2060
|
+
the caret while typing is worse than an ugly buffer, so it belongs at a commit
|
|
2061
|
+
point, which `on_blur` has since become (`D_on_blur`).
|
|
2054
2062
|
- *A locale decimal comma:* no locale seam exists in Tuile, and inventing one
|
|
2055
2063
|
for a single field would put i18n in the wrong layer.
|
|
2064
|
+
**Amended 2026-09-04 (`D_locale`):** the seam now exists, and
|
|
2065
|
+
`Locale#decimal_separator` is detected and exposed — but the field still does
|
|
2066
|
+
not read it, because that is field-side work this entry's own reasoning
|
|
2067
|
+
constrains: `TYPEABLE` is an `insert_text` filter, so admitting `,` changes
|
|
2068
|
+
what a pasted `"1,5"` does (today it lands nothing, deliberately, rather than
|
|
2069
|
+
sieving to `"15"`), and `value=` writes through `to_s`, which is always a dot.
|
|
2070
|
+
A comma grammar is still prefix-closed, so `D_input_filters` holds and the
|
|
2071
|
+
work is tractable; it is simply not done. The member is one of the six
|
|
2072
|
+
`D_locale` shipped ahead of its consumer.
|
|
2056
2073
|
|
|
2057
2074
|
---
|
|
2058
2075
|
|
|
@@ -2128,9 +2145,10 @@ through the value seam: `"1.0"`→`"1.00"` fires nothing, because the two
|
|
|
2128
2145
|
("shortest decimal that round-trips") hidden inside a setter. If it is ever
|
|
2129
2146
|
wanted, it belongs at the call site, where it is visible.
|
|
2130
2147
|
- *A `scale=` / `decimals=` knob to pad the display:* it would have to rewrite
|
|
2131
|
-
the buffer under the caret while typing (`19.9` → `19.90` mid-edit),
|
|
2132
|
-
|
|
2133
|
-
|
|
2148
|
+
the buffer under the caret while typing (`19.9` → `19.90` mid-edit), so it
|
|
2149
|
+
belongs at a commit point — the same reason `D_integer_field` gave for not
|
|
2150
|
+
normalizing, and re-openable on the same terms now `on_blur` exists
|
|
2151
|
+
(`D_on_blur`).
|
|
2134
2152
|
- *A settable `step=`:* `D_float_field` rejected it over binary-float noise,
|
|
2135
2153
|
which genuinely doesn't apply here (`BigDecimal` steps exactly). Kept out
|
|
2136
2154
|
anyway, so the three numeric fields stay one shape; this is the field to
|
|
@@ -2146,6 +2164,12 @@ value types on `Layout`). Book ch3 pre-approved the shape and named the
|
|
|
2146
2164
|
acceptance criterion — "added if and when the convenience pays for itself" —
|
|
2147
2165
|
so what this entry records is that it did, and every choice inside it.
|
|
2148
2166
|
|
|
2167
|
+
**Update 2026-09-04: an empty rect propagates, and a child can be re-placed.**
|
|
2168
|
+
`#relayout` no longer returns early on an empty rect of its own, `#add` takes
|
|
2169
|
+
`at:` and `#constrain` re-constrains a child already added — so hiding a pane by
|
|
2170
|
+
`#remove` is reversible, which is what keeps `Fixed[0]` a *collapse* rather than
|
|
2171
|
+
Tuile's answer to hiding. See `D_empty_ancestor`.
|
|
2172
|
+
|
|
2149
2173
|
**Context.** `Layout::Absolute` was the only container: you override `rect=`
|
|
2150
2174
|
and compute each child's rectangle. That is right for genuinely
|
|
2151
2175
|
two-dimensional geometry and tedious for a stack. `examples/sampler.rb` carried
|
|
@@ -2568,6 +2592,10 @@ two callers that already know the answer.
|
|
|
2568
2592
|
non-focusable `Menu` really does give the same re-entrancy safety
|
|
2569
2593
|
`ComboBox#active=` leans on, and filtering / row rendering / the commit action
|
|
2570
2594
|
really do vary.
|
|
2595
|
+
- **`D_mouse`'s keyboard-first ranking leaves this entry untouched.** The
|
|
2596
|
+
dropdown is *enumeration* — options the user cannot type without seeing — not
|
|
2597
|
+
a mouse affordance, and the no-printable claim is a keyboard argument; nothing
|
|
2598
|
+
there demotes Select relative to `ComboBox`.
|
|
2571
2599
|
|
|
2572
2600
|
## D_list_items — `List` takes items + a renderer, rendered lazily (2026-08-14)
|
|
2573
2601
|
|
|
@@ -2845,10 +2873,11 @@ it: it snaps to the absolute start/end of the text.
|
|
|
2845
2873
|
|
|
2846
2874
|
**Decision — two public readers on `TextArea`, forwarding to the private wrap.**
|
|
2847
2875
|
`caret_row` and `row_count`, one line each. The caller claims the key in a seam
|
|
2848
|
-
that already exists — `handle_text_input_key` in a subclass
|
|
2849
|
-
interceptor for app code that would rather not subclass — and delegates to
|
|
2876
|
+
that already exists — `handle_text_input_key` in a subclass — and delegates to
|
|
2850
2877
|
`super` everywhere else, which leaves the edge snap intact for anyone who
|
|
2851
|
-
doesn't claim it. The recipe lives in the `TextArea` rdoc.
|
|
2878
|
+
doesn't claim it. The recipe lives in the `TextArea` rdoc. (The entry originally
|
|
2879
|
+
offered the `on_key` interceptor as a no-subclass alternative; it is gone, and
|
|
2880
|
+
the readers are public, so a subclass is the one route — `D_no_key_interceptor`.)
|
|
2852
2881
|
|
|
2853
2882
|
Both readers are needed and neither is redundant: history recall uses both, and
|
|
2854
2883
|
the auto-growing prompt strip — the case the name was reserved for — uses
|
|
@@ -2859,9 +2888,10 @@ the auto-growing prompt strip — the case the name was reserved for — uses
|
|
|
2859
2888
|
- **A protected `on_caret_vertical_overflow(delta)` hook**, consulted inside
|
|
2860
2889
|
`move_caret_vertical` before the snap. This was the issue's own preferred
|
|
2861
2890
|
shape, on the grounds that it avoids re-deriving a decision `TextArea` already
|
|
2862
|
-
makes. Rejected on five counts. It would be a *
|
|
2863
|
-
mechanism in a class that already has
|
|
2864
|
-
|
|
2891
|
+
makes. Rejected on five counts. It would be a *third* key-interception
|
|
2892
|
+
mechanism in a class that already has two (`handle_text_input_key` and the
|
|
2893
|
+
rung-3 ancestor bubble; `on_key` was a third until
|
|
2894
|
+
`D_no_key_interceptor`), where the house style is
|
|
2865
2895
|
"claim the key, or decline it". It names an implementation *moment* rather than
|
|
2866
2896
|
an event — one point inside a private method, after a clamp — so a later branch
|
|
2867
2897
|
in the Up path (desired-column memory, say) would shift its firing condition
|
|
@@ -2873,7 +2903,7 @@ the auto-growing prompt strip — the case the name was reserved for — uses
|
|
|
2873
2903
|
`D_scroll_nomenclature` rejected a general `Component` scroll seam. It serves
|
|
2874
2904
|
one question, in one direction, at one moment, where the readers also serve the
|
|
2875
2905
|
prompt strip, a "row 3/7" readout and a caller-drawn scrollbar. And it needs a
|
|
2876
|
-
subclass, where the readers serve
|
|
2906
|
+
subclass, where the readers serve any caller. In COP terms it is neither a
|
|
2877
2907
|
listener (nothing changed) nor a provider (no data pulled) — a template-method
|
|
2878
2908
|
escape valve where two COP-shaped seams already exist. As for the
|
|
2879
2909
|
re-derivation it was meant to avoid: the decision is literally
|
|
@@ -3311,8 +3341,8 @@ mechanism.
|
|
|
3311
3341
|
|
|
3312
3342
|
**Decision — a `PasteEvent`, and it never touches the key ladder.** The key
|
|
3313
3343
|
thread posts one event carrying the whole payload; `Screen#event_loop` routes it
|
|
3314
|
-
to `handle_paste
|
|
3315
|
-
|
|
3344
|
+
to `handle_paste`, with the same modal scoping as a key and no other rung.
|
|
3345
|
+
*Rejected: reusing `KeyEvent` with a flag*, which would put a
|
|
3316
3346
|
`pasted?` predicate on the ladder and re-create the runtime gate `D_key_dispatch`
|
|
3317
3347
|
deleted — every `handle_key` would have to remember to check it, and the ones
|
|
3318
3348
|
that forgot would be exactly today's bug. *Rejected: replaying an unhandled paste
|
|
@@ -3320,6 +3350,21 @@ as individual keys.* It reads like graceful degradation and is the ambiguity
|
|
|
3320
3350
|
walking back in through the fallback: a component that declines a paste would
|
|
3321
3351
|
still get eight ENTERs. Unhandled text is dropped.
|
|
3322
3352
|
|
|
3353
|
+
**Amended 2026-09-03 — delivery is to the focused component, and stops there.**
|
|
3354
|
+
`ScreenPane#handle_paste` originally walked the focus chain the way
|
|
3355
|
+
`bubble_key` does, offering the text to each ancestor in turn. That was
|
|
3356
|
+
symmetry for its own sake. The three reasons a *key* bubbles (`D_key_dispatch`)
|
|
3357
|
+
are all about a scope-wide **binding** — a form's default button, a layout's
|
|
3358
|
+
one-key jumps, and the modality that falls out of stopping at the scope root —
|
|
3359
|
+
and none of them has a paste analogue: "the ancestor gets the clipboard the
|
|
3360
|
+
field declined" is not a feature, and no component in the gem but
|
|
3361
|
+
`AbstractStringField` overrides `handle_paste`, which is always the innermost
|
|
3362
|
+
component on the chain when it matters. The scoping is kept (focus outside the
|
|
3363
|
+
modal scope still receives nothing, so a modal stays modal); only the walk is
|
|
3364
|
+
gone. This is a **narrowing**, so the re-grow bar is low if a real ancestor-level
|
|
3365
|
+
paste consumer ever appears — but the replay-as-keys fallback rejected above
|
|
3366
|
+
stays rejected, which is the part that would actually hurt.
|
|
3367
|
+
|
|
3323
3368
|
**Decision — the field inserts it as one mutation.**
|
|
3324
3369
|
`AbstractStringField#handle_paste` inserts at the caret in a single `text=`, so
|
|
3325
3370
|
`on_change` fires once for the paste rather than once per character. That is what
|
|
@@ -3338,8 +3383,8 @@ layer. What a **text buffer** may hold is the field's call:
|
|
|
3338
3383
|
`AbstractStringField#preprocess_paste` drops the C0 controls (a raw `\e` or `\t`
|
|
3339
3384
|
reaching {Tuile::Buffer} would move the real cursor mid-frame), keeps `\n`, and
|
|
3340
3385
|
turns a tab into one space rather than inventing a tab width;
|
|
3341
|
-
`TextField#preprocess_paste` narrows further —
|
|
3342
|
-
|
|
3386
|
+
`TextField#preprocess_paste` narrows further — the newline ruling is its own
|
|
3387
|
+
entry (`D_paste_newlines`), and a trim to `max_text_length` rather than a
|
|
3343
3388
|
rejection, because that is what typing the same characters would have done. An
|
|
3344
3389
|
app wanting tab *expansion* or a `[Pasted 230 lines]` placeholder overrides
|
|
3345
3390
|
`handle_paste`, which is the seam that exists for it.
|
|
@@ -3395,8 +3440,14 @@ the invalidation set and the buffer were both self-consistent.
|
|
|
3395
3440
|
|
|
3396
3441
|
**Decision.** Make the *clear* conditional and the *invalidate* unconditional:
|
|
3397
3442
|
|
|
3398
|
-
|
|
3399
|
-
|
|
3443
|
+
clear_outside_extent unless children.any? && children_tile_rect?
|
|
3444
|
+
invalidate_children
|
|
3445
|
+
|
|
3446
|
+
**Update 2026-09-04:** the second line is a named protected method rather than
|
|
3447
|
+
an inline `children.each`, so a container that skips `super` to paint its own
|
|
3448
|
+
rect has something to call — `Component::Window` skipped the clear it did not
|
|
3449
|
+
need and lost the cascade in the same edit, twice, because the cascade had no
|
|
3450
|
+
name (`D_component_contract`).
|
|
3400
3451
|
|
|
3401
3452
|
A container that paints nothing of its own can only redraw its area *through* its
|
|
3402
3453
|
children, so being invalidated has to mean invalidating them. The tiling test
|
|
@@ -3440,6 +3491,13 @@ identities), `D_select` (claim the minimum), `D_ambiguous_width` (the separator
|
|
|
3440
3491
|
glyph), `D_tree_api` (the slot-swap recipe) and `D_attach_hooks` (what
|
|
3441
3492
|
detachment fires).
|
|
3442
3493
|
|
|
3494
|
+
**Update 2026-09-05: the re-grow rule below has been met.** `D_visibility`
|
|
3495
|
+
brings `Component#visible=` back as the full focus-and-paint gate this entry
|
|
3496
|
+
asked for, with the `Box` arithmetic ruled, on the conditional form field as the
|
|
3497
|
+
second consumer. `TabSheet` keeps detaching regardless — the lifecycle hooks on
|
|
3498
|
+
switch are a feature — so the *decision* here stands; only the "Tuile grows no
|
|
3499
|
+
visibility flag" clause is superseded.
|
|
3500
|
+
|
|
3443
3501
|
**Context.** Several views, one visible at a time, and a one-row strip of
|
|
3444
3502
|
captions to pick between them. Two components, because the strip is useful
|
|
3445
3503
|
alone — Vaadin documents that case explicitly ("content switching without Tab
|
|
@@ -4531,8 +4589,9 @@ open question.
|
|
|
4531
4589
|
## D_hook_visibility — A framework-invoked hook is protected, reached with `__send__` (2026-08-30)
|
|
4532
4590
|
|
|
4533
4591
|
**Status:** Accepted and implemented for {Component#on_theme_changed}
|
|
4534
|
-
(protected; `Screen#theme=` fans it out through `__send__`)
|
|
4535
|
-
is the one
|
|
4592
|
+
(protected; `Screen#theme=` fans it out through `__send__`) and for
|
|
4593
|
+
{Component#on_blur} (`D_on_blur`). `Component#on_focus` is the one
|
|
4594
|
+
framework-invoked hook still public — see *Consequences*.
|
|
4536
4595
|
|
|
4537
4596
|
**Context — the field report.** virtui crashed on an OS appearance flip:
|
|
4538
4597
|
|
|
@@ -4620,10 +4679,11 @@ of bug never reached them.
|
|
|
4620
4679
|
sense. {Component::HasContent} / {Component::Layout} / {Component::TabSheet}
|
|
4621
4680
|
each override it to forward focus into their content, so it reads as part of
|
|
4622
4681
|
the composition seam a mixin publishes rather than as a private notification.
|
|
4623
|
-
|
|
4624
|
-
|
|
4625
|
-
|
|
4626
|
-
|
|
4682
|
+
Its narrowing hazard is nevertheless **gone**: `Screen#focused=` sends it with
|
|
4683
|
+
`__send__` since `D_on_blur`, because a *protected* `on_blur` beside it makes
|
|
4684
|
+
the fatal grouping likely rather than theoretical. Public-and-`__send__`-ed is
|
|
4685
|
+
the combination for a hook that is genuinely interface; the rule above is for
|
|
4686
|
+
the rest.
|
|
4627
4687
|
- **Specs call the hook with `send`**, and two guards exist: `component_spec`
|
|
4628
4688
|
asserts the visibility pair, `screen_spec` asserts that a subclass declaring a
|
|
4629
4689
|
`protected` override is still fired *and* that the walk continues past it.
|
|
@@ -4815,9 +4875,10 @@ is a wash; what is bought is that an offset extent cannot be written.
|
|
|
4815
4875
|
## D_final_tree — `children` and `parent` are final; no shadow tree (2026-08-30)
|
|
4816
4876
|
|
|
4817
4877
|
**Decision.** `children`, `parent`, `parent=`, `add_child`, `remove_child` and
|
|
4818
|
-
`detach_child` may not be overridden. `Component
|
|
4819
|
-
|
|
4820
|
-
|
|
4878
|
+
`detach_child` may not be overridden. `Component` declares them through
|
|
4879
|
+
`Tuile::Final`, whose `verify_final!` resolves each one and compares its
|
|
4880
|
+
`owner`, raising `Tuile::Error` from `Component#initialize` when a subclass has
|
|
4881
|
+
taken any of them. Checked once per class and memoized.
|
|
4821
4882
|
|
|
4822
4883
|
**Why a runtime check rather than the existing prose.** `D_tree_api` already
|
|
4823
4884
|
said "never override `children`" and `component_spec` already walked a tree of
|
|
@@ -4830,6 +4891,25 @@ is attached but never painted, a lifecycle hook fired for the wrong set, and a
|
|
|
4830
4891
|
click that never reaches a widget the tree still lists. Nothing raises; the
|
|
4831
4892
|
widget is just dead.
|
|
4832
4893
|
|
|
4894
|
+
**Amendment (2026-09-01): extracted to `Tuile::Final`.** The first cut fused the
|
|
4895
|
+
mechanism with this one rationale — a `FINAL_METHODS` constant plus a
|
|
4896
|
+
`verify_final!` whose raise recited three sentences about `attached?` and the
|
|
4897
|
+
parent chain. That message is nonsense printed for any *other* final method, and
|
|
4898
|
+
the fusion was noticed while weighing a second group (`bg_color` /
|
|
4899
|
+
`effective_bg_color`, so an app can't override the reader the framework reads).
|
|
4900
|
+
So the mechanism is now Ruby's missing `final` keyword and nothing more: a class
|
|
4901
|
+
`extend`s `Tuile::Final`, marks its methods, and the raise points at the
|
|
4902
|
+
offending method's own rdoc, which is where each *why* lives. Enforcement is
|
|
4903
|
+
unchanged — the resolved-`owner` check from `initialize`, memoized per class.
|
|
4904
|
+
|
|
4905
|
+
*Declared in one call, not on each `def`.* `final def foo` parses (a `def` hands
|
|
4906
|
+
back its name) and reads like Java, but YARD has no handler for the macro, so
|
|
4907
|
+
the decorated `def` loses its parameter list and sord generates
|
|
4908
|
+
`def foo: () -> void` into `sig/tuile.rbs` — measured, not feared: it dropped
|
|
4909
|
+
`add_child`'s two parameters and both `attr_reader`s outright. CI's `sig/` drift
|
|
4910
|
+
gate catches the *change*, but a newly-added `final def` would just be committed
|
|
4911
|
+
with an empty signature. So the names are listed once near the top of the class.
|
|
4912
|
+
|
|
4833
4913
|
The check earned itself immediately: `component_spec`'s own `container_with`
|
|
4834
4914
|
helper built its fixtures with `define_method(:children) { kids }`, i.e. the gem's
|
|
4835
4915
|
test suite was faking the tree it was asserting about. That is now real
|
|
@@ -5420,3 +5500,3063 @@ for the 16 named colors, which a terminal's own scheme may redefine — the one
|
|
|
5420
5500
|
mapping here that can be honestly wrong. It is documented on `Color#quantize`,
|
|
5421
5501
|
and the result is a *named* color (SGR `30..37`/`90..97`), so the user's scheme
|
|
5422
5502
|
still decides what is finally drawn. `TERM=linux` is about the only consumer.
|
|
5503
|
+
|
|
5504
|
+
---
|
|
5505
|
+
|
|
5506
|
+
## D_scrollbar_reserve — `TextView` reserves a blank column beside the bar; no knob, and not called a gutter (2026-09-01)
|
|
5507
|
+
|
|
5508
|
+
**Status:** Accepted; implemented 2026-09-01 (`TextView#scrollbar_columns`).
|
|
5509
|
+
Fixes [issue #9](https://github.com/mvysny/tuile/issues/9).
|
|
5510
|
+
|
|
5511
|
+
**Context.** With `D_status_bar`'s borderless panes, a `TextView` scrollbar sits
|
|
5512
|
+
at the pane's outermost column and text runs straight into it: `wrap_width` was
|
|
5513
|
+
`rect.width - 1`, `rewrap` padded every row to exactly that, and `paintable_row`
|
|
5514
|
+
concatenated the glyph onto the padded row, so a row wrapping at the full width
|
|
5515
|
+
put its last character in the column immediately left of `█`:
|
|
5516
|
+
|
|
5517
|
+
```
|
|
5518
|
+
enough that it must wrap against the pane edge to show the█
|
|
5519
|
+
```
|
|
5520
|
+
|
|
5521
|
+
Inside a `Window` the gap came free from the border, which is why this survived
|
|
5522
|
+
to 0.14.0 unnoticed — and why no spec caught it: every scrollbar example in
|
|
5523
|
+
`text_view_spec` used content far shorter than the viewport.
|
|
5524
|
+
|
|
5525
|
+
**The deciding fact: `List` already reserved it.** `List#pad_to_row` ellipsizes
|
|
5526
|
+
the body to `content_width - 2` and pads `" " + body + " " * (fill + 1)` — the
|
|
5527
|
+
"two row gutters" `D_select` measures a dropdown against — so a `List` row has
|
|
5528
|
+
never touched the bar. The two components only look alike at `paintable_row`.
|
|
5529
|
+
That reframed the request from "a new option on two components" to "`TextView`
|
|
5530
|
+
is inconsistent with `List`", in the direction the reporter already preferred.
|
|
5531
|
+
|
|
5532
|
+
**Decision — always reserve, no option.** `scrollbar_columns` returns the
|
|
5533
|
+
columns the bar claims (`0` hidden, else `2`), `wrap_width` subtracts it and
|
|
5534
|
+
`paintable_row` emits the blank before the glyph. An `Integer` knob defaulting
|
|
5535
|
+
to `0` was the issue's own first proposal and was rejected: it would only ever
|
|
5536
|
+
hold `0` or `1` (a boolean wearing a number), it has no defensible default once
|
|
5537
|
+
`List` is known to reserve unconditionally, and it is the per-child tuple growth
|
|
5538
|
+
`D_box_layouts` refuses — a gap between two things is a property of the pair,
|
|
5539
|
+
not a parameter of one. Both affected methods are private, so nothing public
|
|
5540
|
+
changes; existing apps rewrap one column narrower wherever a bar is visible,
|
|
5541
|
+
which is invisible inside a `Window` and is the fix everywhere else.
|
|
5542
|
+
|
|
5543
|
+
**Decision — the name `gutter` is unavailable.** The word was already spent
|
|
5544
|
+
twice in this tree, in the two incompatible industry senses: `List`'s
|
|
5545
|
+
"one-column gutter" is the blank pad (the CSS-Grid/Bootstrap *gap* sense), while
|
|
5546
|
+
`list.rb`/`text_view.rb` said "minus the scrollbar gutter" for the bar's own
|
|
5547
|
+
column (the CSS `scrollbar-gutter: stable` sense, where the gutter *is* where
|
|
5548
|
+
the bar goes; editors add a third — VS Code's gutter *contains* line numbers).
|
|
5549
|
+
So `scrollbar_gutter` would have been a third meaning contradicting a rdoc
|
|
5550
|
+
sentence six lines above it, which `D_scroll_nomenclature`'s one-word-per-concept
|
|
5551
|
+
rule forbids. The rdoc uses of the bar-column sense were reworded to "the
|
|
5552
|
+
scrollbar column", leaving the blank-pad sense as the only surviving one. Had an
|
|
5553
|
+
option shipped, the name would have been `scrollbar_spacing` — `spacing` already
|
|
5554
|
+
means "gap between two things" here (`Box#spacing`).
|
|
5555
|
+
|
|
5556
|
+
**The reserve drops below width 3.** At `rect.width == 2` a bar plus a blank
|
|
5557
|
+
leaves no column for text at all, so `scrollbar_columns` returns `1` there and
|
|
5558
|
+
at width 1. That keeps `paintable_row`'s "exactly `rect.width` columns"
|
|
5559
|
+
contract — the thing the whole paint path rests on — true at every width, which
|
|
5560
|
+
is the part a naive `- 2` would break silently.
|
|
5561
|
+
|
|
5562
|
+
**Not an AGENTS.md invariant.** The reserve lives entirely inside
|
|
5563
|
+
`text_view.rb`; no contributor can break it from another file, so it stays in
|
|
5564
|
+
that file's rdoc under the gate at the top of AGENTS.md.
|
|
5565
|
+
|
|
5566
|
+
---
|
|
5567
|
+
|
|
5568
|
+
## D_bg_surface — A component's own background: `default_bg_color`, keyed by state (2026-09-01)
|
|
5569
|
+
|
|
5570
|
+
**Status:** Accepted; implemented 2026-09-01. Closes
|
|
5571
|
+
[issue #11](https://github.com/mvysny/tuile/issues/11). Extends `D_bg_inherit`
|
|
5572
|
+
and `D_theme_ref`, which built the inheritance chain but left no level for a
|
|
5573
|
+
widget's *own* surface.
|
|
5574
|
+
|
|
5575
|
+
**Context.** `Component#bg_color=` documented itself as tinting a component and
|
|
5576
|
+
its subtree, and `#effective_bg_color` as "the background actually painted".
|
|
5577
|
+
Neither held for an editable field: `AbstractStringField#background` reached
|
|
5578
|
+
past the chain to `screen.theme` directly, so a `TextField` ignored an inherited
|
|
5579
|
+
tint *and* ignored a `bg_color` set on itself — the value was never read on the
|
|
5580
|
+
paint path. Six widgets did some version of that reach-around
|
|
5581
|
+
(`AbstractStringField`, `Select`, `ComboBox`'s `▾`, and the focus accent in
|
|
5582
|
+
`Button` / `Checkbox` / `Tabs`), which is why AGENTS.md had to describe "three
|
|
5583
|
+
camps, don't mix them" with a standing prohibition — *inherent-bg widgets must
|
|
5584
|
+
not set `bg_color`* — on the third.
|
|
5585
|
+
|
|
5586
|
+
Diagnosis: the chain was missing a level. There were three questions and only
|
|
5587
|
+
two names. *What's behind me?* was `effective_bg_color`; *does the app want to
|
|
5588
|
+
override it?* was `bg_color`; **do I paint an opaque surface of my own, and in
|
|
5589
|
+
what color?** had no name, so every widget that needed one reached *around* the
|
|
5590
|
+
chain instead of contributing *to* it.
|
|
5591
|
+
|
|
5592
|
+
**Decision.** Add the missing level as a protected hook, and let the app's
|
|
5593
|
+
answer at any level be keyed by component state.
|
|
5594
|
+
|
|
5595
|
+
```
|
|
5596
|
+
effective_bg_color = @bg_color || default_bg_color || parent.effective_bg_color
|
|
5597
|
+
```
|
|
5598
|
+
|
|
5599
|
+
- `default_bg_color` is protected, `nil` by default ("no surface of my own"),
|
|
5600
|
+
and overridden by the widgets that paint one. A non-nil answer terminates
|
|
5601
|
+
inheritance, which is what keeps a form's fields looking like fields inside a
|
|
5602
|
+
tinted panel.
|
|
5603
|
+
- `bg_color` (and `default_bg_color`) accept a `Hash` keyed by `BG_STATES`
|
|
5604
|
+
(`:normal`, `:active`) beside a `Color` / `Theme::Ref`. A missing key is not
|
|
5605
|
+
answered at that level and falls through, so `{ active: blue }` means "keep my
|
|
5606
|
+
own well, override the focus shade" and a flat `Color` means flat in every
|
|
5607
|
+
state.
|
|
5608
|
+
- `effective_bg_color` becomes protected and final; `bg_color` stays the one
|
|
5609
|
+
public knob.
|
|
5610
|
+
- `clear_outside_extent` blanks with a private `ambient_bg_color`
|
|
5611
|
+
(`@bg_color`, else the parent's `effective_bg_color`), skipping the widget's
|
|
5612
|
+
own default: outside its extent the widget is not there.
|
|
5613
|
+
- `AbstractStringField#background` is deleted; the text fields paint through
|
|
5614
|
+
`draw_text` like every other self-painter.
|
|
5615
|
+
|
|
5616
|
+
**Why a state map rather than picking one focus behavior.** The narrower
|
|
5617
|
+
question was where a field's focus highlight lives: *inside* the hook
|
|
5618
|
+
(`active? ? active_bg : input_bg`, so an app tint replaces the shade too) or
|
|
5619
|
+
*outside* it, as a `with_bg` layer over whatever resolved (so the shade
|
|
5620
|
+
survives a tint). Each solves half the problem. Inside, `select.bg_color = X`
|
|
5621
|
+
silently removes the *only* focus indicator a `Select` has — it paints no
|
|
5622
|
+
caret. Outside, an app can never produce the flat, focus-invariant surface that
|
|
5623
|
+
motivated the issue, and the mechanism covers only the widgets that happen to
|
|
5624
|
+
paint a well.
|
|
5625
|
+
|
|
5626
|
+
The state map subsumes both, and turns the hole in the first option into a
|
|
5627
|
+
choice: pass a flat color and you get a flat surface; pass a pair and you keep a
|
|
5628
|
+
shade of your own choosing. It is also the shape the hook already wanted — one
|
|
5629
|
+
channel answering "what color for the state I am in" — where the layer variant
|
|
5630
|
+
needs a second, un-settable channel beside it.
|
|
5631
|
+
|
|
5632
|
+
**Why this is not the CSS road.** The reflex objection was that state keys are
|
|
5633
|
+
pseudo-classes. They are not: what makes CSS CSS is selectors matching across
|
|
5634
|
+
the tree, plus specificity, plus the cascade. A small closed set of states,
|
|
5635
|
+
resolved on the component itself, is **Android's `ColorStateList`** — a bounded,
|
|
5636
|
+
well-regarded design. The guardrails that keep it there, all enforced or
|
|
5637
|
+
recorded: the key set is framework-defined and the setter raises on anything
|
|
5638
|
+
else; a key is added only when Tuile grows the *state* (hence no `:disabled`
|
|
5639
|
+
today — there is no disabled state, no `enabled?`, no focus-skipping and no
|
|
5640
|
+
theme token, and a key promising one would be a lie); and a `Hash` resolves
|
|
5641
|
+
against **its owner's** state only, never a descendant's.
|
|
5642
|
+
|
|
5643
|
+
**Naming.** `normal:` over `inactive:` — a state set names its base state
|
|
5644
|
+
positively, or the day `disabled:` arrives `inactive` reads as a superset of it.
|
|
5645
|
+
`default:` was unavailable: "terminal default" is load-bearing vocabulary in
|
|
5646
|
+
exactly this area.
|
|
5647
|
+
|
|
5648
|
+
**Alternatives rejected.**
|
|
5649
|
+
- *Override the `bg_color` **reader*** in `AbstractStringField` (`super || well`)
|
|
5650
|
+
— a two-line fix needing no new API. Rejected on three counts: the reader
|
|
5651
|
+
stops meaning "what the app set", which is the test the framework needs to
|
|
5652
|
+
distinguish an app tint from a widget well; that distinction is exactly what
|
|
5653
|
+
the dead-tail rule requires, so recovering it means reading `@bg_color` around
|
|
5654
|
+
your own accessor; and a widget's private well silently becomes a subtree tint.
|
|
5655
|
+
`bg_color` / `bg_color=` / `effective_bg_color` are marked final partly to
|
|
5656
|
+
foreclose this route.
|
|
5657
|
+
- *A public `opaque=` flag* (default false, true on fields, app-flippable). It
|
|
5658
|
+
found something real — a per-instance opt-out, unreachable today without
|
|
5659
|
+
subclassing — but the name imports the compositing model `D_bg_inherit`
|
|
5660
|
+
refused, and collides head-on with the standing "terminal cells are opaque".
|
|
5661
|
+
It is also a no-op on any component with no `default_bg_color`, and dead in
|
|
5662
|
+
combination with a set `bg_color`. The capability itself was real, and landed
|
|
5663
|
+
the same day as the sentinel this note called for — see the amendment below.
|
|
5664
|
+
- *A `bg_color=` forwarded from a composed field to its inner one*
|
|
5665
|
+
(`super; content.bg_color = color`) — illegal, since the setter is final, and
|
|
5666
|
+
unnecessary: the composer owns the well and marks its face `BG_INHERIT`
|
|
5667
|
+
instead, which also deletes the duplicated `active? ? … : …` the `ComboBox`
|
|
5668
|
+
`▾` was carrying.
|
|
5669
|
+
- *Migrating `Button` / `Checkbox` / `Tabs` / `MenuBar` / `List` onto the hook.*
|
|
5670
|
+
Expressible — `{ active: active_bg_color }` with no `:normal` key is exactly
|
|
5671
|
+
their behavior — but deliberately not done here. Their accent is `with_bg`
|
|
5672
|
+
(override-all) where the chain is `under_bg` (fill-unset), so an app-styled
|
|
5673
|
+
caption span would start surviving the highlight; and for `Tabs` / `MenuBar` /
|
|
5674
|
+
`List` the accent covers a *segment or row*, not the component, which the
|
|
5675
|
+
per-component hook cannot express at all. A separate call, on its own merits.
|
|
5676
|
+
|
|
5677
|
+
**Consequences.**
|
|
5678
|
+
- AGENTS.md's "three camps" becomes two, and the prohibition on a well widget
|
|
5679
|
+
setting `bg_color` is gone — that is the bug, not the rule.
|
|
5680
|
+
- `Select#face_row` no longer stomps span backgrounds with `with_bg`, so an item
|
|
5681
|
+
label carrying its own background now keeps it.
|
|
5682
|
+
- A new composed field owes a `default_bg_color`, or its face paints untinted;
|
|
5683
|
+
a new widget with a well owes one plus an `extent`, or its dead tail lies.
|
|
5684
|
+
- The hook must not allocate: `TextArea` resolves the chain once per painted
|
|
5685
|
+
row, so a `Hash` built per call would put an allocation on the repaint path.
|
|
5686
|
+
Branch on `active?` and return one `Color`.
|
|
5687
|
+
**Amendment (2026-09-01): `BG_INHERIT`, and ownership is told, not inferred.**
|
|
5688
|
+
The first cut had `AbstractStringField#default_bg_color` return `nil` when
|
|
5689
|
+
`parent.is_a?(HasValue)` — the leaf working out for itself whether a composed
|
|
5690
|
+
field owned its surface. That was wrong in both directions, and it was
|
|
5691
|
+
positional where the question is structural:
|
|
5692
|
+
|
|
5693
|
+
- *False negative.* Anything inserted between composer and field — a `Layout`,
|
|
5694
|
+
a `Slot` — makes the parent something else, the field reclaims its well, and
|
|
5695
|
+
the composer's `bg_color` goes inert over the field's cells again. Issue #11
|
|
5696
|
+
resurrected by a refactor with nothing to do with backgrounds.
|
|
5697
|
+
- *False positive.* An app composite that happens to include `HasValue` and
|
|
5698
|
+
holds a `TextField` alongside other widgets silently loses a well it wanted,
|
|
5699
|
+
with nothing saying why.
|
|
5700
|
+
|
|
5701
|
+
So the owner says it out loud. `Component::BG_INHERIT` (the Symbol `:inherit`)
|
|
5702
|
+
assigned to `bg_color` means **skip my own `default_bg_color`, take what
|
|
5703
|
+
surrounds me** — CSS's `background: inherit`. Each composer marks its face
|
|
5704
|
+
(`field.bg_color = BG_INHERIT`) at construction and declares the well itself;
|
|
5705
|
+
`AbstractStringField#default_bg_color` is unconditional again. `spec` pins both
|
|
5706
|
+
halves, including a `Layout` inserted between composer and field, which is the
|
|
5707
|
+
case the old rule failed.
|
|
5708
|
+
|
|
5709
|
+
*Why the sentinel rather than a marker flag.* It is the opt-out the `opaque=`
|
|
5710
|
+
alternative above was groping for, and it serves a **second** caller: an app
|
|
5711
|
+
making a field sit flush in a tinted panel writes `field.bg_color = BG_INHERIT`
|
|
5712
|
+
rather than repeating the ancestor's `Theme::Ref` or subclassing (the pikuri
|
|
5713
|
+
prompt that opened issue #11). One property, no dead combinations, no no-op on
|
|
5714
|
+
components without a default. `nil` keeps its own distinct meaning — fall
|
|
5715
|
+
through to `default_bg_color` *first* — so the two are not redundant.
|
|
5716
|
+
|
|
5717
|
+
*What it costs.* `ambient_bg_color` owes the same `BG_INHERIT` check as
|
|
5718
|
+
`effective_bg_color`, or the dead tail paints the literal `:inherit`. And the
|
|
5719
|
+
mark and the override are a **pair**: measured on 2026-09-01, dropping the
|
|
5720
|
+
composer's `default_bg_color` while keeping the mark leaves the face with no
|
|
5721
|
+
well at all (`nil`, a ComboBox reads as plain text); dropping both puts the
|
|
5722
|
+
inner field's own well back and makes the composer's `bg_color` inert over it
|
|
5723
|
+
(`IntegerField` entirely so) with the `▾` alone taking the tint. Neither is
|
|
5724
|
+
caught by the numeric fields' specs, which assert nothing about their well.
|
|
5725
|
+
|
|
5726
|
+
*Named, not chosen:* `TRANSPARENT` — accurate about the effect, but it imports
|
|
5727
|
+
the compositing model `D_bg_inherit` refused and collides with the standing
|
|
5728
|
+
"terminal cells are opaque". `INHERIT` is the house word (this section is
|
|
5729
|
+
"Background color (opt-in, inherited)") and matches CSS. A bare Symbol is safe
|
|
5730
|
+
where `D_theme_ref` rejected one for `Theme::Ref`: that objection was
|
|
5731
|
+
ambiguity with `Color.coerce`'s ANSI colour *names*, and `:inherit` is not one.
|
|
5732
|
+
|
|
5733
|
+
- **`Label#bg` is deleted** (the wart `D_bg_inherit` flagged and parked). It
|
|
5734
|
+
predated the chain and did two things: fill behind the text, the trailing pad
|
|
5735
|
+
and the blank rows — which is exactly `bg_color` now, down to the padding,
|
|
5736
|
+
since `Label#repaint` routes every row through `draw_text` — and *stomp* a
|
|
5737
|
+
span's own background via `with_bg`. Only the second was unique, and it is a
|
|
5738
|
+
restyle of the text rather than a property of the component, so it belongs on
|
|
5739
|
+
the text: `label.text = text.with_bg(c)`. Migration is `label.bg = c` →
|
|
5740
|
+
`label.bg_color = c`, plus the `with_bg` above only if the text carries span
|
|
5741
|
+
backgrounds you meant to override. Keeping it would have left two spellings of
|
|
5742
|
+
"this label's background" that differ only in an edge case, one of them
|
|
5743
|
+
invisible to inheritance, `Theme::Ref` and the state map.
|
|
5744
|
+
|
|
5745
|
+
---
|
|
5746
|
+
|
|
5747
|
+
## D_scrollbar_ink — The scrollbar's ink: a theme token, and no handle when nothing scrolls (2026-09-01)
|
|
5748
|
+
|
|
5749
|
+
**Status:** Accepted; implemented 2026-09-01 (`Theme#scrollbar_color`,
|
|
5750
|
+
`VerticalScrollBar#scrollbar_char`, `VerticalScrollBar.handle_char=`). Fixes
|
|
5751
|
+
[issue #10](https://github.com/mvysny/tuile/issues/10). Sits beside
|
|
5752
|
+
`D_scrollbar_reserve`, which fixed the bar's *geometry* from the same borderless
|
|
5753
|
+
pane work; this fixes its *ink*.
|
|
5754
|
+
|
|
5755
|
+
**Context.** `scrollbar_char` returned a bare `█` / `░` and both call sites —
|
|
5756
|
+
`List#paintable_row` and `TextView#paintable_row` — wrapped it in
|
|
5757
|
+
`StyledString.plain`, so the bar painted in the terminal's **default
|
|
5758
|
+
foreground**: on a dark scheme, near-white. And it was loudest exactly when it
|
|
5759
|
+
said least, because `row_count <= height` sets `handle_height = height`: a
|
|
5760
|
+
full-height, 100%-ink column carrying no information at all. Inside a `Window`
|
|
5761
|
+
this read as chrome; in a borderless pane (`D_status_bar`'s panes, a
|
|
5762
|
+
`LogTextView`) it became the loudest thing on screen, with `scrollbar_visibility
|
|
5763
|
+
= :gone` — trading the whole indicator away — as the only lever.
|
|
5764
|
+
|
|
5765
|
+
The two halves are independent, and only one of them needs a theme.
|
|
5766
|
+
|
|
5767
|
+
**Decision — no handle when there is nothing to scroll, and that is an *ink*
|
|
5768
|
+
rule.** `scrollbar_char` returns the track glyph at every row when
|
|
5769
|
+
`row_count <= height`. The handle geometry readers still report the covering
|
|
5770
|
+
handle; what changed is what gets drawn. This matters because the issue asked
|
|
5771
|
+
for it as an `:auto` **visibility** mode, which `D_select` refuses and still
|
|
5772
|
+
refuses: making visibility a function of `rect.height` makes `content_width` /
|
|
5773
|
+
`wrap_width` a function of `rect.height` too, while the padded-row cache is
|
|
5774
|
+
rebuilt from the width-only `on_width_changed` — a height-only resize would
|
|
5775
|
+
leave every row one column off, silently. Going quiet inside the *glyph* touches
|
|
5776
|
+
none of that: `scrollbar_visible?` stays true, the column stays reserved, the
|
|
5777
|
+
wrap width never moves (pinned by a spec in each component). So the request is
|
|
5778
|
+
granted without reopening the ban, and the two must not be conflated later —
|
|
5779
|
+
`:auto` is still the wrong name and the wrong mechanism.
|
|
5780
|
+
|
|
5781
|
+
*Rejected: a third `scrollbar_visibility` value* (`:when_scrollable`) preserving
|
|
5782
|
+
the old look under `:visible`. It would spend API surface defending the exact
|
|
5783
|
+
behavior being complained about; a full-height solid handle has no defenders.
|
|
5784
|
+
The change is cosmetic and ships as a `Fix`.
|
|
5785
|
+
|
|
5786
|
+
*Rejected: painting blanks rather than `░`.* The track keeps the affordance —
|
|
5787
|
+
"a bar lives here, there is nothing to scroll" — where a blank column reads as a
|
|
5788
|
+
layout bug. And with `DARK`'s token the sparse `░` is already near-invisible,
|
|
5789
|
+
which is the quiet the issue asked for.
|
|
5790
|
+
|
|
5791
|
+
**Decision — one token, `Theme#scrollbar_color`, read at paint time.** The
|
|
5792
|
+
precedent is exact: `active_border_color` is framework-chrome *foreground*, and
|
|
5793
|
+
this is the same kind of thing. Both `paintable_row`s style the glyph span with
|
|
5794
|
+
`screen.theme.scrollbar_color`; `VerticalScrollBar` stays a pure geometry helper
|
|
5795
|
+
with no `Screen` dependency, which is why the token is read by the components and
|
|
5796
|
+
not by it. Two consequences fall out free: `CHROME_TOKENS` derives from
|
|
5797
|
+
`members`, so `Theme.ref(:scrollbar_color)` works the day it lands
|
|
5798
|
+
(`D_theme_ref`), and `Buffer#flush` quantizes at the wire, so nothing is
|
|
5799
|
+
pre-degraded (`D_color_depth`).
|
|
5800
|
+
|
|
5801
|
+
*Rejected: a second token for the empty track*, which the issue floats on the
|
|
5802
|
+
grounds that `░` and `█` want different weights against the same background. They
|
|
5803
|
+
already have them: `░` is ~25% ink against `█`'s 100%, so the glyph delivers the
|
|
5804
|
+
weight difference a second token would buy. And with the quiet-handle rule above,
|
|
5805
|
+
`░` is the *resting* state and `█` appears only when it means something. A second
|
|
5806
|
+
token stays purely additive if one proves flat.
|
|
5807
|
+
|
|
5808
|
+
*Rejected: reusing `hint_color`.* Tempting — it is the theme's "de-emphasized
|
|
5809
|
+
chrome" color — but since `D_status_bar` deleted the status bar in 0.13.0,
|
|
5810
|
+
`hint_color` is the one token **nothing in `lib/` paints with**: it is now
|
|
5811
|
+
app-facing. Loading framework chrome onto it means a theme author cannot retune
|
|
5812
|
+
their hints without retuning every scrollbar.
|
|
5813
|
+
|
|
5814
|
+
*Rejected: a per-component `scrollbar_color=` accessor.* The same rule
|
|
5815
|
+
`D_bg_surface` closes with — a component does not grow a private color accessor
|
|
5816
|
+
beside the theme channel. An app that wants one bar different from another styles
|
|
5817
|
+
the theme, or (per-slot, live-resolved) uses `Theme.ref`.
|
|
5818
|
+
|
|
5819
|
+
**Decision — the two glyphs are an app-global knob on the class.**
|
|
5820
|
+
`VerticalScrollBar.handle_char=` / `.track_char=`, so an app can ask for a
|
|
5821
|
+
lazygit-style bar (`▐` over a `│` rule) in two lines. Scope was the whole
|
|
5822
|
+
question, and app-global is right for the same reason `Theme` is: a scrollbar
|
|
5823
|
+
style is look-and-feel, which an app wants *uniform*, and per-component styling
|
|
5824
|
+
would make inconsistency the default. It is also the shape `D_ambiguous_width`
|
|
5825
|
+
already blessed — the pretty glyph offered as an opt-in knob, alongside
|
|
5826
|
+
`TextField#mask_char=` — and the precedent for a reassignable app-global lives
|
|
5827
|
+
next door in `ThemeDef.default`, whose spec-restore discipline this inherits.
|
|
5828
|
+
Both call sites already construct a bar per repaint, so nothing had to be
|
|
5829
|
+
plumbed through `List` / `TextView`.
|
|
5830
|
+
|
|
5831
|
+
*Rejected: passing the glyphs through the constructor, or a per-component
|
|
5832
|
+
`scrollbar_glyphs=`.* Two components, two more setters, two more things to keep
|
|
5833
|
+
in sync, to serve a choice an app makes once. If one component ever genuinely
|
|
5834
|
+
needs to differ, an instance override is additive on top of this.
|
|
5835
|
+
|
|
5836
|
+
**The knob validates at assignment: one grapheme cluster, one column.**
|
|
5837
|
+
`paintable_row` concatenates the glyph onto a row padded to fill the rest of the
|
|
5838
|
+
rect, so a two-column glyph pushes *every* painted row one column past
|
|
5839
|
+
`rect.width` — the "exactly `rect.width` columns" contract the whole paint path
|
|
5840
|
+
rests on, broken silently, with nothing in the diff to notice. That is the same
|
|
5841
|
+
silent-corruption class `D_select` refuses an `:auto` mode for, and it is why the
|
|
5842
|
+
check is at the writer rather than at paint, where the symptom is a corrupt frame
|
|
5843
|
+
with nothing to point at. Note this stays *inside* `D_ambiguous_width`'s bet
|
|
5844
|
+
rather than testing it: `█`, `░`, `│` and `▐` are all East-Asian Ambiguous and
|
|
5845
|
+
all measure 1 under the gem's policy.
|
|
5846
|
+
|
|
5847
|
+
**The token is required, not defaulted.** `Theme` is a `Data.define` with
|
|
5848
|
+
required kwargs, so a new member breaks an explicit `Theme.new(...)`. Defaulting
|
|
5849
|
+
it would have kept those callers working while baking a dark-tuned grey into
|
|
5850
|
+
light themes; `theme.rb`'s own rdoc says a theme "is declared once so the
|
|
5851
|
+
verbosity self-documents", and the class fail-fasts on every other input
|
|
5852
|
+
(`TypeError` on a non-`Color`, `KeyError` on a `custom` typo). So it ships as a
|
|
5853
|
+
**Breaking** changelog entry with a one-line migration. `Theme::DARK.with(...)`,
|
|
5854
|
+
the documented construction path, is unaffected either way.
|
|
5855
|
+
|
|
5856
|
+
**Defaults.** `DARK` reuses `GREY37` (59), the `active_bg_color` value, so the
|
|
5857
|
+
handle carries the same weight as the selection well — and the sparse track at
|
|
5858
|
+
that color is near-invisible against a #1d–#2d terminal background, which is the
|
|
5859
|
+
resting state we want. `LIGHT` inverts the reasoning rather than copying the
|
|
5860
|
+
token: a *foreground* on a pale background must be darker than it, so `GREY62`
|
|
5861
|
+
(247, ~#9e9e9e) sits a step below the light theme's highlights instead of
|
|
5862
|
+
matching them.
|
|
5863
|
+
|
|
5864
|
+
**Parked: focus-awareness.** The bar in the *focused* pane is the one the user
|
|
5865
|
+
can actually drive, so it arguably wants the brighter ink — the question
|
|
5866
|
+
`ideas/focus-accent.md` holds. Doing it here would mean importing `BG_STATES`-style
|
|
5867
|
+
state-keyed maps (`D_bg_surface`) into a foreground token for one widget's sake.
|
|
5868
|
+
Not built, not foreclosed.
|
|
5869
|
+
|
|
5870
|
+
## D_paste_newlines — A one-line field keeps the paste's first line (2026-09-03)
|
|
5871
|
+
|
|
5872
|
+
**Status:** Accepted and implemented in `TextField#preprocess_paste`. Supersedes
|
|
5873
|
+
the flatten-to-spaces rule recorded in `D_bracketed_paste`.
|
|
5874
|
+
|
|
5875
|
+
**Context.** `TextField` holds one row, so a pasted `\n` has to go somewhere. It
|
|
5876
|
+
used to become a space. That is wrong on the *dominant* real paste: a whole line
|
|
5877
|
+
copied from an editor or a terminal carries a trailing newline, and flattening
|
|
5878
|
+
turned `"widget-3141\n"` into `"widget-3141 "` — an invisible trailing space that
|
|
5879
|
+
survives into whatever the app does with the value, and that no user can see to
|
|
5880
|
+
delete.
|
|
5881
|
+
|
|
5882
|
+
**Nobody agrees, so "what everyone does" was not available.** Measured against
|
|
5883
|
+
the neighbours rather than recalled:
|
|
5884
|
+
|
|
5885
|
+
| toolkit | `"a\nb"` pasted into its single-line input | mechanism |
|
|
5886
|
+
|---|---|---|
|
|
5887
|
+
| HTML `<input type=text>` | `"ab"` — **stripped** | the spec's value sanitization algorithm ("strip newlines") |
|
|
5888
|
+
| Textual `Input` (a TUI) | `"a"` — **first line** | `event.text.splitlines()[0]` in `_on_paste` |
|
|
5889
|
+
| GTK4 `GtkText` / `GtkEntry` | `"a"` with `truncate-multiline`, else all of it | the `truncate-multiline` property, default `FALSE` |
|
|
5890
|
+
| Swing `JTextField` | `"a b"` — **space** | `PlainDocument`'s `filterNewlines`, set true by `JTextField` |
|
|
5891
|
+
| Qt `QLineEdit` | value keeps `"a\nb"`; *displays* `"a b"` | `QWidgetLineControl::updateDisplayText` rewrites C0 to spaces at paint |
|
|
5892
|
+
|
|
5893
|
+
Three camps, and Qt is really a fourth: it never sanitizes the value at all, only
|
|
5894
|
+
the pixels, so `text()` hands back a string with a newline in it that the widget
|
|
5895
|
+
never showed. Tuile can't take that road — `Buffer` is a grid of cells and a `\n`
|
|
5896
|
+
reaching it corrupts the frame — but it is worth naming, because it is the road
|
|
5897
|
+
"just fix the paint" leads to.
|
|
5898
|
+
|
|
5899
|
+
**Decision — keep the first line, drop the rest.** `super[/\A[^\n]*/]`, before
|
|
5900
|
+
the `max_text_length` trim. Of the three real options it is the only one that
|
|
5901
|
+
never *invents* content: stripping fuses `"John Smith\nMain St"` into
|
|
5902
|
+
`"John SmithMain St"`, a token that was in nobody's clipboard, and spacing
|
|
5903
|
+
manufactures the trailing blank above. Truncation only ever discards, and it
|
|
5904
|
+
discards the part a one-row field could not have shown anyway. It also agrees
|
|
5905
|
+
with stripping on the case that actually happens (a trailing newline), so the
|
|
5906
|
+
difference between them is confined to pastes that were already never going to
|
|
5907
|
+
fit.
|
|
5908
|
+
|
|
5909
|
+
Textual is the precedent that counts here — same medium, same constraint, same
|
|
5910
|
+
ruling — over HTML's, which inherits a sanitization algorithm written for form
|
|
5911
|
+
submission rather than for editing.
|
|
5912
|
+
|
|
5913
|
+
*Rejected: a `truncate_multiline` knob*, GTK's answer. It buys the caller a
|
|
5914
|
+
choice between two lossy behaviours neither of which they can act on, and a
|
|
5915
|
+
field that keeps every line is a `TextArea`. *Rejected: rejecting the paste
|
|
5916
|
+
outright* — that is right for a field whose grammar the paste violates
|
|
5917
|
+
(`D_input_filters`) and wrong here, where the first line is perfectly good input.
|
|
5918
|
+
|
|
5919
|
+
## D_input_filters — Filter input at `insert_text`, and only where the grammar is prefix-closed (2026-09-03)
|
|
5920
|
+
|
|
5921
|
+
**Status:** Accepted and implemented in `AbstractStringField#insert_text`
|
|
5922
|
+
(promoted to the documented seam), `TextField#insert` / `TextArea#insert_char`
|
|
5923
|
+
(both now route through it), and the nested `Field` subclasses of
|
|
5924
|
+
`IntegerField`, `FloatField` and `BigDecimalField`.
|
|
5925
|
+
|
|
5926
|
+
**Context — the filter was on the wrong event, and shipped broken for it.**
|
|
5927
|
+
The three numeric fields kept their "digits only" rule in a `field_key` proc
|
|
5928
|
+
wired as the inner field's `on_key`, and `on_key` is consulted only from
|
|
5929
|
+
`handle_key`. A paste is not a key (`D_bracketed_paste`), so it walked straight
|
|
5930
|
+
past. Measured on `master` before this entry:
|
|
5931
|
+
|
|
5932
|
+
```
|
|
5933
|
+
typed "xyz" → text "" value nil # filter works
|
|
5934
|
+
pasted "xyz" → text "xyz" value nil # filter bypassed
|
|
5935
|
+
42, pasted "abc" at 0 → text "abc42" value nil
|
|
5936
|
+
FloatField, "1,5" → text "1,5" value nil # a plausible real paste
|
|
5937
|
+
```
|
|
5938
|
+
|
|
5939
|
+
So all three fields could be put in a state their own rdoc said was impossible,
|
|
5940
|
+
with one Ctrl-V. The deeper fault is one of *altitude*: the rule constrains the
|
|
5941
|
+
**buffer**, and it was written against the **event**, which is why there were two
|
|
5942
|
+
paths and only one of them was guarded.
|
|
5943
|
+
|
|
5944
|
+
**Decision — one seam, at the text mutation.** `insert_text` is now the sole
|
|
5945
|
+
insertion point for every string field: a typed character
|
|
5946
|
+
(`TextField#insert`), the ENTER newline (`TextArea#insert_char`) and a whole
|
|
5947
|
+
pasted clipboard all land there. A field constrains its contents by overriding
|
|
5948
|
+
it. There is no second thing to remember, and no way to guard typing and forget
|
|
5949
|
+
paste — the shape of the code makes that misfeature unwritable.
|
|
5950
|
+
|
|
5951
|
+
**Decision — judge the result, not the fragment.** The override tests the whole
|
|
5952
|
+
resulting buffer against a `TYPEABLE` regexp rather than sieving the inserted
|
|
5953
|
+
string character by character. Sieving is the seductive one — it "salvages" a
|
|
5954
|
+
messy paste — and it is how `"1,5"` becomes `"15"`: a plausible number, off by a
|
|
5955
|
+
factor of ten, that the user never copied and cannot see is wrong. Rejecting the
|
|
5956
|
+
paste whole is also exactly what typing the comma does, so the two paths stay
|
|
5957
|
+
indistinguishable to the user.
|
|
5958
|
+
|
|
5959
|
+
**Decision — prevention requires a prefix-closed grammar; otherwise report.**
|
|
5960
|
+
This is the criterion that says which mechanism a field gets, and it is the
|
|
5961
|
+
useful half of this entry:
|
|
5962
|
+
|
|
5963
|
+
- A grammar is **prefix-closed** when every valid value can be reached through
|
|
5964
|
+
valid intermediate states. An integer buffer is (`""`, `"-"`, `"-1"`), so is a
|
|
5965
|
+
decimal (`"1."` must be reachable, and is a member of `TYPEABLE` even though
|
|
5966
|
+
`value` reads it as `1`). Such a field can *prevent* bad input, and then it
|
|
5967
|
+
has none: `IntegerField#value` is now `nil` only for the two half-typed states,
|
|
5968
|
+
never for garbage.
|
|
5969
|
+
- A date is **not**: `"2020-13-45"` is well-formed at every character and denotes
|
|
5970
|
+
nothing, and month lengths and leap years are whole-string facts, so no filter
|
|
5971
|
+
over insertions can decide it. A field like that must accept the input and
|
|
5972
|
+
report it bad — the `HasBadInput` channel (`D_bad_input`).
|
|
5973
|
+
|
|
5974
|
+
The two are complements, not rivals: prevention where it is total, reporting
|
|
5975
|
+
where prevention is impossible. What must not happen is a *partial* filter, which
|
|
5976
|
+
is the worst of both — it looks like a guarantee, and isn't.
|
|
5977
|
+
|
|
5978
|
+
**Roads not taken, for keeping a value out of a field.**
|
|
5979
|
+
|
|
5980
|
+
- *Watch `on_change` and revert.* The callback has already fired for the state
|
|
5981
|
+
you are about to undo, so every other observer sees the bad value and acts on
|
|
5982
|
+
it; the revert fires a second round; and the caret has nowhere sensible to
|
|
5983
|
+
land. It also cannot distinguish a bad *user edit* from a bad programmatic
|
|
5984
|
+
`value=`.
|
|
5985
|
+
- *A veto seam on `HasValue`.* Wrong layer twice over. `HasValue` is deliberately
|
|
5986
|
+
thin (`D_has_value`), and for the composed fields `value` is a **derived
|
|
5987
|
+
parse** of the buffer (`D_integer_field`) — there is no "value is being set"
|
|
5988
|
+
moment to veto while the user types, so any veto would have to run on the
|
|
5989
|
+
buffer anyway, which is where it now is.
|
|
5990
|
+
- *Keep the per-key filter and add a parallel paste filter.* The two-seam design
|
|
5991
|
+
that caused this. A future field would have to remember both, and the one that
|
|
5992
|
+
forgot would fail silently and only under Ctrl-V.
|
|
5993
|
+
- *Filter in `text=`.* Too wide: it would police the programmatic `value=`, which
|
|
5994
|
+
legitimately writes shapes no key types (`FloatField`'s `"1.0e-05"`). Only
|
|
5995
|
+
*user input* is filtered, which is exactly what `insert_text` means.
|
|
5996
|
+
|
|
5997
|
+
**Consequence — `e` became typeable in a `FloatField`.** The old per-character
|
|
5998
|
+
rule admitted no `e`, while `value = 1e-5` writes `"1.0e-05"` into the buffer, so
|
|
5999
|
+
the field could display a value the user was then unable to edit — every
|
|
6000
|
+
insertion into it would now be rejected by a `TYPEABLE` without an exponent. The
|
|
6001
|
+
grammar therefore admits the exponent, which widens typing slightly and closes
|
|
6002
|
+
that hole. A displayed buffer should always be one the user can go on editing;
|
|
6003
|
+
that is a general rule for a field with a `TYPEABLE`.
|
|
6004
|
+
|
|
6005
|
+
## D_no_key_interceptor — No `on_key` callback: a component that wants a key subclasses (2026-09-03)
|
|
6006
|
+
|
|
6007
|
+
**Status:** Accepted. `AbstractStringField#on_key` is **deleted**; its three
|
|
6008
|
+
consumers moved to `ComboBox#handle_key` + `on_escape`, the sampler's
|
|
6009
|
+
`SlashCommandTextArea`, and (downstream) a `TextArea` subclass in pikuri-tui's
|
|
6010
|
+
`ConfirmerPopup`.
|
|
6011
|
+
|
|
6012
|
+
**Context — it was a veto wearing a listener's clothes.** `on_key` was a proc
|
|
6013
|
+
consulted *before* the field's own key handling, with a truthy return consuming
|
|
6014
|
+
the key. Every sibling seam on the class either reports something
|
|
6015
|
+
(`on_change`, `on_value_change`) or claims **one named key** (`on_enter`,
|
|
6016
|
+
`on_escape`, `on_key_up`, `on_key_down`). `on_key` claimed *all* of them,
|
|
6017
|
+
pre-emptively. That is a behavior override, and COP's answer to a behavior
|
|
6018
|
+
override is the sanctioned inheritance carve-out — subclass the widget to *be*
|
|
6019
|
+
the component — not an injected proc.
|
|
6020
|
+
|
|
6021
|
+
**Decision — delete it; `handle_key` (and its `handle_text_input_key` hook) is
|
|
6022
|
+
the seam.** Three properties decided it:
|
|
6023
|
+
|
|
6024
|
+
- **It duplicated an existing, better seam.** `Component#handle_key` is already
|
|
6025
|
+
the per-component key hook, and it composes two ways `on_key` could not:
|
|
6026
|
+
through `super` (a subclass claims one key and inherits the rest) and through
|
|
6027
|
+
the rung-3 bubble (an ancestor sees what the focused component declined).
|
|
6028
|
+
`on_key` was one slot, no chaining.
|
|
6029
|
+
- **The slot was contended, and losing it was silent.** A composed field must
|
|
6030
|
+
claim its inner field's single `on_key` to do anything with keys — all four
|
|
6031
|
+
did — so an app writing `combo.content.on_key = mine` silently disabled the
|
|
6032
|
+
widget's own behavior, and the widget writing it silently disabled the app's.
|
|
6033
|
+
There is no such contention on a subclass.
|
|
6034
|
+
- **It sat at the wrong altitude for what people reached for it for.** The
|
|
6035
|
+
three numeric fields put their input filter there, and a paste walked past it
|
|
6036
|
+
for two releases (`D_input_filters`). By the end its own rdoc had to warn
|
|
6037
|
+
readers off the obvious use — a doc that says "don't use this API for the
|
|
6038
|
+
thing it looks like it's for" is the API being wrong, not the doc.
|
|
6039
|
+
|
|
6040
|
+
**And *not* promoted to `Component`.** The tempting generalization — if
|
|
6041
|
+
`handle_key` is on `Component`, why is `on_key` only on string fields? — points
|
|
6042
|
+
the other way. A universal pre-dispatch veto is a **fourth rung on the key
|
|
6043
|
+
ladder**: a per-component gate consulted before delivery, which is exactly the
|
|
6044
|
+
capture phase `D_key_dispatch` deleted in 0.10.0 and exactly what AGENTS.md's
|
|
6045
|
+
"no gate, no predicate and no mode flag anywhere in it" forbids. The right
|
|
6046
|
+
generalization was the one already there: `handle_key`.
|
|
6047
|
+
|
|
6048
|
+
**Each consumer got *better*, which is the evidence the seam was wrong.**
|
|
6049
|
+
|
|
6050
|
+
- **`ComboBox` needed no subclass at all** — it uses the **bubble**.
|
|
6051
|
+
`ListDropdown::MOVE_KEYS` is `UP/DOWN/PAGE_UP/PAGE_DOWN/^U/^D`, deliberately
|
|
6052
|
+
excluding Home/End *because the combo's field needs them for the caret*; the
|
|
6053
|
+
field claims just one of the six (no `on_key_up`/`on_key_down` set; `^U` clears
|
|
6054
|
+
the query as of `D_kill_keys`) and exposes no `on_enter`, so the other five
|
|
6055
|
+
plus ENTER decline and reach `ComboBox#handle_key` untouched, while printables
|
|
6056
|
+
and editing keys are consumed below and never arrive. The whole `field_key` if/elsif tree became a seven-line `handle_key`.
|
|
6057
|
+
ESC is the one exception — the field consumes it — so the combo takes it
|
|
6058
|
+
through the purpose-fit `on_escape`. All 39 combo specs passed unchanged,
|
|
6059
|
+
driving real dispatch, which is what makes the equivalence a measurement
|
|
6060
|
+
rather than an argument.
|
|
6061
|
+
- **The sampler's slash menu became `SlashCommandTextArea`.** Note what does
|
|
6062
|
+
*not* work here: routing it "through the value". `on_change` already does the
|
|
6063
|
+
refill that way, but Up/Down/PgUp/PgDn over a dropdown have no value
|
|
6064
|
+
semantics at all — navigation is irreducibly about keys. It just doesn't need
|
|
6065
|
+
a *callback*; it needs an override.
|
|
6066
|
+
- **pikuri-tui's `ConfirmerPopup`** claims ENTER on a `TextArea`, which is the
|
|
6067
|
+
exact case book ch5 already teaches as `PromptTextArea < TextArea`. The gem
|
|
6068
|
+
documented the subclass route *and* shipped the callback, and the downstream
|
|
6069
|
+
app reached for the callback — the clearest sign the two-ways-to-do-it was
|
|
6070
|
+
costing something.
|
|
6071
|
+
|
|
6072
|
+
**What is deliberately kept.** The *named* key callbacks stay:
|
|
6073
|
+
`TextField#on_enter` / `#on_key_up` / `#on_key_down` and
|
|
6074
|
+
`AbstractStringField#on_escape`. They are not vetoes — each claims one key whose
|
|
6075
|
+
meaning the widget itself has no use for, they compose (four can coexist), and
|
|
6076
|
+
ESC in particular *must* be a callback rather than a bubble, since the field
|
|
6077
|
+
consumes it before any ancestor could see it.
|
|
6078
|
+
|
|
6079
|
+
**Re-grow rule.** A general key callback comes back only if a caller appears
|
|
6080
|
+
that genuinely *cannot* subclass — which today means the framework would first
|
|
6081
|
+
have to grow a way to inject an inner field into a composed widget. Even then it
|
|
6082
|
+
would be a constructor-injected component, not a proc slot.
|
|
6083
|
+
|
|
6084
|
+
## D_bad_input — `HasBadInput`: the field reports input its value cannot represent (2026-09-03)
|
|
6085
|
+
|
|
6086
|
+
**Status:** Accepted; **v1 (the pull) implemented** in
|
|
6087
|
+
`Component::HasBadInput`, included by `IntegerField`, `FloatField` and
|
|
6088
|
+
`BigDecimalField`. The push notice (`on_bad_input_change`) is **deferred
|
|
6089
|
+
indefinitely, not rejected** — see "No push notice yet" below; the `on_blur`
|
|
6090
|
+
hook it was waiting on has since shipped for its own reasons (`D_on_blur`).
|
|
6091
|
+
|
|
6092
|
+
**Context — `on_value_change` is structurally incapable of carrying this.** It
|
|
6093
|
+
is a diff over **values**, and the map from input to value is not injective:
|
|
6094
|
+
every unrepresentable input collapses onto the same `nil`. The information was
|
|
6095
|
+
destroyed by the parse before the diff ran. With a *derived* parse
|
|
6096
|
+
(`D_integer_field`) that leaves four cases, and only one of them fires anything:
|
|
6097
|
+
|
|
6098
|
+
| input before | input after | `value` before | after | `on_value_change` |
|
|
6099
|
+
|---|---|---|---|---|
|
|
6100
|
+
| `"2020-01-01"` | `"xyz"` | a date | `nil` | **fires** (`nil`) — but says *empty*, not *bad* |
|
|
6101
|
+
| `""` | `"xyz"` | `nil` | `nil` | **silent** |
|
|
6102
|
+
| `"xyz"` | `"xyzw"` | `nil` | `nil` | **silent** |
|
|
6103
|
+
| `"xyz"` | `""` | `nil` | `nil` | **silent** — and the field is now *genuinely* empty |
|
|
6104
|
+
|
|
6105
|
+
The silent rows are the plumbing problem; the first row is the semantic one —
|
|
6106
|
+
even when an event fires it reports the wrong fact, and a form reading "empty"
|
|
6107
|
+
as "the user cleared it" saves `nil` over a value they believe they typed.
|
|
6108
|
+
Vaadin hit this and named it (v25.2 `components-binder-validation.md`): *"Since
|
|
6109
|
+
the field is optional, the binder doesn't complain… This behavior can create the
|
|
6110
|
+
illusion for the user that they were able to save an invalid value."*
|
|
6111
|
+
|
|
6112
|
+
**Decision — a mixin with one override point, returning a message or `nil`.**
|
|
6113
|
+
`bad_input_message` is the whole seam; `bad_input?` is its presence. A message
|
|
6114
|
+
rather than a boolean because the *reason* differs per field kind and the field
|
|
6115
|
+
is where that constant belongs. `nil`-means-fine is the convention
|
|
6116
|
+
`Component#extent` already uses. Being a mixin is what makes
|
|
6117
|
+
`is_a?(HasBadInput)` a locator seam for a future forms layer and for tests — the
|
|
6118
|
+
same argument that keeps `HasCaption` a mixin (`D_has_value`). Both members are
|
|
6119
|
+
**public**: the reader is the *app*, not the framework, so `D_hook_visibility`'s
|
|
6120
|
+
protected-hook rule doesn't apply. The default raises `NotImplementedError`
|
|
6121
|
+
(`Layout::Box`'s precedent) rather than returning `nil`, so including the mixin
|
|
6122
|
+
and forgetting the override is loud instead of a silent "never bad".
|
|
6123
|
+
|
|
6124
|
+
**Decision — empty input is not bad input.** An empty buffer parses to nothing
|
|
6125
|
+
too, so the naive predicate is `value.nil?` and it is wrong: an optional field
|
|
6126
|
+
left blank would block every save, which is the exact failure this channel
|
|
6127
|
+
exists to prevent, inverted. Each override therefore reads
|
|
6128
|
+
`value.nil? && !content.text.empty?`. Emptiness is `HasValue#empty?`'s fact;
|
|
6129
|
+
this one is about input the value *could not use*. The three-line rule is
|
|
6130
|
+
duplicated per field rather than derived in the mixin from an abstract `input`
|
|
6131
|
+
reader: that base would need two hooks over one expression (`D_float_field`'s
|
|
6132
|
+
duplicate-rather-than-DRY rule), and a `DateField` will not share the shape
|
|
6133
|
+
anyway — a mask distinguishes *incomplete* (`"__/05/2026"`) from *invalid*, which
|
|
6134
|
+
Vaadin gives its own message (`setIncompleteInputErrorMessage`).
|
|
6135
|
+
|
|
6136
|
+
**Decision — the field reports; it never stores a verdict.** Two error
|
|
6137
|
+
categories exist and exactly one belongs to the component:
|
|
6138
|
+
|
|
6139
|
+
| | **bad input** — this entry | a rule's verdict — elsewhere |
|
|
6140
|
+
|---|---|---|
|
|
6141
|
+
| example | `"xyz"` is not a date; a lone `"-"` | must be in the past; age ≥ 18 |
|
|
6142
|
+
| authority | **the field, and only the field** (it owns the format) | the app / a binder (it owns the domain) |
|
|
6143
|
+
| when known | on every input mutation | when the rules run |
|
|
6144
|
+
| the field's role | **it is the fact** | a mailbox it cannot fill, defend, or recompute |
|
|
6145
|
+
|
|
6146
|
+
The tempting economy is one `invalid?` flag both write. Vaadin's own custom-field
|
|
6147
|
+
guide warns against it (*"Do not rely on the same `invalid` and `errorMessage`
|
|
6148
|
+
properties for internal validation. Otherwise… external validation is likely to
|
|
6149
|
+
override or ignore the internal state."*) and then needs two mechanisms to stop
|
|
6150
|
+
the shared flag lying — a pull via `getDefaultValidator` and a push via
|
|
6151
|
+
`ValidationStatusChangeEvent`. Tuile inherits neither, because it puts the two
|
|
6152
|
+
facts in two *places* rather than sharing one cell — which is exactly what
|
|
6153
|
+
`D_has_validation` then built: `error_message` is a *stored* member only an
|
|
6154
|
+
outside validator writes, beside this *derived* one only the field answers, and
|
|
6155
|
+
there is still no `invalid?` anywhere.
|
|
6156
|
+
|
|
6157
|
+
**Decision — fixed English, one frozen constant per field kind, no
|
|
6158
|
+
interpolation.** `"not a whole number"`, never `"'xyz' is not a whole number"`:
|
|
6159
|
+
the method is called per read and a future error ink would call it per paint, so
|
|
6160
|
+
interpolating allocates a fresh `String` every call (the rule `default_bg_color`
|
|
6161
|
+
already follows), and it sidesteps quoting a 500-character paste into a message.
|
|
6162
|
+
There is no wording knob even though every *other* user-facing string in the gem
|
|
6163
|
+
is an overridable default (`ConfirmWindow.alert(..., button: "OK")`), because
|
|
6164
|
+
**the message is advisory and `bad_input?` is the escape hatch**: a consumer
|
|
6165
|
+
wanting its own prose, in any language, reads the boolean and composes its own.
|
|
6166
|
+
That is what makes fixing the language cheap *and* reversible. **Re-grow rule:**
|
|
6167
|
+
when i18n arrives it arrives as the *wording* fork — a settable message, or a
|
|
6168
|
+
catalogue lookup inside `bad_input_message` — never as a redesign of the channel.
|
|
6169
|
+
|
|
6170
|
+
**Decision — the fact is continuous; the consumers settle. No push notice yet.**
|
|
6171
|
+
Every prefix of a valid date is bad input, so typing `2026-05-01` walks nine bad
|
|
6172
|
+
states before one good one. The signal is correct at every instant and unusable
|
|
6173
|
+
if consumed naively — an enabled-state Save button would flicker while the user
|
|
6174
|
+
types *correctly*. Rather than settle centrally, the fact stays continuous and
|
|
6175
|
+
each consumer settles for itself; and v1 has no continuous consumer at all, so
|
|
6176
|
+
none is needed. A save gate asked at the click (`ideas/binder.md`) sees one
|
|
6177
|
+
settled state, and the red well reads the pull per paint. The push therefore
|
|
6178
|
+
lands with the first consumer that must react *between* keystrokes without
|
|
6179
|
+
being asked — which is also whoever owes the settling rule. Nothing is waiting
|
|
6180
|
+
on machinery any more: the commit point it would settle against is
|
|
6181
|
+
{Component#on_blur} (`D_on_blur`), which shipped for its own reasons, and the
|
|
6182
|
+
notice itself is one `attr_accessor` plus a sole-writer `sync_bad_input` in
|
|
6183
|
+
`ProgressBar#sync_ticker`'s discipline — called from every input mutation,
|
|
6184
|
+
never toggled by whichever event noticed. It is deferred for want of a
|
|
6185
|
+
*consumer*, and re-derivable in a sitting when one appears.
|
|
6186
|
+
|
|
6187
|
+
**Population — include it iff your parse is partial.** That is
|
|
6188
|
+
`D_integer_field`'s compose-vs-subclass taxonomy read from the other side:
|
|
6189
|
+
|
|
6190
|
+
- **Yes:** the three numeric fields, and a future date or masked field. Note the
|
|
6191
|
+
first three reach this list *after* prevention (`D_input_filters`): their
|
|
6192
|
+
grammar is prefix-closed, so their residue is the half-typed prefixes a filter
|
|
6193
|
+
must admit — `"-"` for `IntegerField`, and for `FloatField` an infinite family
|
|
6194
|
+
(`"e"`, `"1e"`, `"1.0e-"`, …). They need the channel least and are the only
|
|
6195
|
+
place to exercise it before a date field exists.
|
|
6196
|
+
- **No, the parse is identity:** `TextField`, `TextArea`, `PasswordField`. A
|
|
6197
|
+
string field's value *is* its input. `PasswordField` also pins a vocabulary
|
|
6198
|
+
boundary — **input is what the user put in, never what is painted**; the
|
|
6199
|
+
asterisks are `display_text`, one level below.
|
|
6200
|
+
- **No, input and value are one act:** `Checkbox`, `Select`, `RadioGroup`,
|
|
6201
|
+
`CheckboxGroup`. Nothing sits between the keystroke and the value.
|
|
6202
|
+
- **No, and it is the interesting exclusion:** `ComboBox`. It has an input layer,
|
|
6203
|
+
but the input is a **filter**, not a formatting of the value, so a no-match is
|
|
6204
|
+
not a failed conversion — it resolves the desync by *reverting* the query. A
|
|
6205
|
+
third strategy beside nil-out and report, and the reason the mixin is not
|
|
6206
|
+
called `HasInput`.
|
|
6207
|
+
|
|
6208
|
+
**Roads not taken.**
|
|
6209
|
+
|
|
6210
|
+
- *A `bad_input? = false` default on `HasValue`*, to spare consumers the
|
|
6211
|
+
`respond_to?`. It would put a field-kind concept on every `Checkbox` and
|
|
6212
|
+
destroy the locator seam — the same argument that kept `tab_stop?` out of
|
|
6213
|
+
`HasValue` (`D_has_value`). The capability is a class fact a consumer may cache
|
|
6214
|
+
at bind time; the status may never be.
|
|
6215
|
+
- *Cache the status in an ivar and diff it.* Caching a derived fact has bitten
|
|
6216
|
+
three times (theme accents, `bg_color`, `TextArea#@wrap`). Deriving it also
|
|
6217
|
+
makes notice *order* immaterial: when `"2020-01-01"` becomes `"xyz"`, both
|
|
6218
|
+
`on_value_change(nil)` and (once it exists) the bad-input notice fire, and
|
|
6219
|
+
whichever a consumer receives first, asking `bad_input_message` yields the
|
|
6220
|
+
current answer. The residual trap is a consumer that reacts to
|
|
6221
|
+
`on_value_change` *without* re-asking — it concludes "the user cleared the
|
|
6222
|
+
field", which is the illusion in the table above.
|
|
6223
|
+
- *Vaadin-faithful: one shared flag plus a pull seam and a push event.* Rejected
|
|
6224
|
+
on the strength of Vaadin's own warning above — the repair mechanisms exist
|
|
6225
|
+
*because* the flag is shared.
|
|
6226
|
+
- *Do nothing; bad input reads as empty.* The prior behavior. Defensible for the
|
|
6227
|
+
fields Tuile has now that prevention is in place — their residue is visibly
|
|
6228
|
+
half-typed — and not defensible at all under a text-input date field, where no
|
|
6229
|
+
filter can shrink the residue in the first place. Shipping the seam now is what
|
|
6230
|
+
keeps that field from inventing an ad-hoc `parse_error` accessor.
|
|
6231
|
+
|
|
6232
|
+
**Consequences elsewhere.**
|
|
6233
|
+
|
|
6234
|
+
- **`HasValue#empty?` gained an rdoc caveat** — it is empty of *value*, and a
|
|
6235
|
+
required-field rule must ask `bad_input?` first or it reports "required" for a
|
|
6236
|
+
field that is full.
|
|
6237
|
+
- **`clear` clears the *input*, not the value.** `HasValue#clear` is
|
|
6238
|
+
`self.value = empty_value` and the mixin's default `value=` returns early when
|
|
6239
|
+
the value is unchanged — so on a field holding bad input, whose `value` already
|
|
6240
|
+
reads `nil`, an inherited `clear` would be a silent no-op leaving the garbage on
|
|
6241
|
+
screen. Today's three are safe because each overrides `value=` without that
|
|
6242
|
+
guard; the rule is now written on `HasValue#clear` and specced per field.
|
|
6243
|
+
- **A field holds bad input *or* a value, never both**, which is why nothing
|
|
6244
|
+
here needs revert-on-commit machinery: with a derived parse, `value=`
|
|
6245
|
+
overwrites the input by formatting it, so setting a value *is* clearing the
|
|
6246
|
+
bad input and there is nothing left to revert. `ComboBox` is outside the
|
|
6247
|
+
population precisely because it breaks that — it holds both a query and a
|
|
6248
|
+
selected item, and resolves the divergence by reverting the query. Don't
|
|
6249
|
+
generalize either half.
|
|
6250
|
+
- **Items-plus-value components stay out of it.** `Select`, `ComboBox`,
|
|
6251
|
+
`RadioGroup` and `CheckboxGroup` deliberately allow a `value` their `items` do
|
|
6252
|
+
not contain, with no reconcile and no clamp (`D_combobox`, `D_checkbox_group`,
|
|
6253
|
+
`D_radio_group`). That is a domain rule, not bad input, and wiring it up here
|
|
6254
|
+
is the obvious wrong move now that a channel exists.
|
|
6255
|
+
- **`EmailField` is not blocked on this, and is probably not a component.** Its
|
|
6256
|
+
value *is* its input, so it has no bad-input state at all and contributes only
|
|
6257
|
+
a packaged regex — re-tiered toward reject in `ideas/new-components.md`.
|
|
6258
|
+
|
|
6259
|
+
## D_caption_ownership — A field carries no caption; the layout around it does (2026-09-03)
|
|
6260
|
+
|
|
6261
|
+
**Status:** Accepted; the code side is a **non-change** — no field has ever
|
|
6262
|
+
included `HasCaption`, and this entry is what keeps it that way. The container
|
|
6263
|
+
half (`FormLayout`) is unbuilt. Graduated from
|
|
6264
|
+
`ideas/caption-and-error-ownership.md`, retired 2026-09-03; what it kept — the
|
|
6265
|
+
`FormLayout` geometry — is now `ideas/form-layout.md`.
|
|
6266
|
+
|
|
6267
|
+
**Context.** Vaadin shipped both answers, which is what made this a real fork
|
|
6268
|
+
rather than a preference. Vaadin 8: `field.setCaption("Name")`, the component
|
|
6269
|
+
renders its own label. Vaadin 25: `formLayout.addFormItem(field, "Name")`, the
|
|
6270
|
+
form item owns the geometry. Tuile had to pick before a `FormLayout` could
|
|
6271
|
+
exist, and the answer decides whether `HasCaption` reaches `HasValue`.
|
|
6272
|
+
|
|
6273
|
+
**Decision — the caption is the container's.** Three reasons, any one
|
|
6274
|
+
sufficient:
|
|
6275
|
+
|
|
6276
|
+
- **A field cannot paint one.** Layout is top-down: a field is handed one row
|
|
6277
|
+
and cannot grow a second, and advertising a wanted height is the bottom-up
|
|
6278
|
+
channel v0.9.0 deleted. A caption inside the row displaces the value. So
|
|
6279
|
+
`field.caption = "Name"` would be a stored string with *no reader on the
|
|
6280
|
+
component's own face* — the mailbox shape `D_bad_input` refused for a rule's
|
|
6281
|
+
verdict, in the other channel.
|
|
6282
|
+
- **The rendering is not the field's to fix.** Caption left, caption above,
|
|
6283
|
+
caption column aligned across a form — all three are legitimate, all three
|
|
6284
|
+
are the container's arithmetic, and storing the string on the field implies a
|
|
6285
|
+
rendering it never performs.
|
|
6286
|
+
- **Nothing above needs it there.** A binder binds values and writes verdicts;
|
|
6287
|
+
a caption is presentation. `D_has_value` already parks model-mapping above the
|
|
6288
|
+
field.
|
|
6289
|
+
|
|
6290
|
+
**The axis is "paints it", not "is a field",** and the existing membership
|
|
6291
|
+
already discriminates correctly: `Checkbox` is `HasValue` **and** `HasCaption`
|
|
6292
|
+
because it draws `[x] Enable logging` inside its own rect, as `Button` and
|
|
6293
|
+
`Window` draw theirs. So this entry bans a caption on `TextField`, not on every
|
|
6294
|
+
input.
|
|
6295
|
+
|
|
6296
|
+
**Consequence — caption-based lookup relocates, it does not die.** AGENTS.md's
|
|
6297
|
+
mixin-for-lookup rule (a tree walk finding "the Button captioned Submit" via
|
|
6298
|
+
`is_a?(HasCaption)` plus a compare) still holds for the three painters, which
|
|
6299
|
+
still pass the reachable-plus-more-than-one-class test (`D_tabs` is where that
|
|
6300
|
+
test stops). For a field, the caption lives in the `FormLayout`'s per-child map
|
|
6301
|
+
— held by the thing that actually knows the caption↔field association — so a
|
|
6302
|
+
locator asks the authority (`form.field_for(caption: "Name")`), which is what
|
|
6303
|
+
Karibu-Testing does against Vaadin 25 form items. A direct handle
|
|
6304
|
+
(`Component#id`, reached through `Tuile::Testing.get`) is a separate decision,
|
|
6305
|
+
since made: `D_component_lookup`.
|
|
6306
|
+
|
|
6307
|
+
**Consequence — a field outside a form has no caption**, and an app puts a
|
|
6308
|
+
`Label` beside it, exactly as every pane in the sampler already does. This
|
|
6309
|
+
declines to add a capability; it removes none.
|
|
6310
|
+
|
|
6311
|
+
**Roads not taken.**
|
|
6312
|
+
|
|
6313
|
+
- *`HasCaption` on `HasValue`, painted by the container anyway.* The worst of
|
|
6314
|
+
both: the field stores a string it never reads, and two places can now claim
|
|
6315
|
+
authorship of the same text.
|
|
6316
|
+
- *A caption that grows the field a row.* The deleted bottom-up channel, and
|
|
6317
|
+
`D_status_bar` refuses the framework-placed row from the other side.
|
|
6318
|
+
- *Vaadin 8 wholesale (caption **and** error ink on the field).* Half of it
|
|
6319
|
+
survived on the merits — see `D_has_validation`, which keeps the *verdict* on
|
|
6320
|
+
the field for a reason that does not apply to the caption: a field can paint
|
|
6321
|
+
invalidity inside its rect without displacing the value, because ink is a
|
|
6322
|
+
restyle of cells it already paints.
|
|
6323
|
+
|
|
6324
|
+
## D_has_validation — `HasValidation`: the field holds the verdict, the container paints the message (2026-09-03)
|
|
6325
|
+
|
|
6326
|
+
**Status:** Accepted and implemented in `Component::HasValidation`
|
|
6327
|
+
(`error_message`, `on_error_message_change`, the protected `error_ink?`),
|
|
6328
|
+
included by `HasValue`; plus `Theme#error_color` / `#error_bg_color` /
|
|
6329
|
+
`#error_active_bg_color` and `Component#error_bg_color`. The container that
|
|
6330
|
+
renders the *message* is unbuilt — `FormLayout` — so the sampler's Validation
|
|
6331
|
+
pane is the only consumer today. Graduated from
|
|
6332
|
+
`ideas/caption-and-error-ownership.md` (retired 2026-09-03; its container half
|
|
6333
|
+
lives on as `ideas/form-layout.md`), then amended the same day from
|
|
6334
|
+
`ideas/error-background-tint.md`, which reversed the ink ruling below.
|
|
6335
|
+
|
|
6336
|
+
**Context — `D_bad_input` shipped a channel and then could not say where a
|
|
6337
|
+
rule's verdict lives**, because the answer depended on who paints. That entry's
|
|
6338
|
+
authority table is still correct; what it left open is this.
|
|
6339
|
+
|
|
6340
|
+
**Decision — split "invalid" by geometry, and the fork dissolves.** It was
|
|
6341
|
+
being treated as one thing and it is two:
|
|
6342
|
+
|
|
6343
|
+
| | the **verdict** | the **message** |
|
|
6344
|
+
|---|---|---|
|
|
6345
|
+
| what | this field is invalid | "Username is required" |
|
|
6346
|
+
| cells | fits the one row the field has — ink is a restyle of cells it already paints | needs cells the field does not own |
|
|
6347
|
+
| so it lives | on the field (`error_message`) | wherever the cells are: a `FormLayout`, or an app's own `Label` |
|
|
6348
|
+
|
|
6349
|
+
So the field stores the fact and paints itself; whoever has the cells reads the
|
|
6350
|
+
text off it. The re-grow rule that governs both halves —
|
|
6351
|
+
*a component gets a member only when something on its own face reads it*, and
|
|
6352
|
+
never as a mailbox for a value it cannot compute or paint —
|
|
6353
|
+
passes here and fails for the caption (`D_caption_ownership`), which is why the
|
|
6354
|
+
same rule gives the two halves opposite answers.
|
|
6355
|
+
|
|
6356
|
+
**Decision — one member, no `invalid?`.** A second predicate beside
|
|
6357
|
+
`bad_input?` gives a caller no way to know which to ask, and the two differ in
|
|
6358
|
+
authority, population and lifetime (`D_bad_input`'s table). Invalid *is* a
|
|
6359
|
+
non-nil message; `""` is the flag with nothing to say.
|
|
6360
|
+
|
|
6361
|
+
**Decision — the field never writes it, which is the whole answer to Vaadin's
|
|
6362
|
+
warning.** `D_bad_input` quotes the custom-field guide: *"Do not rely on the
|
|
6363
|
+
same `invalid` and `errorMessage` properties for internal validation.
|
|
6364
|
+
Otherwise… external validation is likely to override or ignore the internal
|
|
6365
|
+
state."* Tuile's two facts stay in two members: the field's own report is
|
|
6366
|
+
`bad_input?` (derived on read, never stored), and `error_message` is written
|
|
6367
|
+
only from outside — the field computes no verdicts, so it has nothing to write.
|
|
6368
|
+
That leaves exactly one writer, and the discipline that writer owes is one
|
|
6369
|
+
sentence: **set *or clear* it on every validate pass.** Vaadin needs
|
|
6370
|
+
`getDefaultValidator` and `ValidationStatusChangeEvent` to repair a shared
|
|
6371
|
+
cell; Tuile inherits neither, for the same reason it inherited neither in
|
|
6372
|
+
`D_bad_input`.
|
|
6373
|
+
|
|
6374
|
+
**Decision — it carries a change notice, where `bad_input?` deliberately does
|
|
6375
|
+
not.** Not an inconsistency: `bad_input?` is *continuous* (every prefix of a
|
|
6376
|
+
date is bad input), so a display consumer owes a settling rule first — the ink
|
|
6377
|
+
has since paid that (`bad_input_settled?`, `D_date_field`) — while
|
|
6378
|
+
`error_message` is *discrete* — asserted at a click or a binder pass. So the
|
|
6379
|
+
notice is plain listener inversion, and it is load-bearing rather than
|
|
6380
|
+
speculative: the message is painted in cells the field does not own and does not
|
|
6381
|
+
invalidate, so without it a `FormLayout` cannot know to repaint.
|
|
6382
|
+
|
|
6383
|
+
**Decision (amended) — the verdict is a red *well*, not red text.** The first
|
|
6384
|
+
cut made it a foreground, reasoning that `invalid` and `focused` co-occur so an
|
|
6385
|
+
error background would put two meanings in the channel `bg_color` owns and would
|
|
6386
|
+
owe a 2×2 precedence ruling. That shipped with a gap the entry admitted — *an
|
|
6387
|
+
empty invalid field has no glyphs to tint*, which is the required-field case,
|
|
6388
|
+
i.e. the commonest validation failure there is — and a second one it missed:
|
|
6389
|
+
`under_fg` was fill-unset, so a span already carrying a color never reddened
|
|
6390
|
+
(a `RadioGroup` row with a styled label, a `List` with per-item colors). The fg
|
|
6391
|
+
channel needed glyphs **and** needed them unstyled.
|
|
6392
|
+
|
|
6393
|
+
The reframe that unblocked it: *why does a field have a well at all?* To show
|
|
6394
|
+
its boundary. A red well shows the boundary **and** the verdict, so nothing is
|
|
6395
|
+
lost — and the 2×2 dissolves because the pair is *declared*, not derived:
|
|
6396
|
+
`Theme#error_bg_color` / `#error_active_bg_color` are `input_bg_color` /
|
|
6397
|
+
`active_bg_color`'s red counterparts. Two tokens rather than one flat error
|
|
6398
|
+
color, because otherwise a focused invalid `Select` shows no focus at all —
|
|
6399
|
+
`D_bg_surface` already found that exact bug ("`select.bg_color = X` silently
|
|
6400
|
+
removes the only focus indicator a `Select` has — it paints no caret").
|
|
6401
|
+
`BG_STATES` stays closed either way: error is a *level in the chain*, not a
|
|
6402
|
+
state key. This still re-weighs `D_color_slots` (a chrome token over a
|
|
6403
|
+
per-component slot), which validity spanning every `HasValue` field is the case
|
|
6404
|
+
that argument was waiting for.
|
|
6405
|
+
|
|
6406
|
+
**The hook sits above `bg_color`, not under it.** `Component#error_bg_color`
|
|
6407
|
+
(protected, nil by default) resolves first:
|
|
6408
|
+
`error_bg_color || @bg_color || default_bg_color || parent`. Under it — the
|
|
6409
|
+
obvious placement, and where the `default_bg_color` precedent points — an app
|
|
6410
|
+
tinting a panel would silently switch the validation signal off on the fields
|
|
6411
|
+
inside, which is the issue-#11 bug class again. It also keeps the change to one
|
|
6412
|
+
site: `default_bg_color` is overridden by six widgets that would each have
|
|
6413
|
+
needed a `return super if invalid` line, and the seventh would forget it. So
|
|
6414
|
+
**no widget needed a line of paint code**, and the ordinary background chain
|
|
6415
|
+
does the composing — a composed field's inner face is already `BG_INHERIT` and a
|
|
6416
|
+
group's `List` declares no background, so both walk up and land on the
|
|
6417
|
+
composer's answer with nothing forwarded. `ambient_bg_color` skips this level as
|
|
6418
|
+
it skips `default_bg_color`: outside its extent the widget is not there.
|
|
6419
|
+
|
|
6420
|
+
**The well ORs `bad_input?`,** through the protected `error_ink?` hook that
|
|
6421
|
+
`HasBadInput` widens — so `HasBadInput` `include`s `HasValidation` to pin the
|
|
6422
|
+
ancestor order its `super` needs. This knowingly inherits `D_bad_input`'s
|
|
6423
|
+
continuity problem on the *face* only: a `FloatField` reddens at the half-typed
|
|
6424
|
+
`"1."`, an `IntegerField` at a lone `-`. Accepted because a save gate that lets
|
|
6425
|
+
you press Save on a field it will reject is the worse failure.
|
|
6426
|
+
|
|
6427
|
+
**Amended 2026-09-04 — the flicker bit, and the settling rule landed exactly
|
|
6428
|
+
where this entry parked it.** `DateField` took the measurement nobody had taken:
|
|
6429
|
+
where *every* prefix of the grammar is bad input, the OR holds the well red for
|
|
6430
|
+
the whole time the user types a correct date, which reads as "you are wrong"
|
|
6431
|
+
rather than "you are not finished". So `HasBadInput` grew the protected
|
|
6432
|
+
`bad_input_settled?` and `error_ink?` became
|
|
6433
|
+
`(bad_input? && bad_input_settled?) || super`. As predicted, it gates **the ink
|
|
6434
|
+
only** — `bad_input?` stays derived-on-read, so a save gate asking at a click is
|
|
6435
|
+
untouched. Its default is `true`, so the numeric fields still redden
|
|
6436
|
+
immediately, and that is a ruling rather than inertia: their residue is one or
|
|
6437
|
+
two transient buffers (`-`, `"1."`, and the `-`/`-0.` pair while `-0.5` is
|
|
6438
|
+
typed), so the flicker is brief and the early warning beats the quiet.
|
|
6439
|
+
`DateField` overrides it with a latch on its commit gestures (`D_date_field`).
|
|
6440
|
+
|
|
6441
|
+
**Rejected — blending the well toward `error_color`.** Attractive because it
|
|
6442
|
+
composes with an app's own panel tint, and because modelling error as a
|
|
6443
|
+
*transform over* the resolved background is what dissolves the 2×2 (that
|
|
6444
|
+
reframe survives; the transform did not). It dies on quantization: a lerp is a
|
|
6445
|
+
contraction, `|tint(active) − tint(normal)| = (1−w)·|active − normal|`, so the
|
|
6446
|
+
tint squeezes out the focus shade it composes with. Under `palette256` only
|
|
6447
|
+
`w = 0.40` keeps all three conditions in both schemes, and 0.40 is `#8f4f4f` —
|
|
6448
|
+
not "slight". The elegant repair (add chroma, preserve luminance, so the delta
|
|
6449
|
+
survives by construction) is *worse*: it fails on `DARK` at every weight,
|
|
6450
|
+
because the dark grey ramp is dense enough that a chroma-only shift off a dark
|
|
6451
|
+
grey snaps back onto it. Two further reasons not to revisit: `Theme` validates
|
|
6452
|
+
every member `is_a?(Color)`, so a per-scheme *weight* token would mean loosening
|
|
6453
|
+
that check; and the blend's own best outputs were palette 95/131 and 174/181 —
|
|
6454
|
+
opaque cells an opaque token can simply name, which is what made picking one
|
|
6455
|
+
per state the cheaper answer (and what then let the pair be *retuned* to the
|
|
6456
|
+
cells below without touching a line of resolution code).
|
|
6457
|
+
|
|
6458
|
+
**Rejected — real alpha in `Color`.** It buys multi-layer composition and
|
|
6459
|
+
nothing needs more than one layer. Also settled while deciding, and worth not
|
|
6460
|
+
re-deriving: alpha could never reach the `Buffer` — terminal cells are opaque
|
|
6461
|
+
(`D_bg_inherit`), a cell holds one final color, and there is nothing underneath
|
|
6462
|
+
to composite against except the previous frame — so it would have to be
|
|
6463
|
+
flattened during resolution, and putting it in `Color` makes that value type
|
|
6464
|
+
partial (a translucent color has nothing to hand `sgr_codes`). If a dim factor
|
|
6465
|
+
is ever needed, `ideas/modal-backdrop.md` owns both `Color#mix` and the type
|
|
6466
|
+
question, and has the harder version of it: a fan-out over unknown,
|
|
6467
|
+
app-authored bases rather than one known one.
|
|
6468
|
+
|
|
6469
|
+
**Token choice.** `DARK` uses palette 88 (`#870000`) / `LIGHT_PINK4` (95,
|
|
6470
|
+
`#875f5f`), `LIGHT` uses `MISTY_ROSE1` (224, `#ffd7d7`) / `LIGHT_PINK1` (217,
|
|
6471
|
+
`#ffafaf`). Three conditions were measured on every candidate, at each depth:
|
|
6472
|
+
**A** resting well ≠ `input_bg_color`, **B** focused well ≠ `active_bg_color`,
|
|
6473
|
+
**C** focused ≠ resting. C is the one that kills candidates — losing it means a
|
|
6474
|
+
focused invalid `Select` shows no focus at all, since it paints no caret. Both
|
|
6475
|
+
pairs hold all three at `truecolor` and `palette256`, and each keeps **A and C**
|
|
6476
|
+
at `ansi16`, where the shipped 95/131 and 181/174 had failed all three.
|
|
6477
|
+
|
|
6478
|
+
Each pair also **splits in the same direction as its own valid pair**, so an
|
|
6479
|
+
invalid field feels like the same widget: `DARK` darker → lighter on focus
|
|
6480
|
+
(`GREY27` → `GREY37`, 88 → 95), `LIGHT` lighter → darker (`GREY85` → `GREY82`,
|
|
6481
|
+
224 → 217).
|
|
6482
|
+
|
|
6483
|
+
Two things not to re-derive:
|
|
6484
|
+
|
|
6485
|
+
- **The `DARK` focused well must avoid the bright mid-reds around `#af5f5f`.**
|
|
6486
|
+
That is where terminals put the *cursor*, so `INDIAN_RED` (131) made a caret
|
|
6487
|
+
sitting in an invalid field blur into the well — reported from real use, not
|
|
6488
|
+
from the arithmetic. Tuile cannot know the cursor color (it is the terminal's;
|
|
6489
|
+
OSC 12 would report it, `D_background_rgb` is the precedent) and deliberately
|
|
6490
|
+
chooses rather than queries, exactly as `D_background_rgb` argues a theme
|
|
6491
|
+
picks colors to sit *against* the terminal.
|
|
6492
|
+
- **224 is the floor on `LIGHT`, not a preference.** Anything paler quantizes
|
|
6493
|
+
onto the grey ramp — `#ffeaea` → 255 — so a subtler well is *colorless* on a
|
|
6494
|
+
256-color terminal; `#fbdede` and `#f7d0d0` both land back on 224. The cost is
|
|
6495
|
+
that 224 sits a shade above `GREY85` in luminance, so an invalid field reads
|
|
6496
|
+
level with a valid one rather than more recessed. Accepted: near-white was the
|
|
6497
|
+
ask, and the tint carries the signal by hue.
|
|
6498
|
+
|
|
6499
|
+
The remaining live trade is `ansi16`, where B is unreachable for both pairs — but
|
|
6500
|
+
focus is *already* invisible there (`GREY27` and `GREY37` both quantize to
|
|
6501
|
+
`:bright_black`), so nothing is lost that was not already gone, and
|
|
6502
|
+
`D_color_depth` rules out solving it with a depth-conditional strategy. A/B/C
|
|
6503
|
+
are asserted at both depths in `theme_spec`'s "the error wells" context, so a
|
|
6504
|
+
future retune is measured rather than eyeballed.
|
|
6505
|
+
|
|
6506
|
+
**Decision — a separate mixin, included by `HasValue`, not members on
|
|
6507
|
+
`HasValue`.** Four reasons:
|
|
6508
|
+
|
|
6509
|
+
- **Different authorities.** `HasValue` is the field's own state;
|
|
6510
|
+
`error_message` is written from outside. `D_has_value` keeps that seam
|
|
6511
|
+
deliberately thin and self-owned, and a foreign-written member sits better
|
|
6512
|
+
behind its own name.
|
|
6513
|
+
- **Lookup.** A binder or a test locator iterating "everything that can carry a
|
|
6514
|
+
verdict" walks `is_a?(HasValidation)` — reachable, more than one implementing
|
|
6515
|
+
class, the test `D_bad_input` and `D_tabs` both apply.
|
|
6516
|
+
- **A non-field can be invalid** — a composite custom field, a form section
|
|
6517
|
+
wrapping several — and includes it alone.
|
|
6518
|
+
- **It is Vaadin's split**, so the binder port reads familiar.
|
|
6519
|
+
|
|
6520
|
+
Cost is ~12 lines, the same trade `HasCaption` made.
|
|
6521
|
+
|
|
6522
|
+
**Population — every `HasValue`, and nothing else.** Unlike `HasBadInput`
|
|
6523
|
+
(include it iff your parse is partial), any field can be the subject of a rule,
|
|
6524
|
+
including a `Checkbox` ("you must accept the terms"). `ProgressBar` stays out
|
|
6525
|
+
for the reason it stays out of `HasValue`: a display widget is not a field
|
|
6526
|
+
(`D_progress_bar`).
|
|
6527
|
+
|
|
6528
|
+
**Roads not taken.**
|
|
6529
|
+
|
|
6530
|
+
- *The container stores the message, in its per-child map.* The shape
|
|
6531
|
+
`D_box_layouts` already uses for constraints, and the first instinct — a field
|
|
6532
|
+
should not have to know what its parent is. It dies on the binder: `binder` is
|
|
6533
|
+
handed *fields* and has no reference to the layout they happen to sit in, so
|
|
6534
|
+
the only thing that ever computes a verdict could not report one. A click
|
|
6535
|
+
handler is the same — it holds `username` and `password`, not `form`. And it
|
|
6536
|
+
would leave a field outside a `FormLayout` with no way to show anything at
|
|
6537
|
+
all.
|
|
6538
|
+
- *Vaadin 25 read as "the container owns errors too".* A misreading worth
|
|
6539
|
+
recording, because it nearly settled this the other way: Vaadin 25 moved the
|
|
6540
|
+
*caption* to the form item and kept `invalid` / `errorMessage` on the field
|
|
6541
|
+
(`HasValidation`), which renders both. Even the container-owns precedent does
|
|
6542
|
+
not put the error on the container.
|
|
6543
|
+
- *An `invalid?` boolean plus a separate message.* Two members for one fact, and
|
|
6544
|
+
the predicate collides with `bad_input?` as above.
|
|
6545
|
+
- *A red foreground on the glyphs.* The first cut, argued from the
|
|
6546
|
+
co-occurrence with focus and **reversed by the amendment above** — that worry
|
|
6547
|
+
dissolved once the pair was *declared* rather than derived, and the fg channel
|
|
6548
|
+
turned out to need glyphs it does not have on the required-field case.
|
|
6549
|
+
- *Leaving `bad_input?` out of the well.* Also the first cut, on
|
|
6550
|
+
`D_bad_input`'s continuity grounds; the amendment ORs it in through
|
|
6551
|
+
`error_ink?` and accepts the flicker, for the reason given above — a Save
|
|
6552
|
+
gate that rejects a field the face called fine is the worse failure. A
|
|
6553
|
+
settling rule has since been written and softens the *well* only, as this line
|
|
6554
|
+
predicted (the amendment above; `D_date_field`).
|
|
6555
|
+
- *Forwarding the message down to a composed field's inner widget.* What a
|
|
6556
|
+
push-it-down design would have needed on four composed fields and two groups;
|
|
6557
|
+
resolving the well through `effective_bg_color` deletes the whole category,
|
|
6558
|
+
since that chain already inherits.
|
|
6559
|
+
|
|
6560
|
+
## D_component_lookup — `Component#id` plus `Tuile::Testing`: scope is an argument, not a receiver (2026-09-03)
|
|
6561
|
+
|
|
6562
|
+
**Status:** Accepted; **v1 implemented** — `Component#id`, `Component#inspect`
|
|
6563
|
+
plus the protected `inspect_details` hook, and `Tuile::Testing.find` / `.get` /
|
|
6564
|
+
`.dump`. Graduated from `ideas/component-lookup-for-tests.md`. The checked
|
|
6565
|
+
interactions, a `value:` match and a `test_id`/`name` split are deferred, not
|
|
6566
|
+
rejected (listed at the end).
|
|
6567
|
+
|
|
6568
|
+
**Context — the specs had already written this locator, twelve times.**
|
|
6569
|
+
`sampler_spec` alone carried ten walks shaped
|
|
6570
|
+
`on_tree { |c| combo ||= c if c.is_a?(ComboBox) }`, plus one each in
|
|
6571
|
+
`confirm_window_spec` and `has_caption_spec`. They were the reason to build it,
|
|
6572
|
+
and one of them named the trap in a comment: *"demo_window, not the sampler:
|
|
6573
|
+
the jump box is a ComboBox too, and it comes first in tree order."* The `||=`
|
|
6574
|
+
resolves ambiguity by silently taking whichever component the tree walk reached
|
|
6575
|
+
first — so a pane that grows a second `ComboBox` re-points the spec at a
|
|
6576
|
+
different widget and nothing goes red. Reporting that as an error, rather than
|
|
6577
|
+
picking a winner, is most of what `get` buys.
|
|
6578
|
+
|
|
6579
|
+
**Decision.** A `Symbol` `id` on `Component`, and a `Tuile::Testing` module
|
|
6580
|
+
with three public methods: `find` (every match, optional `count:`), `get`
|
|
6581
|
+
(exactly one) and `dump` (the tree, for a failure message).
|
|
6582
|
+
|
|
6583
|
+
**Scope is the `in:` keyword, not a `Component#get`.** The open question in the
|
|
6584
|
+
note was whether the module should also install a receiver-style subtree
|
|
6585
|
+
lookup. It should not, on four counts:
|
|
6586
|
+
|
|
6587
|
+
- Scope is a parameter *of the search*, not a property of a component. Both
|
|
6588
|
+
spellings end in the same `on_tree` walk, so the receiver form adds surface
|
|
6589
|
+
without adding power.
|
|
6590
|
+
- `get` is a generic name on a class apps subclass freely — the sampler alone
|
|
6591
|
+
has `Panel`, `ShortcutBox`, `TickingBox`. `id` is already one squat on every
|
|
6592
|
+
subclass; take one, not two.
|
|
6593
|
+
- Test-only API stays off production classes. The precedent is
|
|
6594
|
+
`Screen#invalidated?`, which lives on `FakeScreen`.
|
|
6595
|
+
- **Re-grow rule:** if receiver syntax is ever wanted, it comes back as a
|
|
6596
|
+
*refinement* inside `Testing`, so `component.get(Button)` exists only in
|
|
6597
|
+
files that `using` it. Never as a method on `Component`.
|
|
6598
|
+
|
|
6599
|
+
For the same collision reason the *documented* call form is qualified —
|
|
6600
|
+
`Testing.get(...)` — and `config.include Tuile::Testing` is deliberately not
|
|
6601
|
+
recommended: `find` and `get` are the two most collision-prone names in a spec
|
|
6602
|
+
suite (an app driving Capybara already has a `find`). Karibu-Testing solved
|
|
6603
|
+
this with the `_get` / `_find` prefix, which Ruby idiom rules out. Tuile's own
|
|
6604
|
+
specs sit inside `module Tuile`, so they get `Testing.get` with nothing to
|
|
6605
|
+
include.
|
|
6606
|
+
|
|
6607
|
+
**`find` returns an Array and takes `count:`; that is Karibu's `_expect`.**
|
|
6608
|
+
`count:` accepts an Integer (exactly) or a Range (a bound), raises
|
|
6609
|
+
`Testing::LookupError` on a mismatch, and defaults to any number. `get` is then
|
|
6610
|
+
defined as `find(count: 1).first` rather than as a second search, which is why
|
|
6611
|
+
it reports an ambiguous spec instead of resolving it. `count: 0` is legal
|
|
6612
|
+
because it falls out of the same check, but it is **not** the idiom for
|
|
6613
|
+
"nothing is open" — a spec asserting that keeps `assert_empty
|
|
6614
|
+
Screen.instance.popups`, a direct assertion on the list beating a lookup that
|
|
6615
|
+
finds nothing.
|
|
6616
|
+
|
|
6617
|
+
**`caption:` and `count:` both match with `===`.** A String caption is exact
|
|
6618
|
+
and a Regexp partial; an Integer count is exact and a Range a bound. The
|
|
6619
|
+
polymorphism is the feature, and it is why the one `spec_match?` helper carries
|
|
6620
|
+
a `Style/CaseEquality` disable rather than being rewritten into two branches.
|
|
6621
|
+
Karibu needed separate exact and regex knobs for the same job.
|
|
6622
|
+
|
|
6623
|
+
**The class positional accepts a Module, so a mixin is a first-class spec.**
|
|
6624
|
+
`find(Component::HasValue)` finds every field, `find(Component::HasBadInput)`
|
|
6625
|
+
every field whose parse can fail. This is the mixin-as-locator-seam rule
|
|
6626
|
+
(AGENTS.md, *Input values*) finally having a consumer: `has_caption_spec`'s
|
|
6627
|
+
seam example now asserts through `Testing.get` rather than hand-rolling
|
|
6628
|
+
`is_a?(HasCaption)` plus a compare. The limit stated in `D_tabs` is unchanged —
|
|
6629
|
+
a `Tabs::Tab` is not a `Component`, appears in no `on_tree`, and so is
|
|
6630
|
+
unreachable by any of this.
|
|
6631
|
+
|
|
6632
|
+
**Uniqueness is enforced at lookup, never at assignment, and production never
|
|
6633
|
+
checks it.** A detached tree cannot know the screen, so an assignment-time
|
|
6634
|
+
check would have nothing to check against; and two `TabSheet` panes may
|
|
6635
|
+
legitimately carry the same `id`, since only one is attached at a time.
|
|
6636
|
+
`get` raising on two matches is the whole mechanism, and it costs nothing.
|
|
6637
|
+
The setter's one guard is a type check: `id = "save"` is refused rather than
|
|
6638
|
+
coerced, because a String would never match a `get(id: :save)` — silently.
|
|
6639
|
+
|
|
6640
|
+
**An `id` is not the mailbox that `caption` and `error_message` are.** The
|
|
6641
|
+
re-grow rule those two live under is "a component gets a member only when
|
|
6642
|
+
something on its own face *reads* it". An identifier inverts it: identification
|
|
6643
|
+
*is* the purpose, nothing is expected to paint it, and inertness is therefore
|
|
6644
|
+
not a smell. Worth stating because the shape looks identical and isn't.
|
|
6645
|
+
|
|
6646
|
+
**`Component#inspect` is part of v1, not a nicety.** The tree dump in a failed
|
|
6647
|
+
lookup is most of a locator's value — Karibu's real lesson — and there was no
|
|
6648
|
+
`Component#inspect`, so `Object#inspect` would have walked `parent`, `children`
|
|
6649
|
+
and the `Screen`, dumping the whole UI for one component. The base line is
|
|
6650
|
+
class, `id` and rect; mixin details arrive through a **protected
|
|
6651
|
+
`inspect_details` hook** that each mixin extends with `super + [...]`, so the
|
|
6652
|
+
base stays ignorant of which mixins a component includes — the same rule that
|
|
6653
|
+
rejected a leaf checking `parent.is_a?(HasValue)` (`D_bg_surface`). They appear
|
|
6654
|
+
in reverse include order, the last-included module calling `super` first.
|
|
6655
|
+
`HasValue` truncates a String value at 40 characters *before* calling
|
|
6656
|
+
`inspect`, since a `TextArea`'s value is its whole buffer. The dump strips the
|
|
6657
|
+
`Tuile::` namespaces so a fifty-row tree stays readable, and flags the matches
|
|
6658
|
+
with a leading arrow; an app's own classes keep their full name.
|
|
6659
|
+
|
|
6660
|
+
**It ships in `lib/`, not as a separate gem.** Zeitwerk loads
|
|
6661
|
+
`lib/tuile/testing.rb` on the first reference, so an app that never names
|
|
6662
|
+
`Tuile::Testing` pays nothing. Karibu is separate from Vaadin because Vaadin
|
|
6663
|
+
was someone else's project; here one author owns both sides, and a spec suite
|
|
6664
|
+
that has to add a gem to locate a component will keep hand-rolling `on_tree`
|
|
6665
|
+
instead. The `Testing` name signals intent rather than a hard boundary: if an
|
|
6666
|
+
app ever needs the id walk in production (a `FormLayout#field_for`), that is a
|
|
6667
|
+
re-grow onto `Component`, not a reason to rename the module.
|
|
6668
|
+
|
|
6669
|
+
**This is additive to the assertion channel, not a replacement.** A spec
|
|
6670
|
+
asserting what a component *shows* still asserts `Screen#buffer`
|
|
6671
|
+
(`D_list_items`). What the locator replaces is the *driving* half — and, as a
|
|
6672
|
+
side effect, about a dozen `instance_variable_get(:@overlay)` reach-ins, since
|
|
6673
|
+
an open overlay is a popup under the pane and so reachable by class.
|
|
6674
|
+
|
|
6675
|
+
**Deferred, not rejected.**
|
|
6676
|
+
|
|
6677
|
+
- **Checked interactions** (`_click` / `_setValue`): refuse when the component
|
|
6678
|
+
could not have received the interaction for real — not attached, not
|
|
6679
|
+
focusable, not on the focus chain. Needs a modal-scope predicate, which
|
|
6680
|
+
already exists as `bubble_key`'s `modal_popup || content` rule, and a ruling
|
|
6681
|
+
on whether a key is simulated through the ladder or handed to `handle_key`.
|
|
6682
|
+
- **`value:` in the match spec**, and an `error_message:` one now that
|
|
6683
|
+
`HasValidation` has merged (`D_has_validation`) — the mixin itself is already
|
|
6684
|
+
matchable as a class positional, like every other seam.
|
|
6685
|
+
- **A `test_id` / `name` split** — a stable test handle distinct from an
|
|
6686
|
+
app-meaningful identifier. One member until a second meaning actually turns
|
|
6687
|
+
up.
|
|
6688
|
+
- **An `id:` constructor kwarg.** No component constructor takes kwargs today,
|
|
6689
|
+
so it is a sweep over ~30 classes to save one line per call site.
|
|
6690
|
+
|
|
6691
|
+
## D_on_blur — `on_blur`, the commit point Tuile lacked (2026-09-04)
|
|
6692
|
+
|
|
6693
|
+
**Status:** Accepted; implemented — the protected `Component#on_blur`, fired
|
|
6694
|
+
from `Screen#focused=`. Graduated from `ideas/bad-input.md`, now retired; the
|
|
6695
|
+
push notice that note also carried is **postponed indefinitely** (last section).
|
|
6696
|
+
|
|
6697
|
+
**Context — the gap was on record three times, from three directions.**
|
|
6698
|
+
`D_integer_field` declined to canonicalize `"007"` and `D_bigdecimal_field`
|
|
6699
|
+
declined a `scale=` knob, both citing *"a blur/commit point a TUI lacks"*;
|
|
6700
|
+
`D_bad_input` needed the same thing for a settled bad-input notice. And
|
|
6701
|
+
`on_enter` is not that point: Tab is unconditional (rung 1, `D_key_dispatch`),
|
|
6702
|
+
so tabbing out of a half-typed field is the *likely* exit, not the exotic one.
|
|
6703
|
+
|
|
6704
|
+
**Decision — one hook, at the site that already existed.** `Screen#focused=`
|
|
6705
|
+
already held `previous` and already diffed it for `on_focus_changed`, so the
|
|
6706
|
+
whole implementation is the private `fire_focus_hooks`: blur, then focus, then
|
|
6707
|
+
the app notice. The rulings on its shape:
|
|
6708
|
+
|
|
6709
|
+
- **Protected, reached with `__send__`** (`D_hook_visibility`). The same call
|
|
6710
|
+
now reaches `on_focus` too, and that is a fix this entry owes rather than a
|
|
6711
|
+
drive-by: shipping a *protected* sibling makes the natural grouping
|
|
6712
|
+
(`protected` / `def on_blur` / `def on_focus`) likely, and it would have
|
|
6713
|
+
broken the explicit-receiver `@focused.on_focus` — precisely the landmine
|
|
6714
|
+
`D_hook_visibility` accepted while `on_focus` stood alone. `__send__` at the
|
|
6715
|
+
site retires it without protecting `on_focus`, which three mixins present as
|
|
6716
|
+
a composition seam.
|
|
6717
|
+
- **Blur before focus** — the DOM order, and the order `ideas/hover.md` had
|
|
6718
|
+
already settled for its own exit/enter pair, so the framework has one answer
|
|
6719
|
+
to the question rather than one per notice.
|
|
6720
|
+
- **Edge-triggered and fired on one component**, exactly like `on_focus`: not on
|
|
6721
|
+
the ancestors that drop off the active chain. They already have a better seam
|
|
6722
|
+
for it — `Component#active=`, which `ComboBox` overrides to close its dropdown
|
|
6723
|
+
and revert a half-typed query when focus leaves the *widget* (focus sits on
|
|
6724
|
+
its inner field, so a chain-wide `on_blur` would still be the wrong shape:
|
|
6725
|
+
it would fire on the field, which is not who owns the dropdown).
|
|
6726
|
+
Focus that merely *passes through* still blurs, so a container
|
|
6727
|
+
forwarding focus from `on_focus` blurs itself one hop later. Accepted: the
|
|
6728
|
+
pointer really did move, and suppressing it would mean remembering which
|
|
6729
|
+
assignments were forwards.
|
|
6730
|
+
- **A notification, not a veto.** No return value and no refuse-to-leave, which
|
|
6731
|
+
would have to fight the one key nothing can suppress. A handler *may* reassign
|
|
6732
|
+
focus: the nested assignment wins and the outer one stops, so `on_focus` never
|
|
6733
|
+
fires for a component that no longer holds focus (`screen_spec` pins it).
|
|
6734
|
+
- **No listener writer.** `on_focus` has none either, and `on_blur=` is additive
|
|
6735
|
+
whenever a stock-assembly consumer turns up — in the `on_theme_changed=`
|
|
6736
|
+
shape, public writer over protected hook.
|
|
6737
|
+
- **It fires wherever focus is *dropped*,** not only where a user moved it: the
|
|
6738
|
+
popup-close repair blurs an **already-detached** component (an `invalidate`
|
|
6739
|
+
there is a silent no-op, as in `on_detached`), and `Screen#close` blurs on the
|
|
6740
|
+
way out, while the tree is still mounted. Written down here because both are
|
|
6741
|
+
invisible from the call site and specced for the same reason.
|
|
6742
|
+
|
|
6743
|
+
**Alternatives rejected.**
|
|
6744
|
+
|
|
6745
|
+
- *`Screen#on_focus_changed` alone.* It exists, and it is the app-level channel
|
|
6746
|
+
(`D_status_bar`) — but a *field* cannot commit itself from it, so every app
|
|
6747
|
+
would rewrite the same dispatch-by-identity. `ideas/hover.md` asks the mirror
|
|
6748
|
+
question for hover (does `on_hover_changed` make `on_mouse_exit` unnecessary?);
|
|
6749
|
+
this is the focus half of the answer, and it is no.
|
|
6750
|
+
- *A public hook, for symmetry with `on_focus`.* The symmetry is real but
|
|
6751
|
+
cosmetic; `D_hook_visibility`'s shape wins, and `__send__`-ing both hooks buys
|
|
6752
|
+
the symmetry back where it matters — an override may declare any visibility.
|
|
6753
|
+
- *Reuse `on_detached` as the commit point.* Wrong axis: focus leaves a field
|
|
6754
|
+
that stays mounted for the rest of the session, and by the time one detaches
|
|
6755
|
+
the container it should report to may be gone.
|
|
6756
|
+
- *Name it `on_focus_lost`.* Longer, and `blur` is the word every neighbouring
|
|
6757
|
+
toolkit uses (DOM, Swing's `focusLost`/`FocusEvent`, Textual's `Blur`); the
|
|
6758
|
+
hover note independently reached for `enter`/`exit` from the same instinct.
|
|
6759
|
+
|
|
6760
|
+
**Consequences.**
|
|
6761
|
+
|
|
6762
|
+
- **Two entries' "a TUI lacks a blur/commit point" is now false**, and both were
|
|
6763
|
+
edited: `D_integer_field`'s no-normalization and `D_bigdecimal_field`'s no-
|
|
6764
|
+
`scale=` now rest on the half that survives — rewriting the buffer under the
|
|
6765
|
+
caret while typing. They are re-openable on the merits, no longer blocked.
|
|
6766
|
+
- **The bad-input push notice stays deferred, and the design sketch is
|
|
6767
|
+
deliberately not preserved.** `on_bad_input_change` was blocked on this hook
|
|
6768
|
+
*and* on a consumer; the hook has landed and the consumer still has not asked.
|
|
6769
|
+
The shipped red well reads the pull per paint (`D_has_validation`) and a Save
|
|
6770
|
+
gate asks at the click (`ideas/binder.md`), so nothing is waiting. If one ever
|
|
6771
|
+
is, the shape is an hour's work re-derived from scratch — one `attr_accessor`
|
|
6772
|
+
plus a sole-writer `sync_bad_input` in the `ProgressBar#sync_ticker`
|
|
6773
|
+
discipline, called from every input mutation — and it arrives together with
|
|
6774
|
+
the settling rule its first *continuous display* consumer owes. Half of that
|
|
6775
|
+
debt is now paid: the **ink** settles via `HasBadInput#bad_input_settled?`,
|
|
6776
|
+
latched by `DateField` on its commit gestures (`D_date_field`). That is the
|
|
6777
|
+
template a push notice copies, not an argument for building one — the pull
|
|
6778
|
+
plus a latch covered the only consumer that could not be asked at a click.
|
|
6779
|
+
|
|
6780
|
+
## D_placeholder — `HasPlaceholder`: a hint in the field's own cells, in ink tuned to be missed (2026-09-04)
|
|
6781
|
+
|
|
6782
|
+
**Status:** Accepted; implemented — `Component::HasPlaceholder`, painted by
|
|
6783
|
+
`TextField` and forwarded by the four composed fields, in the new
|
|
6784
|
+
`Theme#placeholder_color`. Graduated from `ideas/text-field-placeholder.md`, now
|
|
6785
|
+
retired.
|
|
6786
|
+
|
|
6787
|
+
**Context.** `DateField` wanted it first (`D_date_field`): a date field must
|
|
6788
|
+
tell the user *which* of its formats it writes back, information available
|
|
6789
|
+
nowhere else.
|
|
6790
|
+
But that is a general text-input affordance, so it ships as one rather than as a
|
|
6791
|
+
private `DateField` trick.
|
|
6792
|
+
|
|
6793
|
+
**Decision — a paint-time branch, not a `display_text` substitution.** The seam
|
|
6794
|
+
that *looks* right is `TextField#display_text`, and it is the wrong one: its
|
|
6795
|
+
contract is one display character per `text` character, in order, because
|
|
6796
|
+
`column_at`, `index_at`, `visible_text` and `adjust_left_column` all measure it
|
|
6797
|
+
as the rendering of the buffer. An empty buffer showing ten glyphs of hint
|
|
6798
|
+
breaks that in the most visible way there is — `cursor_position` would park the
|
|
6799
|
+
caret past the hint instead of at column 0. So the hint is a branch in
|
|
6800
|
+
`repaint`, beside `visible_text`, and the rest of the class is untouched: with an
|
|
6801
|
+
empty buffer `caret` and `left_column` are both 0, so the caret lands correctly
|
|
6802
|
+
and the scrolling machinery has nothing to do.
|
|
6803
|
+
|
|
6804
|
+
The rulings on its shape:
|
|
6805
|
+
|
|
6806
|
+
- **Paint-only, in every direction.** Not in `text`, `value`, `empty?`,
|
|
6807
|
+
`on_value_change`, a paste, or `max_text_length`'s budget. That asymmetry *is*
|
|
6808
|
+
the feature — a placeholder living in the buffer would be a default value, and
|
|
6809
|
+
a form saving it would write `"dd.mm.yyyy"` to the database.
|
|
6810
|
+
- **The condition is `text.empty?` alone — no focus term.** Browsers used to
|
|
6811
|
+
hide the hint on focus and HTML5 stopped; here the argument is stronger than
|
|
6812
|
+
convention, because the format hint is wanted *precisely* while the user is
|
|
6813
|
+
typing into the field. One condition also means no `on_focus` bookkeeping.
|
|
6814
|
+
- **A plain `String`, and `placeholder=` raises on a `StyledString`** rather
|
|
6815
|
+
than flattening it. Two reasons, and the second is the durable one: an
|
|
6816
|
+
app-supplied `StyledString` bakes its colors at construction and would need an
|
|
6817
|
+
`on_theme_changed` rebuild to survive a flip (the trap `D_theme_ref` exists to
|
|
6818
|
+
keep off chrome); and the ink is deliberately calibrated to be *barely*
|
|
6819
|
+
visible, so a per-app color is not a missing knob but a knob for defeating the
|
|
6820
|
+
design.
|
|
6821
|
+
- **It ellipsizes rather than clips.** A middle-cut `dd.mm.yyy` reads as a
|
|
6822
|
+
*complete* format that happens to be wrong, where `dd.mm.y…` reads as
|
|
6823
|
+
truncated — and for the motivating case that difference is the whole point.
|
|
6824
|
+
Free, too: `StyledString#ellipsize` already defaults to the one-column `…`,
|
|
6825
|
+
which is East-Asian Ambiguous and already inside `D_ambiguous_width`'s
|
|
6826
|
+
inventory (`Checkbox`, `ComboBox`), so this adds no glyph and reopens no bet.
|
|
6827
|
+
- **An invalid field still shows it.** An empty *required* field is the
|
|
6828
|
+
commonest invalid state and exactly when a hint about what belongs there is
|
|
6829
|
+
worth most: the red well says *something is wrong*, the hint says *what goes
|
|
6830
|
+
here*, and they are complementary rather than competing.
|
|
6831
|
+
- **`Select` does not include it** — not a contradiction of this entry but the
|
|
6832
|
+
case `D_select` already ruled: a blank face plus `▾` is self-evidently
|
|
6833
|
+
"nothing picked", so an absent enum *value* needs no hint the way an
|
|
6834
|
+
unguessable input *format* does.
|
|
6835
|
+
|
|
6836
|
+
**The ink — `hint_color` was the obvious choice and is wrong.** The idea note
|
|
6837
|
+
filed it as "the subdued-secondary-text token". It is not: it is
|
|
6838
|
+
`LIGHT_SKY_BLUE3` (109) on dark and `TURQUOISE4` on light, a saturated accent
|
|
6839
|
+
whose two consumers both use it to *pull* the eye (the shortcut caption in
|
|
6840
|
+
`"q quit"`, `PickerWindow`'s option captions). A placeholder painted in it makes
|
|
6841
|
+
an empty field *louder* than a filled one, which is the affordance backwards.
|
|
6842
|
+
`hint_color`'s own rdoc was widened to say "subdued **accent** text" in the same
|
|
6843
|
+
change, since that is what it has always been.
|
|
6844
|
+
|
|
6845
|
+
So a new token, `placeholder_color` — and the shade is a *rule*, not a taste
|
|
6846
|
+
call, because the hard part is that the background varies: one ink must survive
|
|
6847
|
+
`input_bg_color`, `active_bg_color`, both error wells, and terminal-default
|
|
6848
|
+
under `BG_INHERIT`. Quantizing the candidates settles it:
|
|
6849
|
+
|
|
6850
|
+
| shade | → `ansi16` |
|
|
6851
|
+
|---|---|
|
|
6852
|
+
| `GREY27`, `GREY37` — the *dark* wells | `:bright_black` |
|
|
6853
|
+
| `GREY42` … `GREY62` (247) | `:bright_black` |
|
|
6854
|
+
| `GREY66` (248) … `GREY85` — incl. the *light* wells | `:white` |
|
|
6855
|
+
|
|
6856
|
+
On a 16-color terminal there is **no middle ground in either theme**: every grey
|
|
6857
|
+
subtle enough to want collapses onto its own theme's wells and the hint is not
|
|
6858
|
+
subtle but *gone*, while the first shade that separates is already at full text
|
|
6859
|
+
brightness. So each token is the boundary value on its side — `DARK` takes
|
|
6860
|
+
`GREY66` (248), the dimmest that still reads `:white`; `LIGHT` takes `GREY62`
|
|
6861
|
+
(247), the palest that still reads `:bright_black`. **A hint the user is allowed
|
|
6862
|
+
to miss must fail loud, never absent**, which is the tie-break, and
|
|
6863
|
+
`theme_spec`'s "the placeholder ink" pins the whole rule at all three depths
|
|
6864
|
+
(unlike the error wells beside it, `ansi16` *is* asserted here — that is the
|
|
6865
|
+
depth the shade was chosen for).
|
|
6866
|
+
|
|
6867
|
+
**The token is required, not defaulted**, following `D_scrollbar_ink`'s
|
|
6868
|
+
precedent exactly: a default would keep hand-rolled themes working while baking
|
|
6869
|
+
a dark-tuned grey into light ones. One `**Breaking:**` line, and
|
|
6870
|
+
`Theme::DARK.with(...)` — the documented path — is unaffected.
|
|
6871
|
+
|
|
6872
|
+
**The seam is a mixin, and it is the odd one in the `Has*` family.** Every other
|
|
6873
|
+
`Has*` shares real behavior (`HasCaption` *stores* the caption for all its
|
|
6874
|
+
includers, which own only the rendering). This one cannot: the leaf `TextField`
|
|
6875
|
+
stores and paints, while each composed field **delegates** to its inner field,
|
|
6876
|
+
because a copy in the composer beside the copy in the field is two sources of
|
|
6877
|
+
truth for one fact — the desync `D_tree_api` forbids for slots, in miniature. So
|
|
6878
|
+
every composer overrides both accessors, and what the mixin buys is the contract
|
|
6879
|
+
in one place, a shared `inspect_details`, storage for the single leaf, and
|
|
6880
|
+
`is_a?(HasPlaceholder)` as a lookup seam. Written down because a reader who
|
|
6881
|
+
assumes it works like its siblings will "fix" the composers onto the mixin's
|
|
6882
|
+
storage and reintroduce the desync.
|
|
6883
|
+
|
|
6884
|
+
**Why the composers forward at all**, when `content` is public on `HasContent`
|
|
6885
|
+
and `content.placeholder =` already works: an app should not have to know that
|
|
6886
|
+
an `IntegerField` is a `TextField` in a trenchcoat. The counter-argument — that
|
|
6887
|
+
`content` is already the seam for every other inner-field knob
|
|
6888
|
+
(`max_text_length`, `mask_char`), so promoting this one implies the others are
|
|
6889
|
+
unreachable — was weighed and lost. A placeholder is part of a field's *public
|
|
6890
|
+
face* in a way a scroll or masking detail is not.
|
|
6891
|
+
|
|
6892
|
+
**Alternatives rejected.**
|
|
6893
|
+
|
|
6894
|
+
- *A `dim` (SGR 2) attribute on `StyledString::Style`, instead of a token.*
|
|
6895
|
+
Conceptually the nicest: dim is *relative* to whatever foreground is in play,
|
|
6896
|
+
so it inherits the terminal's own fg the way the no-global-fg rule wants,
|
|
6897
|
+
needs no token, and does not quantize at all — it is the only design that
|
|
6898
|
+
keeps subtlety on an `ansi16` terminal. Rejected for v1 because it changes the
|
|
6899
|
+
most-specced frozen value type (parse, `to_ansi`, the diff, the sig) and
|
|
6900
|
+
deserves its own argument rather than riding in on a placeholder. **Its
|
|
6901
|
+
trigger condition is precise:** reach for it if and only if the two greys
|
|
6902
|
+
cannot be tuned, or `ansi16` subtlety turns out to matter.
|
|
6903
|
+
- *Painting it on `TextArea` too.* Deferred, not refused. The state is generic
|
|
6904
|
+
but the paint is not — `TextField` writes one windowed row, `TextArea` wraps
|
|
6905
|
+
into a viewport — and putting the accessor on `AbstractStringField` while only
|
|
6906
|
+
one subclass paints it ships a public setter that is silently inert on the
|
|
6907
|
+
other. A multi-line free-text box rarely has an unguessable *format*, so there
|
|
6908
|
+
is no near-term second caller; if one appears the accessor moves up **with
|
|
6909
|
+
both paints written**.
|
|
6910
|
+
- *Treating it as a caption.* `D_caption_ownership` says a field paints no
|
|
6911
|
+
caption, its container does, and the boundary is exactly the cells: a caption
|
|
6912
|
+
sits *outside* the field's rect, in cells the field neither owns nor
|
|
6913
|
+
invalidates. A placeholder is inside the field's own rect, on cells it already
|
|
6914
|
+
paints and already invalidates. Sharper still: **a caption is unconditional
|
|
6915
|
+
and describes the *field*; a placeholder is conditional on emptiness and
|
|
6916
|
+
stands in for the *value***. Which yields the corollary an app needs — never
|
|
6917
|
+
use a placeholder *as* a caption to save a row in a tight form, because the
|
|
6918
|
+
hint disappears the instant the user types.
|
|
6919
|
+
|
|
6920
|
+
**Consequences.**
|
|
6921
|
+
|
|
6922
|
+
- **`TextField#repaint` does not call `super`, so the padded row *is* the
|
|
6923
|
+
well** — `visible_text` has always padded itself to `rect.width`, and nothing
|
|
6924
|
+
else clears the rect. The placeholder branch therefore ellipsizes to
|
|
6925
|
+
`rect.width` **and pads back out to it**; a row only as wide as the hint would
|
|
6926
|
+
leave the rest of the field holding whatever was painted there before, with
|
|
6927
|
+
the background stopping mid-way. The idea note's first sketch got this wrong.
|
|
6928
|
+
- **`PasswordField` inherits it and should.** "password" under an empty masked
|
|
6929
|
+
field is the standard look, and the mask only ever applies to buffer content —
|
|
6930
|
+
a field showing its hint has none. Pinned in `password_field_spec`.
|
|
6931
|
+
- **`DateField` derives its hint after all — amended 2026-09-04.** This entry
|
|
6932
|
+
ruled the other way first: an explicit default string, because the formats are
|
|
6933
|
+
strftime, so a `"%d.%m.%Y"` → `"dd.mm.yyyy"` mapping table is a second grammar
|
|
6934
|
+
that will drift out of step with the format list. `D_date_field` lifted that by
|
|
6935
|
+
making the table **best-effort** — it serves the placeholder and nothing else,
|
|
6936
|
+
so it is allowed to *abstain*: a primary format holding any directive the table
|
|
6937
|
+
does not cover derives `nil` rather than a half-translation. A table that
|
|
6938
|
+
abstains cannot drift *against* the format, because it makes no claim about
|
|
6939
|
+
what it does not cover. The mixin needed no change for it, and the question
|
|
6940
|
+
this entry flagged (a *settable* accessor on a field whose hint is *computed*)
|
|
6941
|
+
answered itself: `DateField` overrides the accessor pair, holds the app's
|
|
6942
|
+
override in an ivar of its own, and the storage that matters still lives on the
|
|
6943
|
+
leaf `TextField` — `nil` restores the derived hint, `""` suppresses it.
|
|
6944
|
+
|
|
6945
|
+
---
|
|
6946
|
+
|
|
6947
|
+
## D_wrapping_field — `AbstractWrappingField`, and what `HasContent` actually means (2026-09-04)
|
|
6948
|
+
|
|
6949
|
+
**Status:** Accepted; implemented 2026-09-04 (`Component::AbstractWrappingField`;
|
|
6950
|
+
`IntegerField` / `FloatField` / `BigDecimalField` migrated). Graduated from
|
|
6951
|
+
`ideas/composed-field.md`, which retains only the unbuilt `CompositeField`
|
|
6952
|
+
sketch.
|
|
6953
|
+
|
|
6954
|
+
**Context — a fourth copy, and a rule that was backwards.** `D_float_field` and
|
|
6955
|
+
`D_select` both ruled *duplicate rather than DRY a shallow shell*, and set the
|
|
6956
|
+
bar at a **fourth** copy. `DateField` (`D_date_field`) is that copy, and
|
|
6957
|
+
by then the shell was not shallow: six obligations sat in all four composed
|
|
6958
|
+
fields — the mixin set, `bg_color = BG_INHERIT` on the inner field, a
|
|
6959
|
+
character-identical `default_bg_color`, `cursor_position`, the `placeholder`
|
|
6960
|
+
pair, and the `@last_value` change guard. One of them already carried a warning
|
|
6961
|
+
in AGENTS.md ("a **new** composed field owes both or its face paints untinted"),
|
|
6962
|
+
and a rule that needs a warning in the contributor doc wants to be code.
|
|
6963
|
+
|
|
6964
|
+
Underneath sat a worse problem. `HasContent`'s own rdoc said to include it *"when
|
|
6965
|
+
the child is permanent and integral — a typed field's inner `TextField`"*, which
|
|
6966
|
+
is exactly backwards: the mixin ships a **public `content=`**, so
|
|
6967
|
+
`integer_field.content = Button.new` succeeded and left the widget permanently
|
|
6968
|
+
broken (`value` then raised `NoMethodError`). Six components had followed that
|
|
6969
|
+
rule correctly into a hole.
|
|
6970
|
+
|
|
6971
|
+
**Decision — `HasContent` is a statement about the public surface.** *I have a
|
|
6972
|
+
primary child named `content`, this is my content which you populate; my other
|
|
6973
|
+
children are chrome, mine to manage.* Not arity (a `Window` has two app-settable
|
|
6974
|
+
children and the mixin names which is *the* content) and not
|
|
6975
|
+
permanent-vs-swappable (an `Overlay`'s body is permanent **and** public; that
|
|
6976
|
+
correlated for `Slot` alone). Legitimate includers: `Slot`, `Window`, `Overlay`.
|
|
6977
|
+
|
|
6978
|
+
**Decision — a class, not a mixin, and one for the editor-faced fields only.**
|
|
6979
|
+
A class because it has a constructor obligation and two ivars: a mixin would need
|
|
6980
|
+
an `init_wrapper(editor)` an includer must remember to call, which is the very
|
|
6981
|
+
footgun this deletes (`AbstractStringField` is the precedent for an `Abstract`
|
|
6982
|
+
component base, and composition-over-inheritance permits a *cohesive* one). The
|
|
6983
|
+
`Abstract` prefix follows a rule rather than habit — Tuile's precedent is split,
|
|
6984
|
+
`AbstractStringField` carries it and `Layout::Box` does not — **prefix when the
|
|
6985
|
+
unprefixed name would read as an instantiable widget**.
|
|
6986
|
+
|
|
6987
|
+
**Decision — the commit point is `Component#active=`, not `on_blur`.**
|
|
6988
|
+
`D_on_blur` had already ruled this and named the seam; `ComboBox` already
|
|
6989
|
+
implemented it. It is right at both tiers for free: "the widget left the focus
|
|
6990
|
+
chain" is what a commit means, and moving focus *between* two editors of a future
|
|
6991
|
+
composite keeps the composite active, where an `on_blur` design would fire on
|
|
6992
|
+
every internal hop.
|
|
6993
|
+
|
|
6994
|
+
**Amended 2026-09-04 — ENTER is the second commit gesture, and the base owns
|
|
6995
|
+
it.** A form whose default button is reached by ENTER never moves focus, so
|
|
6996
|
+
leaving the focus chain is not enough: `DateField` would canonicalize *after*
|
|
6997
|
+
the save. So `handle_key` commits on ENTER, and `on_enter=` is **wrapped rather
|
|
6998
|
+
than forwarded** — the editor's slot runs `commit` and then the app's callback,
|
|
6999
|
+
so an ENTER handler never reads an uncommitted buffer. Two consequences a
|
|
7000
|
+
subclass must not undo:
|
|
7001
|
+
|
|
7002
|
+
- **ENTER is committed and then left to keep bubbling.** `handle_key` returns
|
|
7003
|
+
`super` (false), because `TextField` consumes ENTER only when *its* `on_enter`
|
|
7004
|
+
is set — so a field with no callback declines the key, it arrives here by
|
|
7005
|
+
bubbling, and a scope's default button still sees it. Consuming it instead
|
|
7006
|
+
would silently break every form whose Save is bound to ENTER, and the first
|
|
7007
|
+
cut of `DateField` did exactly that by claiming the editor's slot
|
|
7008
|
+
unconditionally. Exactly one commit runs on either path, since the two are
|
|
7009
|
+
mutually exclusive.
|
|
7010
|
+
- **A third claimed slot needs a hook, not a claim.** The base already owns the
|
|
7011
|
+
editor's `on_change` (the change guard) and now its `on_enter`; a subclass
|
|
7012
|
+
reacting to *edits* gets the protected `on_editor_change` no-op instead, which
|
|
7013
|
+
is what `DateField`'s settling latch hangs on (`D_date_field`). One callback
|
|
7014
|
+
slot cannot be shared (`D_no_key_interceptor`), so every one the base claims
|
|
7015
|
+
owes the subclasses a hook in its place.
|
|
7016
|
+
|
|
7017
|
+
**The admission test, which is what keeps this from becoming a junk drawer.** A
|
|
7018
|
+
member belongs here **iff it is true of every wrapping field *because* it wraps**
|
|
7019
|
+
— if you can state it without mentioning the inner editor, it belongs on
|
|
7020
|
+
`Component`, a `Has*` mixin, or the subclass. That admits the delegations and
|
|
7021
|
+
rejects `min`/`max`, `required`, rounding, a `converter=` and a caption, each of
|
|
7022
|
+
which is separately refused elsewhere. Its sharpest consequence is the
|
|
7023
|
+
**forwarding test**: forward a knob only if it means something in the face's own
|
|
7024
|
+
domain. `max_text_length` and `mask_char` fail it — a character count is an
|
|
7025
|
+
editor idea, meaningless on an `IntegerField` (which would want a value
|
|
7026
|
+
`min`/`max`, a different feature) — so they are **not** forwarded and a subclass
|
|
7027
|
+
sets them on its editor internally. Both of the first two candidates coming out
|
|
7028
|
+
*no* is the evidence the surface stays short.
|
|
7029
|
+
|
|
7030
|
+
**Alternatives rejected.**
|
|
7031
|
+
- *Keep `HasContent` and make `content=` protected.* The mixin's whole point for
|
|
7032
|
+
`Slot` / `Window` / `Overlay` is that the caller sets the child; the split is
|
|
7033
|
+
by *audience*, not by visibility of one method.
|
|
7034
|
+
- *Migrate `ComboBox` too.* It fails both premises this base rests on — its
|
|
7035
|
+
buffer is a transient **query** rather than a rendering of its value, and only
|
|
7036
|
+
a commit moves the value. Forcing it in would need three overrides that each
|
|
7037
|
+
*undo* a base behaviour (`clear`, the `on_change` wiring, the `on_enter`
|
|
7038
|
+
forwarder, which would let the inner field eat the ENTER that opens the
|
|
7039
|
+
dropdown). A base whose members a subclass must disable is not a fit. Same line
|
|
7040
|
+
`HasBadInput` already draws for the same component.
|
|
7041
|
+
- *Cover the two group widgets as well.* `CheckboxGroup` / `RadioGroup` wrap a
|
|
7042
|
+
`List` and want four of the fourteen members; ten inapplicable is not a shared
|
|
7043
|
+
base. They were fixed the other way, in the same release: they drop
|
|
7044
|
+
`HasContent` and own their `List` privately, but expose it **read-only** as
|
|
7045
|
+
`list`. That is the second legal shape, and the one the *populate* half of the
|
|
7046
|
+
rule picks out — an app tunes that `List` (`scrollbar_visibility`,
|
|
7047
|
+
`show_cursor_when_inactive`, the cursor) but never supplies it. Forwarding
|
|
7048
|
+
those knobs instead would fail the forwarding test above: they are `List`
|
|
7049
|
+
concepts, not group concepts. Addressable is not the same as yours.
|
|
7050
|
+
- *Names.* `AbstractWrappedField` — the passive names the *inner* thing, and both
|
|
7051
|
+
objects are fields. `AbstractDelegatingField` — the real contender, lost to
|
|
7052
|
+
stdlib `Delegator`'s `method_missing`-based *total* delegation, which promises
|
|
7053
|
+
exactly the forwarding the test above refuses. `AbstractTypedField` —
|
|
7054
|
+
mis-scopes: `Select`'s value is typed and it wraps nothing.
|
|
7055
|
+
`AbstractComposedField` — one letter from the eventual `CompositeField`.
|
|
7056
|
+
|
|
7057
|
+
**Consequences.**
|
|
7058
|
+
- **`content` / `content=` are gone from the three typed fields** — a breaking
|
|
7059
|
+
change to documented API, 6 call sites in-tree. There is no app-facing
|
|
7060
|
+
replacement *by design*: a need the delegation surface does not cover is
|
|
7061
|
+
either a forwarder this class should grow or an editor-shaped knob that fails
|
|
7062
|
+
the forwarding test. Specs are the exception and use `Testing.get`.
|
|
7063
|
+
- **`clear` now empties the *input*.** The trap `HasBadInput`'s rdoc names — a
|
|
7064
|
+
field whose value already reads `empty_value` while glyphs remain — only failed
|
|
7065
|
+
to bite because all three `value=` wrote the buffer unconditionally.
|
|
7066
|
+
- **`value` / `value=` raise `NotImplementedError` in the base**, since
|
|
7067
|
+
`HasValue`'s defaults store into `@value` and never touch the editor.
|
|
7068
|
+
- **`empty_value` is called during construction** to seed the change guard, so it
|
|
7069
|
+
must not depend on subclass state. In practice it is a constant per class.
|
|
7070
|
+
- **`D_placeholder` needs amending, not superseding.** Its argument for
|
|
7071
|
+
forwarding `placeholder` was that "`content` is already the seam for every
|
|
7072
|
+
other inner-field knob"; that premise is void, and the conclusion is now
|
|
7073
|
+
stronger — `placeholder` earns a forwarder precisely because it is the only one
|
|
7074
|
+
of the three that is a domain concept.
|
|
7075
|
+
- **No `extent` declaration.** Checked rather than assumed: a 6-row
|
|
7076
|
+
`IntegerField` paints its well on row 0 only, so there is nothing to fix.
|
|
7077
|
+
- **`Testing.find(HasValue)` matches twice per wrapping field** — the face and
|
|
7078
|
+
its inner editor — and **that is correct and must stay**. Note the asymmetry:
|
|
7079
|
+
an *app* never reaches the editor (the surface above is the whole story), but
|
|
7080
|
+
a *test* legitimately does — `Testing.get(Component::TextField, in:
|
|
7081
|
+
field)` is how a spec puts a field into a state no public setter reaches (a
|
|
7082
|
+
lone `"-"`, a half-typed date) or sends it characters, and it is the sanctioned
|
|
7083
|
+
replacement for the `content` this entry removed. So the locator reports the
|
|
7084
|
+
tree **verbatim** and filters nothing; teaching it to hide a component because
|
|
7085
|
+
of who owns it would both break that technique and make the tree it dumps
|
|
7086
|
+
disagree with the tree that exists, which is the whole debugging value
|
|
7087
|
+
(`D_component_lookup`).
|
|
7088
|
+
|
|
7089
|
+
## D_date_field — `DateField`: several formats in, one format out (2026-09-04)
|
|
7090
|
+
|
|
7091
|
+
**Status:** Accepted; implemented 2026-09-04 (`Component::DateField`).
|
|
7092
|
+
Graduated from `ideas/date-field.md`, now retired. v1 is manual entry only — the calendar
|
|
7093
|
+
grid stays Tier 2 in `ideas/new-components.md`, blocked on the Popover
|
|
7094
|
+
extraction, and the field unblocks itself by dropping it. Its twin is
|
|
7095
|
+
`D_time_field` (2026-09-05), which inherits every ruling here it does not
|
|
7096
|
+
question and records one as *shared* — Up/Down from an unparseable buffer
|
|
7097
|
+
stepping to today/now — changeable only in both fields at once.
|
|
7098
|
+
|
|
7099
|
+
**Context.** `DateField` is the component that forced `HasBadInput` into
|
|
7100
|
+
existence: a date is the first Tuile value whose input cannot be constrained
|
|
7101
|
+
keystroke-by-keystroke, so it is the first field that must *accept* input it
|
|
7102
|
+
cannot represent. It is also the first field whose *display* is a choice rather
|
|
7103
|
+
than a rendering — `42` has one spelling, 4 September 2026 has a dozen.
|
|
7104
|
+
|
|
7105
|
+
**Decision — the value is stdlib `Date`, so the component is `DateField`.**
|
|
7106
|
+
`D_float_field`'s naming rule (named after the Ruby class of its value) applies
|
|
7107
|
+
unchanged. The `LocalDate` instinct is right about the semantics and does not
|
|
7108
|
+
transfer: `LocalDate` exists in Java only because `java.util.Date` was a
|
|
7109
|
+
misnamed instant, while Ruby's `Date` *is* the civil date and `DateTime` /
|
|
7110
|
+
`Time` are the ones carrying time and offset. A Tuile-owned value type would be
|
|
7111
|
+
one no app's models, ORM columns or serializers speak — Tuile's job is to edit
|
|
7112
|
+
the app's values, not to introduce its own. Not `DatePicker`: Vaadin's name
|
|
7113
|
+
names the widget *category*, and a later calendar popup is a feature of this
|
|
7114
|
+
field, not a rename.
|
|
7115
|
+
|
|
7116
|
+
**Decision — a list of strftime formats: parse in order, first whole match
|
|
7117
|
+
wins, `formats.first` writes back.** Stolen from Vaadin's
|
|
7118
|
+
`i18n.setDateFormats` (v25.2), which is the good idea because it makes a field
|
|
7119
|
+
lenient about what it accepts and strict about what it shows, with no mode flag
|
|
7120
|
+
and no ambiguity about which format is *the* format. Two corollaries:
|
|
7121
|
+
|
|
7122
|
+
- **The list belongs to the app, not to the component.** The same mechanism
|
|
7123
|
+
that makes a field lenient makes leniency configurable without a second
|
|
7124
|
+
concept — one format is strict ISO, three accept what a European or an
|
|
7125
|
+
American types, and nothing changes but the array. This is *not* the
|
|
7126
|
+
`converter=` strategy `D_integer_field` refused: a format list configures the
|
|
7127
|
+
field's own parse/format pair, it does not replace it with an injected one.
|
|
7128
|
+
- **strftime, not Java patterns.** `strptime` and `strftime` share one
|
|
7129
|
+
vocabulary, so lenient-parse/strict-write costs one array; translating
|
|
7130
|
+
`dd.MM.yyyy` would be a second grammar to own and keep correct.
|
|
7131
|
+
|
|
7132
|
+
**Decision — the default is one ISO format, and *not* a lenient list.** A
|
|
7133
|
+
lenient default cannot be shipped: `%m/%d/%Y` and `%d/%m/%Y` both match
|
|
7134
|
+
`04/09/2026` and disagree about what it means, and **no validator can detect
|
|
7135
|
+
that** — only the app knows which reading was intended. Shipping the ambiguous
|
|
7136
|
+
pair would silently produce April 9 for a European who typed 4 September: a
|
|
7137
|
+
*wrong value that saves cleanly*, strictly worse than bad input, which is at
|
|
7138
|
+
least visible. So the order of the list is the disambiguation and it is the
|
|
7139
|
+
app's call; Tuile ships the culture-neutral, sortable, screenshot-stable one and
|
|
7140
|
+
lets an app shoot itself in the foot deliberately. (Vaadin's three-format
|
|
7141
|
+
example is *app* code, not its default.)
|
|
7142
|
+
|
|
7143
|
+
**Decision — `default_format` / `default_calendar_start` are app-global
|
|
7144
|
+
*seeds*.** Read at construction into the per-instance `formats` /
|
|
7145
|
+
`calendar_start`, exactly as `ThemeDef.default` seeds new screens rather than
|
|
7146
|
+
being read live: an app sets them before building its UI, and a later change
|
|
7147
|
+
does not reach fields already built. Both rdocs say **"may change in the
|
|
7148
|
+
future"**, because both are stopgaps for the locale seam of `ideas/locale.md`
|
|
7149
|
+
(which also eventually owns `FloatField`'s decimal comma and a calendar grid's
|
|
7150
|
+
month names). The global holds *one* format rather than a list to keep the later
|
|
7151
|
+
deletion small; the accepted cost is that an app-wide lenient list is
|
|
7152
|
+
per-instance only. The house warning that comes with a reassignable app-global
|
|
7153
|
+
applies in full: a spec that reassigns one must restore it.
|
|
7154
|
+
|
|
7155
|
+
**Superseded 2026-09-04 (`D_locale`), as its rdoc promised.** Both globals are
|
|
7156
|
+
**deleted**; `formats` and `calendar_start` became nil-means-inherit readers
|
|
7157
|
+
over `Screen#locale`. The two predictions in the paragraph above both held —
|
|
7158
|
+
the deletion was small, and the "one format, not a list" hedge was what made it
|
|
7159
|
+
so, since a detected `Locale#date_formats` *is* a list and the seed had nothing
|
|
7160
|
+
to lose. What it got wrong was the *seed* shape: a `Locale.default` was designed
|
|
7161
|
+
and then dropped, because a locale is a detected fact whose app override is one
|
|
7162
|
+
line after `Screen.new`, where a `ThemeDef` is an app-authored artifact a whole
|
|
7163
|
+
spec suite needs. So this entry's `ThemeDef.default` parallel is the one part
|
|
7164
|
+
not to copy for the next such global — and the spec-restore warning went away
|
|
7165
|
+
with it. The `formats` rulings this entry makes (the ordering-is-the-app's-call
|
|
7166
|
+
disambiguation, the round-trip validator, the derived placeholder, `%y`'s
|
|
7167
|
+
rejection) all stand, with one loosening `D_locale` records: only the *primary*
|
|
7168
|
+
must round-trip.
|
|
7169
|
+
|
|
7170
|
+
**Decision — parse with `Date._strptime` for the leftover, then build and
|
|
7171
|
+
rescue.** Three findings, verified rather than predicted:
|
|
7172
|
+
`Date.parse("4 sep")` cheerfully guesses (which would make the format list
|
|
7173
|
+
decorative), `Date.strptime("2026-09-04junk", "%Y-%m-%d")` *succeeds* while
|
|
7174
|
+
silently ignoring the tail, and `Date._strptime("2026-02-30", "%Y-%m-%d")` hands
|
|
7175
|
+
back `mday: 30` because it does not check the calendar. So a match is two gates:
|
|
7176
|
+
a non-empty `:leftover` is no match, and only constructing the `Date` catches
|
|
7177
|
+
February 30th. Free leniency falls out — `"2026-9-4"` parses without zero
|
|
7178
|
+
padding, which is itself an argument for canonicalizing on commit.
|
|
7179
|
+
|
|
7180
|
+
**Decision — `formats=` validates by round-tripping each pattern against one
|
|
7181
|
+
pre-1969 reference date.** `Date.strptime(REF.strftime(f), f) == REF` with
|
|
7182
|
+
`REF = Date.new(1962, 9, 4)`, and it does five jobs at assignment instead of at
|
|
7183
|
+
the first keystroke: it rejects `%D` (a whole `mm/dd/yy`, the typo for `%d`), an
|
|
7184
|
+
incomplete `"%Y-%m"` (which silently fills `mday: 1`), a write-only
|
|
7185
|
+
`"%B %-d, %Y"` (strptime takes no `-` flag), `%G` (the ISO *week*-based year
|
|
7186
|
+
masquerading as `%Y`, which round-trips to the reference year whatever you feed
|
|
7187
|
+
it) and every `%y`. Every property of the reference date is load-bearing:
|
|
7188
|
+
*pre-1969* so `%y` fails, *post-1582-10-15* so the Gregorian reform fails no
|
|
7189
|
+
innocent format, and *month ≠ day* so a `%m`/`%d` swap is not masked. It is a
|
|
7190
|
+
canary rather than a proof — but a century-lossy directive is lossy in both
|
|
7191
|
+
directions, so one pre-window date catches the class that ships, and the formats
|
|
7192
|
+
an app plausibly writes (`"%d.%m.%Y"`, `"%m/%d/%Y"`, `"%Y%m%d"`,
|
|
7193
|
+
`"%B %d, %Y"`, `"%d-%b-%Y"`, `"%A, %d %B %Y"`, `"%Y-%j"`) all pass. What it
|
|
7194
|
+
deliberately does *not* catch is an order **ambiguity** — `"%m/%d/%Y"`
|
|
7195
|
+
round-trips itself perfectly — which is correct, since which reading was meant
|
|
7196
|
+
is the app's call. The one thing it forbids is a deliberately incomplete
|
|
7197
|
+
parse-only format like `"%d/%m"` meaning "this year", which is
|
|
7198
|
+
`Date.parse`-flavoured guessing; the rejection is a feature. `%x` / `%X` / `%c`
|
|
7199
|
+
are rejected *separately*, by name, with a message saying they are not
|
|
7200
|
+
locale-aware: Ruby's `%x` is a fixed `"09/04/26"` under every locale and
|
|
7201
|
+
round-trips fine, so it would pass validation while silently meaning "American".
|
|
7202
|
+
|
|
7203
|
+
**Decision — `%y` is out of a format list entirely; the app writes `%Y`.**
|
|
7204
|
+
Ruby's window is fixed *and closed at both ends*: `%y` is exact on
|
|
7205
|
+
1969-01-01…2068-12-31 and silently wrong outside it in both directions (1962
|
|
7206
|
+
writes `62` and reads back 2062; 2069 and 2100 write `69` and `00` and read back
|
|
7207
|
+
1969 and 2000). Since the primary is the write-back format and
|
|
7208
|
+
canonicalize-on-commit makes the rendered text *be* the value, a `%y` primary
|
|
7209
|
+
turns `field.value = Date.new(2100, 9, 4)` into a field holding 2000 — the same
|
|
7210
|
+
wrong-value-that-saves-cleanly the ISO-default ruling refused. And there is **no
|
|
7211
|
+
compact replacement, by arithmetic rather than by stdlib wart**: two characters
|
|
7212
|
+
cannot carry a century. `"%C%y-%m-%d"` round-trips 1962/2026/2100 exactly but is
|
|
7213
|
+
four digits wide, i.e. `%Y` with extra keystrokes. So Vaadin's `referenceDate`
|
|
7214
|
+
(a 100-year window centred on today) is not declined but *moot* — with no
|
|
7215
|
+
two-digit years there is nothing to centre. The cost, accepted and reversible:
|
|
7216
|
+
an app cannot make `04.09.26` typeable.
|
|
7217
|
+
|
|
7218
|
+
**Decision — the placeholder is derived from the primary format, exactly or not
|
|
7219
|
+
at all.** `HasBadInput` mandates one frozen constant with no interpolation, so
|
|
7220
|
+
the message is `"not a valid date"` and can *never* name the accepted formats:
|
|
7221
|
+
the placeholder is the only channel that tells the user what to type, which
|
|
7222
|
+
makes it load-bearing rather than decorative. `D_placeholder` had refused to
|
|
7223
|
+
derive it, on the grounds that a `"%d.%m.%Y"` → `"dd.mm.yyyy"` table is a second
|
|
7224
|
+
grammar that will drift. Lifted, by making the table **best-effort**: it serves
|
|
7225
|
+
the placeholder only, so it may abstain. Every directive of the primary in the
|
|
7226
|
+
table ⇒ a derived hint; any one missing ⇒ `nil`, and the field shows no hint
|
|
7227
|
+
unless the app set one. That kills the drift objection *structurally* — the
|
|
7228
|
+
table cannot disagree with a format it makes no claim about, and a
|
|
7229
|
+
half-translated hint with a raw `%j` in it is unreachable. So the table is
|
|
7230
|
+
deliberately **not** the validator's enumeration: `"%Y-%j"`, `"%F"` and even
|
|
7231
|
+
`"%s"` validate cleanly and simply cost their instance a derived hint. Three
|
|
7232
|
+
directives plus `%%`; no `%b`/`%B` (a month *name* would need an invented
|
|
7233
|
+
`mmm`), no `%e` (a hint of `" d"` is not worth an entry), and `%-d` / `%-m`
|
|
7234
|
+
cannot appear in a format list at all. `nil` restores the derived hint and `""`
|
|
7235
|
+
suppresses it, which is free since `""` is truthy in Ruby and an empty hint
|
|
7236
|
+
paints nothing — and `field.placeholder` on an untouched field therefore reads
|
|
7237
|
+
`"yyyy-mm-dd"` rather than `nil`, an asymmetry with `IntegerField` and the more
|
|
7238
|
+
useful reading ("what does this field show?").
|
|
7239
|
+
|
|
7240
|
+
**Decision — no input filter at all.** `DateField` overrides no `insert_text`:
|
|
7241
|
+
every character is admitted, typed or pasted, and the residue is reported
|
|
7242
|
+
through `bad_input?`. The grammar is not prefix-closed (`"2020-13-45"` is
|
|
7243
|
+
well-formed at every character), which is the condition `D_input_filters` names
|
|
7244
|
+
for taking the accept-and-report road; the tempting middle — rejecting
|
|
7245
|
+
characters no configured format can contain — is refused by the same entry, a
|
|
7246
|
+
partial filter *reads as a guarantee and isn't*. `max_text_length` likewise caps
|
|
7247
|
+
at a flat 64 rather than at the longest configured format: it is a pasted-novel
|
|
7248
|
+
guard, not a grammar.
|
|
7249
|
+
|
|
7250
|
+
**Decision — leaving the field canonicalizes it, and ENTER commits too.** On
|
|
7251
|
+
blur a buffer that parses is rewritten in the primary format: type `4.9.2026`
|
|
7252
|
+
into an ISO field, Tab away, see `2026-09-04`. The UX argument is decisive —
|
|
7253
|
+
*the user sees that the field understood what they typed* — and it is what makes
|
|
7254
|
+
a multi-format list legible rather than mysterious. This is a deliberate
|
|
7255
|
+
divergence from `IntegerField`, which leaves `"007"` alone: a format list is a
|
|
7256
|
+
statement that input and display are *separate vocabularies*, which
|
|
7257
|
+
`IntegerField` never claimed. The seam is `AbstractWrappingField#commit`
|
|
7258
|
+
(`D_wrapping_field`). **ENTER commits too**, because a form whose default button
|
|
7259
|
+
is reached by ENTER never moves focus, so blur alone would let it save an
|
|
7260
|
+
uncanonicalized buffer — and that generalized into the base, which commits on
|
|
7261
|
+
ENTER and then lets the key keep bubbling to whatever binds it. Two gestures,
|
|
7262
|
+
not a general "commit" notion: there is no third candidate.
|
|
7263
|
+
|
|
7264
|
+
**Decision — the red well is latched to those two gestures.** This is the first
|
|
7265
|
+
consumer of the settling rule `D_bad_input` left owed, and it had to be: every
|
|
7266
|
+
prefix of a date is bad input, so the OR in `error_ink?` held the field red from
|
|
7267
|
+
the first keystroke to the last — `2`, `20`, `202` all reddening on the way to a
|
|
7268
|
+
correct `2026-09-04`, which reads as "you are wrong" where the truth is "you are
|
|
7269
|
+
not finished". The rule gates the **ink** and nothing else, exactly as
|
|
7270
|
+
`D_has_validation` predicted it would: `HasBadInput#bad_input_settled?`
|
|
7271
|
+
(default `true`, so the numeric fields are unchanged — their residue is two
|
|
7272
|
+
transient buffers, where the early warning beats the quiet) is overridden here by
|
|
7273
|
+
a latch that `commit` sets and the base's `on_editor_change` clears. So the well
|
|
7274
|
+
reddens when the user leaves the field or presses ENTER, goes quiet on the next
|
|
7275
|
+
edit, and `bad_input?` — the pull a save gate uses — never waits for any of it.
|
|
7276
|
+
Two details that are easy to get backwards: `commit` settles **after** rewriting
|
|
7277
|
+
the buffer, since the rewrite is itself an edit that clears the latch; and
|
|
7278
|
+
settling must `invalidate`, because an ENTER on an untouched buffer paints no
|
|
7279
|
+
cells of its own and would otherwise change the ink with nothing repainting it.
|
|
7280
|
+
|
|
7281
|
+
**Decision — Up/Down step a day; an empty or unparseable field steps to
|
|
7282
|
+
today.** `IntegerField` treats an unparseable buffer as `0`, so the analogue is
|
|
7283
|
+
`Date.today` — and unlike the integer case the step *lands* on today rather than
|
|
7284
|
+
today ± 1, since today is what a picker would have opened on. They claim the
|
|
7285
|
+
editor's `on_key_up` / `on_key_down` slots, as the numeric fields do, leaving
|
|
7286
|
+
the general key seam free.
|
|
7287
|
+
|
|
7288
|
+
**Decision — `Date::GREGORIAN` by default, not Ruby's `Date::ITALY`.**
|
|
7289
|
+
Investigated rather than assumed, and the evidence runs one way. Ruby core calls
|
|
7290
|
+
the split a mistake — in [bug #18946][d_date_field_bug] Matz wrote *"`to_date`
|
|
7291
|
+
has been use GREGORIAN calendar since 2011-05-31 and `to_datetime` preserved the
|
|
7292
|
+
old `DEFAULT_SG` (ITALY). I assume this is a mistake and both should use
|
|
7293
|
+
GREGORIAN"* — `Time` is proleptic Gregorian and always has been (so
|
|
7294
|
+
`Date.new(1500,1,1).to_time` and `Time.new(1500,1,1).to_date` are nine days
|
|
7295
|
+
apart), ISO 8601 mandates proleptic Gregorian and ISO is this field's default
|
|
7296
|
+
primary format, and under `GREGORIAN` the ten days the reform skipped stop being
|
|
7297
|
+
a `Date::Error` the user cannot type their way out of. The cost, stated rather
|
|
7298
|
+
than hidden: the round-trip is exact only while the field's calendar matches
|
|
7299
|
+
that of the `Date`s the app hands it, and `Date.new(1500, 1, 1)` in *app* code
|
|
7300
|
+
is `ITALY` — so an app that builds pre-1582 dates naively gets them back nine
|
|
7301
|
+
days off after a canonicalization. That is what the per-instance setting is for.
|
|
7302
|
+
|
|
7303
|
+
[d_date_field_bug]: https://bugs.ruby-lang.org/issues/18946
|
|
7304
|
+
|
|
7305
|
+
**Rejected alternatives.**
|
|
7306
|
+
- **`value=` remembering the incoming `Date`'s own `start` and parsing back with
|
|
7307
|
+
it.** Makes the round-trip exact for free, and is wrong anyway: `value` would
|
|
7308
|
+
stop being a pure function of the buffer — the shape every other typed field
|
|
7309
|
+
has (`D_integer_field`: the buffer is the single source of truth) — and a
|
|
7310
|
+
field typed into from empty would have no `start` to remember.
|
|
7311
|
+
- **A runtime guard on `value=`.** `DateTime < Date` is true and `Time#strftime`
|
|
7312
|
+
exists, so `field.value = Time.now` "works", formats as the civil date and
|
|
7313
|
+
reads back a `Date`. Keep the thinness (`IntegerField#value=` just calls
|
|
7314
|
+
`to_s`) and rule the truncation in rdoc: it is the same lenient-in/strict-out
|
|
7315
|
+
shape as the format list.
|
|
7316
|
+
- **Two reference dates in the validator** — a pre-1969 one for the primary and
|
|
7317
|
+
an in-window one for the rest, so `%y` stays typeable as a fallback. Bought a
|
|
7318
|
+
two-digit shortcut at the price of a per-position rule; dropped with `%y`
|
|
7319
|
+
itself. Re-allowing it later is purely additive.
|
|
7320
|
+
- **A mask (`dd/mm/yyyy` with per-field ranges).** It *is* a format declaration
|
|
7321
|
+
by another route, and it manufactures a third state: `"__/05/2026"` is neither
|
|
7322
|
+
garbage nor a value but **incomplete**, which Vaadin models separately
|
|
7323
|
+
(`setIncompleteInputErrorMessage`). If this field ever grows one, it owes a
|
|
7324
|
+
ruling on whether incomplete is bad input or its own thing — and it would ride
|
|
7325
|
+
the same `error_ink?` hook either way.
|
|
7326
|
+
- **Designs that make bad input impossible** rather than reportable: a
|
|
7327
|
+
calendar-grid-only picker (no text input, so no parse at all — but ~30
|
|
7328
|
+
keystrokes for a birth date) and text entry behind a modal commit (a
|
|
7329
|
+
`ConfirmWindow`-shaped dialog that won't close on garbage — heavy in a form
|
|
7330
|
+
with six dates). A multi-format parse weakens the case for both.
|
|
7331
|
+
|
|
7332
|
+
**Consequences.**
|
|
7333
|
+
- **`require "date"` is hoisted into `lib/tuile.rb`.** `Date` is not preloaded,
|
|
7334
|
+
but unlike `bigdecimal` it is a default gem — always present, never optional —
|
|
7335
|
+
so it gets none of `D_bigdecimal_field`'s lazy-load treatment, and citing that
|
|
7336
|
+
precedent for it would be a misreading. `rake sig:validate` gains `-r date`
|
|
7337
|
+
for the same reason.
|
|
7338
|
+
- **`formats=` and `calendar_start=` can change `value` with no edit**, since
|
|
7339
|
+
the value is a derived parse — so both fire `on_value_change` when the reparse
|
|
7340
|
+
differs. Neither touches the buffer: it is text, it reparses on the next read,
|
|
7341
|
+
and if it has gone bad the red well says so.
|
|
7342
|
+
- **The humanizer must recognize a directive it does not know.** A `gsub` of the
|
|
7343
|
+
three known ones leaves `"%Y-%j"` as the literal hint `"yyyy-%j"` — exactly
|
|
7344
|
+
the lying hint the derive-exactly rule forbids, produced by the honest-looking
|
|
7345
|
+
code. It scans the *general* strftime directive shape (flags, width, the
|
|
7346
|
+
`E`/`O` modifiers, `%%`, `%::z`) and returns `nil` on any match outside the
|
|
7347
|
+
table.
|
|
7348
|
+
- **No `extent` declaration and no paint code**, like the other wrapping
|
|
7349
|
+
fields: the editor paints, and the red well arrives through
|
|
7350
|
+
`HasBadInput#error_ink?` (`D_has_validation`).
|
|
7351
|
+
- **PageUp/PageDown stepping a month is deferred**, recorded so it is a decision
|
|
7352
|
+
rather than an omission.
|
|
7353
|
+
|
|
7354
|
+
## D_kill_keys — Ctrl+U and Ctrl+W in the string fields; Shift+Backspace is not a key (2026-09-04)
|
|
7355
|
+
|
|
7356
|
+
**Status:** Accepted; implemented 2026-09-04.
|
|
7357
|
+
|
|
7358
|
+
**Context.** Emptying a `ComboBox` query meant holding Backspace down. The
|
|
7359
|
+
obvious binding — Shift+Backspace — does not exist on the wire: Backspace is a
|
|
7360
|
+
single byte (`\x7f`) with nowhere to carry a modifier, so a terminal delivers
|
|
7361
|
+
Shift+Backspace as plain Backspace. Only opt-in protocols express it (xterm's
|
|
7362
|
+
`modifyOtherKeys=2` sends `\e[27;2;127~`, kitty's keyboard protocol
|
|
7363
|
+
`\e[127;2u`), Tuile enables neither, and VTE and Konsole send `\x7f` regardless.
|
|
7364
|
+
Nothing binds it, so nothing needed to.
|
|
7365
|
+
|
|
7366
|
+
What every terminal input *does* bind is readline's kill trio: **Ctrl+U** to the
|
|
7367
|
+
line start, **Ctrl+W** the previous word, Ctrl+K to the line end. bash, zsh, fzf
|
|
7368
|
+
(`clear-query`), Textual's `Input` (`delete_left_all` / `delete_left_word`),
|
|
7369
|
+
prompt_toolkit, the ratatui ecosystem's `tui-input` and vim's insert mode all
|
|
7370
|
+
agree, and have since the 1980s. The GUI toolkits have no keyboard equivalent at
|
|
7371
|
+
all — Vaadin's combo ships a clear `×` and browsers rely on the mouse; macOS's
|
|
7372
|
+
Cmd+Delete is the closest cousin.
|
|
7373
|
+
|
|
7374
|
+
**Decision — Ctrl+W on `AbstractStringField`, Ctrl+U on each subclass, both
|
|
7375
|
+
targeting a deletion the *key* names.** They share one protected primitive,
|
|
7376
|
+
`delete_back_to(index)`, so the caret and cluster rules are written once:
|
|
7377
|
+
|
|
7378
|
+
- **Ctrl+W** is in the base, because "delete back to `word_left`" means the
|
|
7379
|
+
same thing in one line and in many — it deletes exactly what Ctrl+Left would
|
|
7380
|
+
have skipped, newline crossing included.
|
|
7381
|
+
- **Ctrl+U** is per subclass, because the target is not shared: index 0 in a
|
|
7382
|
+
`TextField`, the caret's **row** start in a `TextArea` — the wrapped row, so
|
|
7383
|
+
it kills back to wherever Home goes. Pinning it to the *line* would have made
|
|
7384
|
+
Ctrl+U and Home disagree in a wrapped paragraph, which is the more visible
|
|
7385
|
+
surprise.
|
|
7386
|
+
|
|
7387
|
+
Ctrl+K is deliberately not bound: killing *forward* is the rarer half of the
|
|
7388
|
+
trio and the one nobody reached for here. It stays free, and the shape above
|
|
7389
|
+
(one `when`, one `delete_forward_to`) is what to copy if it is ever wanted.
|
|
7390
|
+
|
|
7391
|
+
**The cost, paid knowingly: a `ComboBox` loses Ctrl+U half-page scrolling.**
|
|
7392
|
+
`ListDropdown::MOVE_KEYS` includes Ctrl+U/D, and a combo only ever sees the keys
|
|
7393
|
+
its field declines — so five of the six still bubble, and Ctrl+U now clears the
|
|
7394
|
+
query instead of moving the highlight five rows. `Select`, which wraps no
|
|
7395
|
+
editor, keeps all six. Worth it in one direction only: half-page-up over a
|
|
7396
|
+
ten-row dropdown duplicates what two arrow presses do, while clearing a query
|
|
7397
|
+
had no key at all. (`D_no_key_interceptor`'s "the field claims none of the six"
|
|
7398
|
+
is amended by exactly this.)
|
|
7399
|
+
|
|
7400
|
+
**Alternatives rejected.**
|
|
7401
|
+
- **Bind it on `ComboBox` alone, gated on the dropdown being closed.** The first
|
|
7402
|
+
proposal, and it fails twice. The gate is a mode flag in dispatch — a key
|
|
7403
|
+
meaning two things depending on invisible state — and the moment `TextField`
|
|
7404
|
+
grows the same key for its own sake (which it should, being a text field),
|
|
7405
|
+
Ctrl+U means one thing in a bare field and another inside a combo. A key
|
|
7406
|
+
earns its meaning from the widget that has focus; here that is always the
|
|
7407
|
+
field.
|
|
7408
|
+
- **Ctrl+W only, leaving Ctrl+U to the dropdown.** No collision, and repeated
|
|
7409
|
+
Ctrl+W clears a one-word query in one press. Declined because it makes Tuile
|
|
7410
|
+
the only terminal input where Ctrl+U does not clear the line, to protect a
|
|
7411
|
+
scroll gesture the arrows already cover.
|
|
7412
|
+
- **Drop Ctrl+U/D from `MOVE_KEYS` so the constant tells the truth.** It reads
|
|
7413
|
+
tidier and is strictly worse: it would take the half-page jump away from
|
|
7414
|
+
`Select` too, which has no editor and no conflict. The constant lists what the
|
|
7415
|
+
dropdown *accepts*; what reaches it is dispatch's business, and the rdoc says
|
|
7416
|
+
so.
|
|
7417
|
+
- **A `clear` gesture on the widget instead of a key** (a `×` affordance in the
|
|
7418
|
+
face, or ESC clearing rather than reverting). The face is one row with one
|
|
7419
|
+
spare column, already spent on the `▾`; and ESC's revert-to-the-committed-
|
|
7420
|
+
label is the behavior that makes the query transient (`D_has_value`), so
|
|
7421
|
+
spending it on clearing would cost more than it buys.
|
|
7422
|
+
|
|
7423
|
+
**Consequences.**
|
|
7424
|
+
- **`delete_before_caret` is now `delete_back_to(cluster_boundary_before(caret))`**
|
|
7425
|
+
— one deletion path, so the "write `@caret` before `text=`" rule (a caret left
|
|
7426
|
+
past the shortened text lands at its end) is stated once.
|
|
7427
|
+
- **Every `TextField` subclass and composed field inherits both keys** —
|
|
7428
|
+
`PasswordField`, the three numeric fields, `DateField`, `ComboBox`. Deletion
|
|
7429
|
+
passes through no `insert_text`, so an input filter has nothing to say about
|
|
7430
|
+
it (`D_input_filters`).
|
|
7431
|
+
- **A container under a text field can no longer bubble-bind Ctrl+U or Ctrl+W.**
|
|
7432
|
+
The same rule that already covers printables and the editing keys, now two
|
|
7433
|
+
keys wider.
|
|
7434
|
+
|
|
7435
|
+
---
|
|
7436
|
+
|
|
7437
|
+
## D_locale — `Locale`: the formatting conventions, and never the prose (2026-09-04)
|
|
7438
|
+
|
|
7439
|
+
**Decision.** `Tuile::Locale` is a frozen `Data` value type of formatting
|
|
7440
|
+
conventions — `date_formats`, `calendar_start`, `first_weekday`, the four
|
|
7441
|
+
month/day name tables, `decimal_separator`. `Screen#locale` holds one, seeded in
|
|
7442
|
+
`Screen#initialize` from `Locale.system` (which shells out to `locale -k`), and
|
|
7443
|
+
`Screen#locale=` replaces it, firing `Component#on_locale_changed` across the
|
|
7444
|
+
tree and invalidating all of it. `Locale::ISO` is the only shipped constant and
|
|
7445
|
+
the universal fallback. `DateField#formats` / `#calendar_start` became
|
|
7446
|
+
nil-means-inherit readers over it, and `DateField.default_format` /
|
|
7447
|
+
`.default_calendar_start` are deleted (`D_date_field`, superseded).
|
|
7448
|
+
Graduated from `ideas/locale.md`, which is retired.
|
|
7449
|
+
|
|
7450
|
+
**Why it became necessary.** Three components needed locale-shaped *data* and
|
|
7451
|
+
were about to grow three separate class-globals for it: `FloatField`'s decimal
|
|
7452
|
+
comma (`D_float_field` deferred it verbatim), a phase-2 calendar grid (which
|
|
7453
|
+
*cannot* be built without month names, since `Date::MONTHNAMES` is frozen
|
|
7454
|
+
English), and `DateField#formats` — whose two stopgap globals shipped the same
|
|
7455
|
+
day this was filed, marked "may change in the future" precisely because of this.
|
|
7456
|
+
|
|
7457
|
+
**The boundary rule, which is the whole design.**
|
|
7458
|
+
|
|
7459
|
+
> `Locale` holds formatting conventions — how a value is rendered and parsed.
|
|
7460
|
+
> It never holds prose.
|
|
7461
|
+
|
|
7462
|
+
That sentence is what keeps this eight members instead of a subsystem, and it is
|
|
7463
|
+
the gate a ninth has to pass. A message catalogue — per-string lookup,
|
|
7464
|
+
interpolation, pluralization — is a different beast, and `D_bad_input` already
|
|
7465
|
+
ruled that wording arrives as *the wording fork* (a settable message, or a
|
|
7466
|
+
catalogue lookup inside `bad_input_message`), never as a redesign of the
|
|
7467
|
+
channel. The rule is not Tuile's invention: **POSIX draws the same line**,
|
|
7468
|
+
putting prose in `LC_MESSAGES` and formatting in `LC_TIME` / `LC_NUMERIC`.
|
|
7469
|
+
Reading only the formatting categories is what lets a session coherently want an
|
|
7470
|
+
English UI, ISO dates and a decimal comma at once — which is not a contrived
|
|
7471
|
+
example but the author's own machine, and the strongest single argument in the
|
|
7472
|
+
whole design.
|
|
7473
|
+
|
|
7474
|
+
**Why a subprocess, which is a first for Tuile.** Ruby exposes no locale data at
|
|
7475
|
+
all, verified rather than remembered (3.3.8 / glibc): no `nl_langinfo` binding,
|
|
7476
|
+
no `D_FMT`, nothing on `Date` or in `Etc`; `Encoding.locale_charmap` is a
|
|
7477
|
+
charset and `RbConfig` has a path, not data. `Date::MONTHNAMES` is frozen
|
|
7478
|
+
English under any `LC_ALL`. And `strftime("%x")` is a **trap**, not a channel:
|
|
7479
|
+
it returned a fixed `"09/04/26"` under every locale tried *and*
|
|
7480
|
+
`Date.strptime("09/04/26", "%x")` parses, so `%x` in a format list is a silently
|
|
7481
|
+
American `%m/%d/%y` that would sail through a round-trip validator — hence the
|
|
7482
|
+
explicit rejection of `%x` / `%X` / `%c` with a message saying so, since that
|
|
7483
|
+
misconception is why this whole entry exists.
|
|
7484
|
+
|
|
7485
|
+
`locale(1)` is POSIX and hands back **strftime patterns — Tuile's exact
|
|
7486
|
+
vocabulary**, so there is no second grammar to translate or keep correct, and
|
|
7487
|
+
one ~1 ms call spans both categories (libc resolves each keyword in its own).
|
|
7488
|
+
The honest cost, and the thing this decision actually has to argue: **a
|
|
7489
|
+
subprocess at `Screen` construction is new.** `ColorDepth.detect` is env-only;
|
|
7490
|
+
`TerminalBackground` is an OSC query on a stream Tuile already owns. The
|
|
7491
|
+
mitigations are that it is gated (below), that nothing in it can fail loudly,
|
|
7492
|
+
and that it is one call rather than one per keyword.
|
|
7493
|
+
|
|
7494
|
+
**`-k`, not the bare keyword form.** Verified: `locale d_fmt bogus_key
|
|
7495
|
+
decimal_point` prints **two** lines for three keys, so positional parsing
|
|
7496
|
+
silently misaligns every later value — and that is exactly the shape of a
|
|
7497
|
+
keyword a non-glibc `locale` lacks (`first_weekday` is a glibc extension POSIX
|
|
7498
|
+
never defined). `-k` prints `key=value`, so a missing key is simply absent.
|
|
7499
|
+
|
|
7500
|
+
**The exit status is useless in both directions**, also verified: a bad locale
|
|
7501
|
+
name prints to stderr, **exits 0** and silently returns the C locale
|
|
7502
|
+
(`LC_ALL=xx_YY.UTF-8 locale d_fmt` → `%m/%d/%y`), while an unknown *keyword*
|
|
7503
|
+
**exits 1** yet still prints every good key. So the status is ignored, stderr
|
|
7504
|
+
discarded, and each value validated on its own — falling back to its `ISO`
|
|
7505
|
+
member individually rather than all-or-nothing. That per-member fallback reuses
|
|
7506
|
+
the real constructor (`locale.with(member => value)` in a `rescue`) instead of
|
|
7507
|
+
restating the shape rules, so there is exactly one validator.
|
|
7508
|
+
|
|
7509
|
+
**Detect only when the user said something — and gate it *per category*. This is
|
|
7510
|
+
the one judgement call the design rests on.** The C/POSIX default is American,
|
|
7511
|
+
so "said nothing" and "wants `%m/%d/%y`" are indistinguishable from the answer,
|
|
7512
|
+
and a container with no `LANG` would confidently show American dates to
|
|
7513
|
+
everyone. A *single* gate on "any locale variable is set" is wrong on a real
|
|
7514
|
+
machine: a session exporting only `LC_NUMERIC=de_DE.UTF-8` opens it, and then
|
|
7515
|
+
`d_fmt` resolves through an unset `LC_TIME` and an unset `LANG` to C's American
|
|
7516
|
+
pattern. So the date half is kept only if `LC_ALL` / `LC_TIME` / `LANG` speaks
|
|
7517
|
+
and the numeric half only if `LC_ALL` / `LC_NUMERIC` / `LANG` does, with unset,
|
|
7518
|
+
`C` and `POSIX` all counting as silence. One subprocess, two gates.
|
|
7519
|
+
|
|
7520
|
+
**`en_GB`'s `d_fmt` is `%d/%m/%y`, and it gets fixed rather than fudged.** It is
|
|
7521
|
+
the case that survives doing detection *correctly*, and the fix is principled: a
|
|
7522
|
+
two-digit year cannot round-trip, because `Date.new(1962, 9, 4)` renders
|
|
7523
|
+
`"04/09/62"` and reparses as **2062** under Ruby's fixed POSIX window
|
|
7524
|
+
(`69`→1969, `26`→2026). So the detected primary is widened `%y`→`%Y` while the
|
|
7525
|
+
raw pattern stays in the *parse* list, and a Brit typing `04/09/26` is still
|
|
7526
|
+
understood — lenient in, strict out, `DateField`'s own design doing the work.
|
|
7527
|
+
**Widen at the probe, raise at assignment**, and that asymmetry is deliberate:
|
|
7528
|
+
the probe has no author to tell, an assignment does. It is not an inconsistency
|
|
7529
|
+
to iron out.
|
|
7530
|
+
|
|
7531
|
+
That forced one **loosening of `D_date_field`'s validator**: only
|
|
7532
|
+
`formats.first` must survive a round-trip, since it is the only one ever
|
|
7533
|
+
*written*; a later entry only ever parses, so it needs merely to be a usable
|
|
7534
|
+
strptime pattern. Without it the detected list above is unrepresentable. It is
|
|
7535
|
+
backwards compatible (strictly more input accepted), and it improves the error
|
|
7536
|
+
message, which now says a `%y` pattern may still appear later in the list.
|
|
7537
|
+
|
|
7538
|
+
**Why `Screen` is the home, objection and all.** The objection is real: a locale
|
|
7539
|
+
is a property of the *human*, and `Screen` is "the service" (`D_tree_first`).
|
|
7540
|
+
Three things answer it. (1) `Screen` is already the **environment** boundary,
|
|
7541
|
+
not just the terminal: it owns both existing environment probes and the
|
|
7542
|
+
dark/light *scheme* derived from one of them — and a colour scheme is every bit
|
|
7543
|
+
as much a property of the human as a date format is. The locale arrives from the
|
|
7544
|
+
same place: the process environment the session was launched in. One session,
|
|
7545
|
+
one locale, one `Screen`. (2) It is machinery by `D_tree_first`'s own split;
|
|
7546
|
+
nothing about the *tree* changes. (3) The alternatives are worse — a
|
|
7547
|
+
`Tuile.locale` module global reintroduces the spec-leak hazard that AGENTS.md
|
|
7548
|
+
warns about for `ThemeDef.default`, and a `bg_color`-style resolve-up-the-tree
|
|
7549
|
+
chain has no consumer asking for two locales in one process.
|
|
7550
|
+
|
|
7551
|
+
**No `Locale.default`, and that is the interesting divergence from `ThemeDef`.**
|
|
7552
|
+
One was designed and dropped. `ThemeDef.default` exists because a theme *pair*
|
|
7553
|
+
is an app-authored artifact a whole spec suite needs every screen to carry; a
|
|
7554
|
+
locale is a **detected fact** whose app override is one line after `Screen.new`.
|
|
7555
|
+
So this follows the `ColorDepth` precedent instead: detect in `initialize`, pin
|
|
7556
|
+
in the fake, nothing global to leak or restore. The payoff is concrete — the
|
|
7557
|
+
spec-restore obligation `ThemeDef.default` and `VerticalScrollBar.handle_char`
|
|
7558
|
+
both carry simply does not exist here, because the next `Screen.fake` resets the
|
|
7559
|
+
only place the value lives. `Locale.system` is also **not memoized**: once per
|
|
7560
|
+
`Screen.new`, 1 ms, and a memo is one more thing a spec would have to reset.
|
|
7561
|
+
|
|
7562
|
+
**Name tables are keyed by the `Date` accessor that reads them.**
|
|
7563
|
+
`month_names[d.month]`, `day_names[d.wday]` — so months are a `Hash` keyed
|
|
7564
|
+
`1..12` and days stay Ruby's 0-based `Array` verbatim. The differing shapes are a
|
|
7565
|
+
*consequence* of the rule, not an inconsistency, and the rule makes the
|
|
7566
|
+
off-by-one **unrepresentable rather than documented against**: a 0-based month
|
|
7567
|
+
array answers `month_names[9] # => "October"`, a plausible wrong answer,
|
|
7568
|
+
silently — the same failure class as `%y` reparsing as 2062 and an `%m`/`%d`
|
|
7569
|
+
swap no validator can see. Out-of-range answers `nil`. The cost is one `.values`
|
|
7570
|
+
where a month picker wants all twelve.
|
|
7571
|
+
|
|
7572
|
+
**`calendar_start` belongs here, and the first answer that said otherwise was
|
|
7573
|
+
wrong for an instructive reason:** it conflated *probeable* with *belonging*.
|
|
7574
|
+
`locale(1)` has no keyword for the calendar reform date, but `Locale` was never
|
|
7575
|
+
defined as "whatever `locale -k` returns" — it is defined as *how a value is
|
|
7576
|
+
rendered and parsed*, and `calendar_start` is literally an argument to
|
|
7577
|
+
`Date.strptime`. It is also the most **regional** fact in the object (Italy 1582,
|
|
7578
|
+
England 1752, Russia 1918), one libc declines to expose only because nobody has
|
|
7579
|
+
needed it since. A member `Locale.system` never sets is fine; the *override*
|
|
7580
|
+
channel is the point, and an app in the pre-reform world now writes
|
|
7581
|
+
`ISO.with(calendar_start: Date::ITALY)` once instead of per field.
|
|
7582
|
+
|
|
7583
|
+
**All eight members ship ahead of their consumers — an explicit, bounded
|
|
7584
|
+
exception.** By this entry's own no-consumer rule (which keeps `t_fmt` /
|
|
7585
|
+
`d_t_fmt` / `am_pm` out), v1 would have been
|
|
7586
|
+
`Data.define(:date_formats, :calendar_start)`: the grid does not exist and
|
|
7587
|
+
`decimal_separator`'s field-side work is deferred above. The author overruled
|
|
7588
|
+
that, and the reasoning is worth recording because it is an exception. The probe
|
|
7589
|
+
is one subprocess either way, so five more keys buy a rounding error; the
|
|
7590
|
+
expensive part is the *rulings* (the keying, the `first_weekday` conversion, the
|
|
7591
|
+
per-category gate), which were settled while fresh and are cheaper to encode in
|
|
7592
|
+
code than to re-derive later; and the rule's real target is *inventing*
|
|
7593
|
+
conventions nobody asked for, which these are not — two consumers are already
|
|
7594
|
+
filed. The boundary that keeps it from becoming licence: the time formats stay
|
|
7595
|
+
out, because their consumer is a `TimeField` nobody has filed. That is the
|
|
7596
|
+
difference between deferred and unimagined. (Filed since — `D_time_field`,
|
|
7597
|
+
2026-09-05, adds `time_formats` as the ninth member, and rules that the *seconds
|
|
7598
|
+
strip* is the field's policy rather than the probe's normalization, so the
|
|
7599
|
+
member carries `t_fmt`'s full precision; the expansion table stays at the
|
|
7600
|
+
boundary as this entry's rule requires.)
|
|
7601
|
+
|
|
7602
|
+
**`locale=` invalidates the whole tree, plus one hook.** A locale change is a
|
|
7603
|
+
once-a-session event, so invalidate-all costs nothing worth optimizing, and a
|
|
7604
|
+
field losing a half-typed buffer to the new grammar is **accepted**: the text
|
|
7605
|
+
stays, the value goes nil, the field reads as bad input, and the user can see
|
|
7606
|
+
and fix it. Invalidation alone is not sufficient, though, and the reason
|
|
7607
|
+
generalizes: anything *pulled* at paint or parse time comes out right on the
|
|
7608
|
+
next frame, but anything **pushed** does not. `DateField`'s typing hint lives in
|
|
7609
|
+
its editor's `placeholder`, written when the formats were last set, so a repaint
|
|
7610
|
+
would faithfully repaint the stale `dd.mm.yyyy`. Hence `on_locale_changed`,
|
|
7611
|
+
shaped exactly like `on_theme_changed` (protected, `__send__` fan-out per
|
|
7612
|
+
`D_hook_visibility`, `super` from an override, plus an assignable listener for
|
|
7613
|
+
apps that compose rather than subclass). It stays a one-consumer mechanism by
|
|
7614
|
+
design: the calendar grid will read names at paint time and need nothing.
|
|
7615
|
+
|
|
7616
|
+
**Roads not taken:**
|
|
7617
|
+
|
|
7618
|
+
- *The `i18n` gem, or CLDR (`ruby-cldr`, `twitter_cldr`).* They have the data and
|
|
7619
|
+
they are correct; they are also dependencies, and Tuile's *one* optional
|
|
7620
|
+
dependency has a whole entry justifying it (`D_bigdecimal_field`, which says
|
|
7621
|
+
explicitly that a second needs its own argument rather than that precedent).
|
|
7622
|
+
Asking the system ships **zero** locale data, which keeps the no-catalogue rule
|
|
7623
|
+
better than any bundled table could.
|
|
7624
|
+
- *`ENV["LC_ALL"] || ENV["LC_TIME"] || ENV["LANG"]` plus a locale-name → format
|
|
7625
|
+
table.* This is the cheap version, and it is the catalogue **plus** a
|
|
7626
|
+
reimplementation of libc's precedence chain. It gets the author's machine wrong
|
|
7627
|
+
twice over (reading `LANG=en_US` for both categories, where the compiled data
|
|
7628
|
+
says otherwise for each), and it gets `en_DK` wrong unless the table happens to
|
|
7629
|
+
carry it. The subprocess's value is not that it reads env vars — it is that it
|
|
7630
|
+
reads the **compiled locale data**, per category.
|
|
7631
|
+
- *A `TUILE_LOCALE` env override*, for symmetry with `TUILE_COLOR_DEPTH`. A whole
|
|
7632
|
+
locale does not fit in an env var without inventing a serialization, and the pin
|
|
7633
|
+
a PTY spec needs already exists: pass `{"LC_ALL" => "C"}` in the env hash it has
|
|
7634
|
+
to pass anyway for the colour-depth reason, or a real locale name to exercise
|
|
7635
|
+
the real path. Unit specs need nothing — `FakeScreen` pins `ISO`.
|
|
7636
|
+
- *Preset constants (`Locale::EN_US`, `::DE`, …).* Two presets are a catalogue, a
|
|
7637
|
+
catalogue implies completeness, and `en_GB`'s own `%d/%m/%y` shows how
|
|
7638
|
+
opinionated even a "correct" entry is. Apps build theirs with `ISO.with(...)`;
|
|
7639
|
+
Tuile hands over the gun and ships no ammunition. The name helps — `ISO` names
|
|
7640
|
+
a *standard*, not a country, and three of its members cite it.
|
|
7641
|
+
- *Mirroring `Date::MONTHNAMES`' 13-entry array with `nil` at index 0.*
|
|
7642
|
+
`[d.month]` would work, at the price of a `nil` hole in a frozen value type —
|
|
7643
|
+
worse than the `Hash`, and it makes "12 names" unexpressible as a shape check.
|
|
7644
|
+
- *Making the day tables `Hash`es too.* Deferred, with a trigger rather than a
|
|
7645
|
+
shrug: the only real argument is a future "widest name in this table" helper for
|
|
7646
|
+
the grid's column arithmetic, which reads `.values` on a `Hash` and the table
|
|
7647
|
+
itself on an `Array`. Until the grid exists, re-keying `Date::DAYNAMES` is
|
|
7648
|
+
transformation noise in a constant whose virtue is being Ruby's data verbatim.
|
|
7649
|
+
- *Validating the probe's answer as a whole.* Rejected in favour of per-member
|
|
7650
|
+
fallback: one odd keyword on one distro would otherwise discard a `d_fmt` that
|
|
7651
|
+
was perfectly good.
|
|
7652
|
+
- *Trusting `locale`'s exit status*, or reading `t_fmt` / `am_pm` "while we are
|
|
7653
|
+
in there". The first is measured to be meaningless; the second is the
|
|
7654
|
+
no-consumer rule, which the eight-member overrule above explicitly does not
|
|
7655
|
+
extend to.
|
|
7656
|
+
|
|
7657
|
+
**Known-unverified, and deliberately so.** macOS / BSD `locale -k` keyword
|
|
7658
|
+
support is believed fine but unchecked (CI is `ubuntu-latest` only), and
|
|
7659
|
+
`first_weekday` is a glibc extension likely absent there. This is safe to leave
|
|
7660
|
+
open precisely because of the `-k` shape and the per-member fallback: an absent
|
|
7661
|
+
or odd key degrades to its `ISO` value with nothing raised. A missing binary
|
|
7662
|
+
(Windows, some musl containers) yields `ISO` whole, and Windows' gate never opens
|
|
7663
|
+
anyway.
|
|
7664
|
+
|
|
7665
|
+
## D_empty_ancestor — An empty rect propagates down, and the drain filter enforces it (2026-09-04)
|
|
7666
|
+
|
|
7667
|
+
**Status:** Accepted; implemented 2026-09-04 in `Layout::Box#relayout`,
|
|
7668
|
+
`Screen#repaint`'s drain filter and `Screen#initialize`, with `Box#add`'s `at:`
|
|
7669
|
+
and `Box#constrain` alongside. Reported downstream by pikuri-tui as issue #17.
|
|
7670
|
+
Leans on `D_repaint_cascade` (the same bug shape one layer up — a notice that
|
|
7671
|
+
must keep travelling down), `D_tabs` (hiding is detachment; the `visible?`
|
|
7672
|
+
re-grow rule), `D_box_layouts` (the sugar this amends) and `D_extent` (why an
|
|
7673
|
+
empty rect is a *paint* convention and gates nothing else).
|
|
7674
|
+
|
|
7675
|
+
**Context — the symptom.** pikuri-tui's coding shell hides its sidebar column by
|
|
7676
|
+
handing it a zero-width rect. The two panes inside it are `Layout::Vertical`s,
|
|
7677
|
+
which kept the rects they had while visible. Nothing painted them — until the
|
|
7678
|
+
user closed the `Ctrl+K` leader menu, a popup, and `Screen#remove_popup`'s
|
|
7679
|
+
`needs_full_repaint` invalidated **every** component directly. The sidebar came
|
|
7680
|
+
straight back, and, being later in tree order, over the conversation pane's
|
|
7681
|
+
scrollbar column. Every subsequent popup open/close flickered it away and back.
|
|
7682
|
+
|
|
7683
|
+
**The mechanism.** `Box#relayout` opened with `return if rect.empty?`, so a box
|
|
7684
|
+
whose own rect went empty never assigned its children. The branch immediately
|
|
7685
|
+
below it does exactly the right thing — `children.each { _1.rect = … 0, 0 }` for
|
|
7686
|
+
an empty `inner_rect` — and the guard made it unreachable. Three paths refuse to
|
|
7687
|
+
paint the resulting stale subtree (`Component#repaint`'s own empty-rect return,
|
|
7688
|
+
the default container `repaint` cascading only from a non-empty parent, and
|
|
7689
|
+
`Screen#repaint`'s detached filter), which is why it stayed hidden;
|
|
7690
|
+
`needs_full_repaint` bypasses all three.
|
|
7691
|
+
|
|
7692
|
+
A sweep says the fault is `Box`'s alone: `Window#layout_footer`, `Slot#layout`
|
|
7693
|
+
and `TabSheet#rect=` all let an empty rect fall through to the arithmetic and
|
|
7694
|
+
zero their children correctly. `Box` was also inconsistent with *itself* — an
|
|
7695
|
+
over-subscribed child is already starved to an empty rect and placed anyway.
|
|
7696
|
+
|
|
7697
|
+
**Decision — a container propagates its own empty rect to every child.** The
|
|
7698
|
+
guard conflated *"no rect yet"* with *"rect deliberately emptied"*, and only the
|
|
7699
|
+
first ever needed protecting; it survives the deletion, because during
|
|
7700
|
+
construction the children are already empty (so the assignments are no-ops) and
|
|
7701
|
+
the trailing `invalidate` returns early while detached. This is the load-bearing
|
|
7702
|
+
half: it is what makes `cursor_position` answer `nil` for a collapsed field, and
|
|
7703
|
+
so what stops the hardware cursor parking inside the *visible* pane.
|
|
7704
|
+
|
|
7705
|
+
**Decision — `Screen#repaint`'s drain filter is ancestor-aware.** The filter
|
|
7706
|
+
already dropped detached components; it now also drops a component with an empty
|
|
7707
|
+
rect anywhere on its ancestor chain. This is not new policy — `Component#repaint`
|
|
7708
|
+
gates each component on its *own* empty rect, and this is that same gate made to
|
|
7709
|
+
see one hop further — so it removes an inconsistency rather than adding a rule.
|
|
7710
|
+
The point is that it holds for a container that has **not** been fixed, including
|
|
7711
|
+
an app's own `Absolute` subclass: a forgetful container now leaves an inert
|
|
7712
|
+
subtree instead of a subtree that paints at stale coordinates. It is also a
|
|
7713
|
+
strictly narrower repaint set, so marginally cheaper.
|
|
7714
|
+
|
|
7715
|
+
**Consequence — the pane is sized in `Screen#initialize`.** `@pane.rect` used to
|
|
7716
|
+
be assigned only by `#layout`, which runs from the event loop; under the new
|
|
7717
|
+
filter an unsized pane is an empty *ancestor* rect for the entire tree, and every
|
|
7718
|
+
`FakeScreen`-driven repaint painted nothing. Seeding it at construction, exactly
|
|
7719
|
+
as `#size` is already seeded from `TTYSizeEvent.create`, is the honest fix: it
|
|
7720
|
+
makes the fake match production, where `#layout` runs before anything paints.
|
|
7721
|
+
`FakeScreen` re-seeds it after resizing itself to 160×50.
|
|
7722
|
+
|
|
7723
|
+
**Decision — a collapse is not hiding, and `Box` lets you re-place a child.**
|
|
7724
|
+
The issue proposed a `visible` property with Android's `INVISIBLE` / `GONE`
|
|
7725
|
+
split. What this entry settled is the measurement that any such flag has to
|
|
7726
|
+
honour: geometry cannot express hiding, because `tab_stop?` does not consult
|
|
7727
|
+
geometry and must not. A zero-rect sidebar keeps its tab stops and still takes
|
|
7728
|
+
keys **after** this fix — the fix only stops it painting and moves its cursor
|
|
7729
|
+
off-screen. So `Fixed[0]` is *collapse*, not hide, and its rdoc says so. The
|
|
7730
|
+
flag itself was deferred here under `D_tabs`' re-grow rule and has since been
|
|
7731
|
+
accepted as `D_visibility`, as the full focus-and-paint gate this measurement
|
|
7732
|
+
demands; that entry owns hiding now.
|
|
7733
|
+
|
|
7734
|
+
What pushed pikuri-tui into the zero-rect idiom is that detachment — the answer
|
|
7735
|
+
at the time — was not expressible in a `Box`: `Box#add` had no `at:`, so a
|
|
7736
|
+
removed child came back at the end of a multi-child box, and a child's
|
|
7737
|
+
constraints could not be changed after `add` at all. Both are closed —
|
|
7738
|
+
`add(child, main, at: i)` and `constrain(child, main = nil, cross: nil, align:
|
|
7739
|
+
nil)`, the latter `nil`-means-keep so one axis moves alone — and both stay
|
|
7740
|
+
useful beside the flag: `remove` / `add(…, at:)` is the move when the lifecycle
|
|
7741
|
+
hooks *should* fire.
|
|
7742
|
+
|
|
7743
|
+
**Roads not taken.**
|
|
7744
|
+
|
|
7745
|
+
- **A `Component#paintable?` predicate** for the filter to call. Rejected: it
|
|
7746
|
+
reads as a component-level concept ("am I paintable?") when it is one screen's
|
|
7747
|
+
drain-time question, and it would be a new public predicate every component
|
|
7748
|
+
answers. The AND is spelled at the one call site instead.
|
|
7749
|
+
- **Fixing `needs_full_repaint` alone**, as the issue proposed. It is the path
|
|
7750
|
+
that surfaced the bug, but not the only one that can reach a stale subtree, and
|
|
7751
|
+
the invariant belongs at the single choke point every invalidation drains
|
|
7752
|
+
through.
|
|
7753
|
+
- **Blanking the cells a collapsed subtree vacated.** Deliberately not attempted.
|
|
7754
|
+
`clear_outside_extent` reaches an L, never an interior hole, and the general
|
|
7755
|
+
version is the rect-subtraction geometry `D_repaint_cascade` already declined
|
|
7756
|
+
in the hottest path. In a tiled layout a sibling grows into the space and
|
|
7757
|
+
repaints, which is what happens in pikuri-tui and in `#constrain`'s
|
|
7758
|
+
`Fixed[0]`-gives-its-space-to-siblings spec. The minimal repro — a root box
|
|
7759
|
+
emptied by nobody, so no sibling exists — keeps its stale glyphs, and that is
|
|
7760
|
+
the artificial case.
|
|
7761
|
+
- **Making a collapsed child cost no `Box#spacing`.** A `Fixed[0]` child still
|
|
7762
|
+
leaves the gap around it. Changing that would also change over-subscription
|
|
7763
|
+
starvation, which shifts existing layouts, and `D_box_layouts` holds that a gap
|
|
7764
|
+
belongs to the *sequence*. Documented on `Fixed` instead. `D_visibility`
|
|
7765
|
+
draws the line: a collapsed child is still a member of the sequence and keeps
|
|
7766
|
+
its gap; a *hidden* one is not, and costs nothing.
|
|
7767
|
+
|
|
7768
|
+
## D_component_contract — A contract suite over a catalog of every component (2026-09-04)
|
|
7769
|
+
|
|
7770
|
+
**Status:** Accepted; `spec/tuile/component_contract_spec.rb` added 2026-09-04
|
|
7771
|
+
with three invariants and a completeness guard. Prompted by `D_empty_ancestor`,
|
|
7772
|
+
whose bug the suite would have caught. Leans on `D_repaint_cascade` and
|
|
7773
|
+
`D_progress_bar` (two of the three invariants are their rules), and on
|
|
7774
|
+
`D_component_lookup` (the other additive-to-the-assertion-channel testing tool).
|
|
7775
|
+
|
|
7776
|
+
**Context.** Tuile's per-widget specs are thorough and all of them share a blind
|
|
7777
|
+
spot: they assert what *their* widget paints, in *its* rect, after *one*
|
|
7778
|
+
repaint. Three framework-wide obligations are invisible from there. A widget
|
|
7779
|
+
that overruns its rect paints on a *neighbour*. A widget that blanks a cell it
|
|
7780
|
+
is about to repaint marks it dirty, so `flush` re-emits it — identical on screen.
|
|
7781
|
+
A container that fails to zero its children's rects strands them, and nothing
|
|
7782
|
+
paints until an unrelated full repaint does. All three fail with no exception
|
|
7783
|
+
and no red example; `D_empty_ancestor` shipped for exactly that reason, and
|
|
7784
|
+
`D_repaint_cascade`'s bug was found by a hand-rolled sweep, which is the same
|
|
7785
|
+
observation made once and then thrown away.
|
|
7786
|
+
|
|
7787
|
+
**Decision — one suite, run over a catalog, with a guard that makes enrolment
|
|
7788
|
+
mandatory.** The catalog maps each concrete `Component` subclass to a factory
|
|
7789
|
+
returning one populated enough to paint; the guard eager-loads `lib/` and fails
|
|
7790
|
+
on any subclass in neither the catalog nor an `excluded` map that carries a
|
|
7791
|
+
reason per entry. The eager load is the load-bearing half: Zeitwerk resolves on
|
|
7792
|
+
first reference, so without it the guard would see only the classes the catalog
|
|
7793
|
+
itself named — it could never spot the new component it exists to spot. The
|
|
7794
|
+
value is entirely in the components that *do not exist yet*; a suite you have to
|
|
7795
|
+
remember to extend is a suite that documents the day it was written.
|
|
7796
|
+
|
|
7797
|
+
**Decision — a violator is `pending`, never `skip`.** RSpec fails a `pending`
|
|
7798
|
+
example that starts passing, so a fix prompts deleting its entry; a `skip` would
|
|
7799
|
+
let the deviation outlive its reason. The distinction matters because the first
|
|
7800
|
+
run found one (below), and the temptation was to widen the check's precondition
|
|
7801
|
+
until it went quiet — which is how a suite becomes decoration.
|
|
7802
|
+
|
|
7803
|
+
**What the first run found.** `Component::Window` and its four subclasses
|
|
7804
|
+
re-emit their whole border on every unchanged repaint: `Window#repaint` calls
|
|
7805
|
+
`super`, whose default clears the rect because the content slot does not tile it
|
|
7806
|
+
(it is inset by the border), and then `repaint_border` redraws the border over
|
|
7807
|
+
the cells just blanked. Measured at **925 bytes per unchanged 80×24 repaint**,
|
|
7808
|
+
paid on every focus change, since that invalidates the whole focus chain and
|
|
7809
|
+
every enclosing `Window` is on it. Note AGENTS.md asserted the opposite —
|
|
7810
|
+
"components that paint their entire rect themselves (currently `Window` … and
|
|
7811
|
+
`List`) opt out" — which is true of `List` and was never true of `Window`. Left
|
|
7812
|
+
**fixed the same day**, and the fix was smaller than the guess: not an override
|
|
7813
|
+
of `children_tile_rect?` but `Window#repaint` dropping `super` for
|
|
7814
|
+
`invalidate_children` plus `clear_background(content_rect)` on the one path
|
|
7815
|
+
nothing else covers — a window with no content in it. Nothing had to change
|
|
7816
|
+
about a shrinking content rect, because the content rect is derived from the
|
|
7817
|
+
window's own (`Window#content_rect`, extracted for this) and so cannot shrink
|
|
7818
|
+
independently of it. The check found a second instance while measuring the
|
|
7819
|
+
first: with `scrollbar = true` the window drops its right border so the
|
|
7820
|
+
content's bar can own that column, but `repaint_border` painted `│` down it
|
|
7821
|
+
anyway before the bar covered it, dirtying the column into every frame —
|
|
7822
|
+
270 bytes an unchanged frame, now 0. Both are pinned in `window_spec`; the
|
|
7823
|
+
`pending` entry is gone, which is the mechanism working as designed.
|
|
7824
|
+
|
|
7825
|
+
**Alternatives rejected.**
|
|
7826
|
+
|
|
7827
|
+
- **Runtime enforcement in the framework**, the way `Tuile::Final` guards the
|
|
7828
|
+
tree methods. Right for a rule with a cheap check at a single call site;
|
|
7829
|
+
wrong for these, which need a *painted buffer* to compare against. Verifying
|
|
7830
|
+
"painted nothing outside its rect" at runtime means bounds-checking every
|
|
7831
|
+
`Buffer` write against the painting component's rect — a check in the hottest
|
|
7832
|
+
path, for a bug that a test catches once per CI run.
|
|
7833
|
+
- **Auto-discovering instances instead of a catalog** (walk `lib/`, call
|
|
7834
|
+
`klass.new`). It fails on every component with a required argument and, worse,
|
|
7835
|
+
yields *empty* components — an unpopulated `List` paints nothing, so every
|
|
7836
|
+
invariant passes vacuously. The factories are the point: the catalog is a set
|
|
7837
|
+
of canonical, painted specimens, and writing one is the work.
|
|
7838
|
+
- **Folding these into `component_spec.rb`.** That file already has the pattern
|
|
7839
|
+
— "keeps children, `@children` and the parent pointers in agreement" walks a
|
|
7840
|
+
tree of every container kind — but it is the spec *for* `Component`, mirroring
|
|
7841
|
+
`lib/tuile/component.rb` per the one-spec-per-source-file rule. A suite whose
|
|
7842
|
+
subject is "every component" mirrors no file, so it is a sibling of
|
|
7843
|
+
`nomenclature_spec.rb`: a guard, named for what it guards.
|
|
7844
|
+
- **Starting with more invariants.** Screen-free construction, `extent` honesty
|
|
7845
|
+
("a component paints every cell it declares"), and `bg_color` inheritance were
|
|
7846
|
+
all drafted and cut. Three that hold and are understood beat eight where two
|
|
7847
|
+
are half-true — a suite with a hedged invariant teaches contributors to widen
|
|
7848
|
+
preconditions rather than fix code.
|
|
7849
|
+
|
|
7850
|
+
## D_visibility — `Component#visible=`: a *gone* flag, kept in the tree (2026-09-05)
|
|
7851
|
+
|
|
7852
|
+
**Status:** Accepted and implemented 2026-09-05 — `Component#visible=` /
|
|
7853
|
+
`#visible?` / `#on_shown_tree` / `#on_child_visibility_changed`, the gates in
|
|
7854
|
+
`Screen`, `ScreenPane`, `Layout`, `HasContent`, `Box`, `Overlay` and `Testing`,
|
|
7855
|
+
a `component_contract_spec` invariant over the whole catalog, book ch5 + ch7 and
|
|
7856
|
+
a sampler pane (Shell ▸ Visibility). Brainstormed in `ideas/visibility.md`, with
|
|
7857
|
+
a 24-toolkit precedent survey beside it; both retired. Satisfies the re-grow
|
|
7858
|
+
rule `D_tabs` recorded and supersedes `D_empty_ancestor`'s "hiding is still
|
|
7859
|
+
detachment".
|
|
7860
|
+
Leans on `D_slots` (an empty slot keeps its space — the *other* state),
|
|
7861
|
+
`D_box_layouts` (a gap belongs to the sequence), `D_overlay` (an overlay is
|
|
7862
|
+
dismissed, not hidden), `D_attach_hooks` (what a hidden component keeps
|
|
7863
|
+
running), `D_component_lookup` (the locator's contract) and
|
|
7864
|
+
`D_component_contract` (where the new invariant is enforced).
|
|
7865
|
+
|
|
7866
|
+
**Context.** A form hides fields on choices above them — "Company name" only
|
|
7867
|
+
when "Business customer" is ticked. After `D_empty_ancestor` the sanctioned
|
|
7868
|
+
way to hide was `box.remove(field)` and `box.add(field, Fixed[1], at: i)`, and
|
|
7869
|
+
three things make that the wrong tool for exactly this consumer: the app has to
|
|
7870
|
+
know `i`, which shifts under it (an insert above, or two fields re-shown in the
|
|
7871
|
+
other order, and they land in each other's places — a shadow copy of the child
|
|
7872
|
+
order, the very thing `D_tree_api` forbids the framework from keeping, pushed
|
|
7873
|
+
onto every app); `remove` drops the child's placement, so the show path re-states
|
|
7874
|
+
`Fixed[1], cross: Fixed[30]` from somewhere; and detachment fires the lifecycle
|
|
7875
|
+
hooks, which is wrong for a pane that must stay live while hidden (a running
|
|
7876
|
+
job, a subscription). The two geometric idioms fail too, measured in
|
|
7877
|
+
`D_empty_ancestor`: `Fixed[0]` keeps its `spacing` gap and its tab stop, and a
|
|
7878
|
+
`Slot` with no content keeps its row. `D_tabs`' re-grow rule asked for a second
|
|
7879
|
+
consumer and a ruling on layout arithmetic; this is both. Note that `D_tabs`
|
|
7880
|
+
argued against *zero-rect* hiding and for detachment as `TabSheet`'s
|
|
7881
|
+
implementation — it never argued against a flag.
|
|
7882
|
+
|
|
7883
|
+
**Decision — one boolean, meaning *gone*.** `Component#visible?` /
|
|
7884
|
+
`#visible=`, default `true`. `false` means: **as if detached — but it stays in
|
|
7885
|
+
the tree.** Paints nothing, takes no space in a `Box`, and is invisible to
|
|
7886
|
+
focus, keys, the cursor and the mouse, exactly as a detached component is; and
|
|
7887
|
+
unlike one it keeps its parent, its rect, its placement, its state and its
|
|
7888
|
+
running resources, so no lifecycle hook fires. Say it that way and not "hiding
|
|
7889
|
+
is detaching" — the analogy is about what the *user* can reach, and the one
|
|
7890
|
+
place it breaks is the one an implementor would otherwise assume: `on_detached`
|
|
7891
|
+
is never called. This is Android's `GONE`, and only `GONE`. The keep-the-space state
|
|
7892
|
+
(`INVISIBLE`) is not a second value: a `Slot` whose content is `nil` already
|
|
7893
|
+
*is* that (`D_slots`), so Tuile has both Android states with no enum. The
|
|
7894
|
+
survey says this is the industry shape, not a Tuile shortcut: where a single
|
|
7895
|
+
boolean exists (Qt, GTK4, Vaadin, Lanterna, WinForms, AppKit stack views,
|
|
7896
|
+
Flutter's `Visibility`, Textual's `display`, FTXUI's `Maybe`, Tk's `grid
|
|
7897
|
+
remove`) it means gone, and keep-the-space is everywhere the opt-in
|
|
7898
|
+
(`retainSizeWhenHidden`, `maintainSize`, `setHonorsVisibility(false)`,
|
|
7899
|
+
`detachesHiddenViews = false`). Named `visible`, positive: a caller wanting a
|
|
7900
|
+
thing shown writes `visible = true`, never `hidden = !x`; it is also the name
|
|
7901
|
+
the re-grow rule used and the sibling of `scrollbar_visibility`.
|
|
7902
|
+
|
|
7903
|
+
**Decision — the gates live on the component tree, not in the containers.**
|
|
7904
|
+
The flag is ancestor-inclusive: a component is *shown* iff it and every
|
|
7905
|
+
ancestor are `visible?`, and every walk prunes the hidden subtree at its root
|
|
7906
|
+
rather than testing leaves, so a `TextField` three levels under a hidden panel
|
|
7907
|
+
is skipped without knowing it. The gates, and where each sits:
|
|
7908
|
+
|
|
7909
|
+
- **Paint:** `Screen#repaint`'s drain filter — the one choke point every
|
|
7910
|
+
invalidation passes — drops a hidden component or one under a hidden
|
|
7911
|
+
ancestor. Same AND as `D_empty_ancestor`'s empty-rect term, one more term.
|
|
7912
|
+
`invalidate` keeps recording; showing again re-invalidates the subtree
|
|
7913
|
+
(`on_tree`), as `bg_color=` does.
|
|
7914
|
+
- **The parent's gap clear:** `children_tile_rect?` excludes hidden children, so
|
|
7915
|
+
a hidden child's cells count as a gap and the parent blanks them. This is what
|
|
7916
|
+
actually erases the field in a container that never re-assigns its rect.
|
|
7917
|
+
- **Focus walks:** `Screen#cycle_focus`, `ScreenPane#first_tab_stop_or_root`,
|
|
7918
|
+
`Layout#on_focus` and `HasContent#on_focus` prune hidden subtrees, through one
|
|
7919
|
+
shared walk helper rather than four copies of the rule.
|
|
7920
|
+
- **Focus assignment:** `Screen#focused=` raises on a hidden target, as it does
|
|
7921
|
+
on a detached one. Focusing what the user cannot see is the cursor-in-the-
|
|
7922
|
+
visible-pane bug of `D_tabs`, and it should fail at the call site.
|
|
7923
|
+
- **Mouse:** `Component#handle_mouse` skips hidden children — belt to the
|
|
7924
|
+
empty-rect braces, since an `Absolute` child keeps its rect.
|
|
7925
|
+
- **Cursor and keys** follow: the focused component is always shown, and a
|
|
7926
|
+
hidden one is never on the focus chain.
|
|
7927
|
+
- **Lifecycle: nothing fires.** `on_attached` / `on_detached` stay bound to
|
|
7928
|
+
`parent=`. That is the feature — the hidden-but-live pane.
|
|
7929
|
+
|
|
7930
|
+
Why component-side: a hidden child that keeps a stale rect is then *harmless*
|
|
7931
|
+
in every container — it paints nothing, its cells are blanked, it takes no
|
|
7932
|
+
focus and no clicks — so a container that never heard of the flag degrades to
|
|
7933
|
+
a hole rather than to a leak. That inverts `D_empty_ancestor`'s failure mode,
|
|
7934
|
+
where every container between the app and the leaves was a separate chance to
|
|
7935
|
+
forget.
|
|
7936
|
+
|
|
7937
|
+
**Decision — the layout arithmetic.** `Box` skips a hidden child entirely: out
|
|
7938
|
+
of the `count` that prices `spacing * (count - 1)`, no share of `available`, and
|
|
7939
|
+
assigned the empty rect at the box's origin (the rect its `inner.empty?` branch
|
|
7940
|
+
assigns). Its placement entry is **kept**, so `visible = true` puts it back with
|
|
7941
|
+
its constraints intact. This is the one place gone reclaims space, and it makes
|
|
7942
|
+
a `spacing: 1` form close up cleanly. It settles the point `D_empty_ancestor`
|
|
7943
|
+
left open: a collapsed (`Fixed[0]`) child is still a member of the sequence and
|
|
7944
|
+
keeps costing its gap; a hidden child is not, and costs nothing. Every box
|
|
7945
|
+
layout in the survey prices spacing over shown children only (Qt's
|
|
7946
|
+
`previousNonEmptyIndex`, GTK's `(n_visible_children - 1) * spacing`, Lanterna,
|
|
7947
|
+
AWT `FlowLayout`, Android `LinearLayout` and its dividers, AppKit); the one
|
|
7948
|
+
double-gap trap is Swing `BoxLayout`, and only because its gaps are strut
|
|
7949
|
+
*components* — Tuile's `spacing` is a number, so it cannot fall into it. `Box`
|
|
7950
|
+
learns of a flip through one protected upward hook,
|
|
7951
|
+
`on_child_visibility_changed(child)`, default no-op, the shape of
|
|
7952
|
+
`on_child_removed`; a future `FormLayout` overrides it too. `Absolute` does
|
|
7953
|
+
nothing — its `rect=` is app arithmetic, and an app that wants the space
|
|
7954
|
+
reclaimed reads `child.visible?` in its own pass. `Window` / `Popup` / `Slot` /
|
|
7955
|
+
`HasContent` have one child and nothing to reclaim; hidden content leaves the
|
|
7956
|
+
inner area blank, as an empty `Slot` does, and a `Window` keeps its border.
|
|
7957
|
+
|
|
7958
|
+
**Decision — hiding the focused subtree repairs focus through the parent, never
|
|
7959
|
+
to `nil`, and never restores on re-show.** When the flag lands on the focused
|
|
7960
|
+
component or an ancestor of it, focus goes to the hidden component's parent and
|
|
7961
|
+
the parent's ordinary `on_focus` cascade takes it from there (a `Layout` to the
|
|
7962
|
+
first *shown* tab stop in its subtree, a `Window` into its content, the
|
|
7963
|
+
`ScreenPane` through its own repair). This is the focus-repair half of
|
|
7964
|
+
`Component#on_child_removed`, reused as-is, so the two ways a subtree can
|
|
7965
|
+
leave the user's reach land focus in the same place and are specced once —
|
|
7966
|
+
only the repair is shared, not the detach; the hidden component keeps its
|
|
7967
|
+
parent and hears no hook. `nil`
|
|
7968
|
+
was the other candidate and it is not neutral in Tuile: `ScreenPane#focus_chain`
|
|
7969
|
+
returns nil, `bubble_key` delivers to nobody — not even the scope root, so a
|
|
7970
|
+
form's Enter-to-submit goes dead — and the next unhandled `q` or ESC falls
|
|
7971
|
+
through to the loop and **quits the app**. The DOM's focus-to-viewport works
|
|
7972
|
+
because a browser has no quit key. Nothing is restored when the component
|
|
7973
|
+
reappears — no toolkit surveyed does, and a stored "focus to restore" is one
|
|
7974
|
+
more pointer into a tree that may have changed. Prior art, verified against
|
|
7975
|
+
source: DOM moves focus to the viewport, and Chromium resumes Tab from the
|
|
7976
|
+
removed node's *parent* (the shape chosen here); Swing (`hide` →
|
|
7977
|
+
`transferFocus(true)`) and Qt (`hide_helper` → `focusNextPrevChild(true)`, also
|
|
7978
|
+
when an ancestor is hidden) go to the **next** component; Android `GONE` clears
|
|
7979
|
+
and lands on the **first** focusable from the top; Textual's `display = False`
|
|
7980
|
+
does *not* reset focus, so its `focused` goes stale — the leak `D_tabs` listed,
|
|
7981
|
+
shipped.
|
|
7982
|
+
|
|
7983
|
+
**Decision — `Testing.find` never finds a hidden component.** Karibu-Testing's
|
|
7984
|
+
standing policy, held for years: a test is a user, and a user cannot click what
|
|
7985
|
+
is not on the screen; a locator that reached a hidden `Button` would pass a form
|
|
7986
|
+
the user cannot operate. So `find` / `get` prune hidden subtrees with the same
|
|
7987
|
+
walk the focus gates use. The failure message then **counts the hidden matches
|
|
7988
|
+
it excluded** — `found 0 (1 hidden match excluded)` — because "I *know* it's
|
|
7989
|
+
there" is the commonest confusion and one clause diagnoses it; and `dump` shows
|
|
7990
|
+
the whole tree, `inspect_details` adding `hidden` and the excluded matches
|
|
7991
|
+
getting their own marker beside the `→` of found ones. **No `visible:` filter.**
|
|
7992
|
+
A test asserting a field *is* hidden holds the reference and asserts
|
|
7993
|
+
`refute field.visible?`; one asserting the user cannot reach it asserts
|
|
7994
|
+
`count: 0`, which is what the user experiences. A filter reaching hidden
|
|
7995
|
+
components is the same hole as `_setValue` on a disabled field, and comes back
|
|
7996
|
+
only argued from a test that cannot be written otherwise.
|
|
7997
|
+
|
|
7998
|
+
**Decision — overlays refuse the flag; `TabSheet` stays on detachment.**
|
|
7999
|
+
`visible=` raises on an `Overlay`: it is dismissed, not hidden (`D_overlay`),
|
|
8000
|
+
the "live while hidden" motivation does not apply, and the pane consults
|
|
8001
|
+
`@popups` for hit-testing and `modal_popup` for key scope — a hidden popup that
|
|
8002
|
+
was still modal and still caught clicks is the `focusable?`-and-`modal?` trap
|
|
8003
|
+
again. `TabSheet` keeps detaching: the lifecycle hooks on switch are a feature
|
|
8004
|
+
the book teaches, and switchers split evenly in the survey (Textual, Swing,
|
|
8005
|
+
Android, Qt, GTK on the flag; urwid, Flutter, SwiftUI, AppKit on detachment). A
|
|
8006
|
+
keep-alive pane, if wanted, would use GTK's split — a container-only
|
|
8007
|
+
`child-visible` beside the public `visible` — and is deliberately not this
|
|
8008
|
+
entry.
|
|
8009
|
+
|
|
8010
|
+
**Consequences.** The contract suite gains a fourth invariant — *a hidden
|
|
8011
|
+
component paints nothing, is not in the Tab cycle, and reappears where it was
|
|
8012
|
+
with its constraints* — over the catalog. AGENTS.md's "Hiding a component means
|
|
8013
|
+
detaching it" becomes "hiding is `visible = false`; `TabSheet` detaches for the
|
|
8014
|
+
lifecycle hooks", and book ch7's section of that name is rewritten; `Fixed`'s
|
|
8015
|
+
collapse note gains "a hidden child costs no gap". `visible?` is a second reason,
|
|
8016
|
+
beside the empty rect, for a component not to paint, and the two are different
|
|
8017
|
+
axes: geometry says *where* and *how much*, the flag says *whether*.
|
|
8018
|
+
|
|
8019
|
+
**Roads not taken.**
|
|
8020
|
+
|
|
8021
|
+
- **A `Hidden` / `Gone` `Box` constraint** (`constrain(field, Gone)`). Parent-side
|
|
8022
|
+
only, so it reclaims space and leaves every focus leak `D_tabs` listed — it is
|
|
8023
|
+
`Fixed[0]` with better branding, the exact thing `D_empty_ancestor` refused.
|
|
8024
|
+
- **An enum** (`:visible` / `:invisible` / `:gone`). The middle state is a `Slot`
|
|
8025
|
+
with no content, a hidden-but-space-keeping field has no form use, and an enum
|
|
8026
|
+
invites `:disabled` next.
|
|
8027
|
+
- **`visible=` as sugar over `remove` / `add(at:)`.** Fires lifecycle, loses the
|
|
8028
|
+
live-while-hidden case, and only `Box` could implement it — an `Absolute` has
|
|
8029
|
+
no `at:`.
|
|
8030
|
+
- **A public `shown?` reader** for the effective state. The walks prune at the
|
|
8031
|
+
hidden root and need none; a reader that walks ancestors is one more thing to
|
|
8032
|
+
keep un-cached. Add only when an app needs it.
|
|
8033
|
+
- **"Next tab stop" focus repair**, the Swing/Qt answer. Implementable (collect
|
|
8034
|
+
stops, pick the successor, before hiding) but then hiding and removing a
|
|
8035
|
+
focused subtree would land focus in different places; if it is ever wanted,
|
|
8036
|
+
change `on_child_removed`'s repair and this in one move so they stay one rule.
|
|
8037
|
+
- **A `hidden` / `hidden=` name** (HTML's attribute, AppKit's `isHidden`).
|
|
8038
|
+
Negative names make callers negate what they want.
|
|
8039
|
+
|
|
8040
|
+
## D_time_field — `TimeField`: a `Time` on a fixed epoch; `step` owns the precision (2026-09-05)
|
|
8041
|
+
|
|
8042
|
+
**Status:** Accepted and implemented 2026-09-05 — `Component::TimeField`,
|
|
8043
|
+
`Locale#time_formats`, `Locale::TimeFormats`, the `Locale::Formats` lexer hoist,
|
|
8044
|
+
a spec mirror, a `component_contract_spec` catalog entry, book ch7 + ch10 and a
|
|
8045
|
+
sampler pane (Input ▸ Typed ▸ TimeField). Graduated from `ideas/time-field.md`,
|
|
8046
|
+
now retired. Both verifications the design was pending held: Rails casts
|
|
8047
|
+
`"13:45"` to `2000-01-01 13:45:00 UTC`, so the epoch is *alignment* rather than
|
|
8048
|
+
invention; and Ruby's `strptime` accepts `1:45pm`, `1:45 PM`, `1:45PM` and
|
|
8049
|
+
`1:45 pm` alike under `%I:%M %p`, so the feared spaceless-secondary workaround
|
|
8050
|
+
was never needed.
|
|
8051
|
+
`D_date_field`'s twin — every ruling not questioned here is inherited from that
|
|
8052
|
+
entry verbatim: the format list consumed in order with `formats.first` writing
|
|
8053
|
+
back, no input filter, the red well latched to the commit gestures,
|
|
8054
|
+
canonicalize on blur and ENTER, the placeholder derived exactly or not at all,
|
|
8055
|
+
`MAX_TEXT_LENGTH = 64`, a frozen `bad_input_message`. Gives `D_locale` its ninth
|
|
8056
|
+
member. Two findings in this entry are marked **surveyed** — read off another
|
|
8057
|
+
toolkit's docs or source — as distinct from **verified**, which means run in this
|
|
8058
|
+
repo's Ruby.
|
|
8059
|
+
|
|
8060
|
+
**Context.** A one-row field whose value is a *local time of day* — a wall-clock
|
|
8061
|
+
reading with no date and no zone; `09:30` means half past nine wherever you are.
|
|
8062
|
+
It is the twin `D_date_field` predicted and `D_locale` explicitly left out
|
|
8063
|
+
("the time formats stay out, because their consumer is a `TimeField` nobody has
|
|
8064
|
+
filed"). Two things make it more than a copy: Ruby has no civil-time class, so
|
|
8065
|
+
the value type has to be *decided* where `DateField` could point at `Date`; and
|
|
8066
|
+
a time has a **precision** — seconds or not — which a date does not, and which
|
|
8067
|
+
turns out to be the one genuinely new ruling.
|
|
8068
|
+
|
|
8069
|
+
**Decision — the value is a `Time` pinned to `2000-01-01` UTC, so the component
|
|
8070
|
+
is `TimeField`.** Four candidates, priced by `D_float_field`'s naming rule (a
|
|
8071
|
+
typed field is named after the Ruby class of its value):
|
|
8072
|
+
|
|
8073
|
+
| Value | Component | Cost |
|
|
8074
|
+
|---|---|---|
|
|
8075
|
+
| `Time` on a fixed epoch date, in UTC | `TimeField` | the value is a real *instant*, so it can be passed where an instant is wanted |
|
|
8076
|
+
| `Tuile::LocalTime` (`Data.define`) | `LocalTimeField` | Tuile ships a domain value type; every app converts at its own boundary |
|
|
8077
|
+
| `Integer` seconds since midnight | — (`IntegerField` by the rule) | indistinguishable from a duration; the name is not derivable |
|
|
8078
|
+
| `String` | — | that is a `TextField`; no typed field at all |
|
|
8079
|
+
|
|
8080
|
+
`Time` wins on a sharpening of `D_date_field`'s "Tuile's job is to edit the
|
|
8081
|
+
app's values, not to introduce its own", which needed sharpening precisely
|
|
8082
|
+
because there is no stdlib type to defer to: **Tuile owns UI value types —
|
|
8083
|
+
`Point`, `Size`, `Rect`, `Fraction`, `Color`, `StyledString`, `Theme`,
|
|
8084
|
+
`Locale` — and no domain value types.** A time of day is domain data: it lands
|
|
8085
|
+
in a model, a column, a serializer. And the ecosystem's near-consensus is
|
|
8086
|
+
exactly this shape — a `Time` on a dummy date is what Rails' `time` column hands
|
|
8087
|
+
you. The epoch is `2000-01-01` **pending one verification** (Rails' date, from
|
|
8088
|
+
recollection; `gem install activerecord` and cast `"13:45"`); if it holds, an
|
|
8089
|
+
ActiveRecord round-trip is exact and the choice is *alignment with an existing
|
|
8090
|
+
convention*, the strongest version of the argument. `Sequel::SQLTime` is
|
|
8091
|
+
recalled to default to *today*, class-configurable, so no fixed epoch could match
|
|
8092
|
+
it and Rails is the one convention available. **UTC, not local:** a local epoch
|
|
8093
|
+
puts every value on a date whose offset the zone can change, so a transition on
|
|
8094
|
+
the epoch date makes some wall times unrepresentable or silently shifted — the
|
|
8095
|
+
whole DST class, removed. Cells still read correctly (**verified**:
|
|
8096
|
+
`Time.utc(2000,1,1,13,45,0).strftime("%H:%M") == "13:45"`).
|
|
8097
|
+
|
|
8098
|
+
The accepted cost, stated loudly in rdoc: the value is an instant, so it is
|
|
8099
|
+
*wrong* somewhere else while being a perfectly good `Time` —
|
|
8100
|
+
`field.value = Time.now; field.value == Time.now` is `false` (**verified**;
|
|
8101
|
+
different date, different zone). An app that forgets to combine it with a date
|
|
8102
|
+
gets the year 2000 in its output, which is *visible*, and by this project's
|
|
8103
|
+
standard beats silently wrong. `value=` is thin and lenient the way
|
|
8104
|
+
`DateField#value=` is: anything responding to `hour` / `min` / `sec` is taken by
|
|
8105
|
+
those readers and rebuilt on the epoch (`Time`, `DateTime`, `Sequel::SQLTime`);
|
|
8106
|
+
`nil` clears; a `Date` raises `TypeError` (no hour to take — accepting it means
|
|
8107
|
+
inventing midnight) and so does a `String` (that is what the buffer is for; a
|
|
8108
|
+
`value=` that parsed would be a second parse path with its own leniency);
|
|
8109
|
+
sub-seconds truncate silently. Three ergonomics, so no caller ever assembles a
|
|
8110
|
+
`Time` on the epoch by hand: `#set_to(hour, minute, second = 0)` and
|
|
8111
|
+
`#set_to_now` to *write* one, and `.time_of_day(hour, minute, second = 0)` to
|
|
8112
|
+
build a value to *compare* against, plus the epoch public as `MIDNIGHT`.
|
|
8113
|
+
|
|
8114
|
+
**The class method is deliberately not named for the class.** It shipped as
|
|
8115
|
+
`TimeField.at` and was renamed on review: every other class method on a
|
|
8116
|
+
component here (`ConfirmWindow.alert`, `Notification.show`) returns an
|
|
8117
|
+
*instance*, so `TimeField.at(13, 45)` reads as a constructor while returning a
|
|
8118
|
+
`Time` — the one thing a reader should not have to check. `time_of_day` says
|
|
8119
|
+
what comes back, and the wordiness is free because the rename also moved the
|
|
8120
|
+
common case off it: setting a field is now `field.set_to(13, 45)`, and the
|
|
8121
|
+
factory is left with the rare read-side job of building something to compare a
|
|
8122
|
+
value against. Deleting it outright was the other candidate and is worse: it
|
|
8123
|
+
pushes `Time.utc(2000, 1, 1, …)` — the hand-written epoch this exists to
|
|
8124
|
+
prevent — into every spec and every app that checks a value it got back.
|
|
8125
|
+
`#set_to_now` is sugar rather than a fix (an app writing `value = Time.now` is
|
|
8126
|
+
already corrected by the buffer round-trip), but it names a concept the field
|
|
8127
|
+
already had: it is where Up/Down land an empty field.
|
|
8128
|
+
|
|
8129
|
+
**Decision — the parse has a third gate, because `Time` normalizes where `Date`
|
|
8130
|
+
raised.** `D_date_field`'s parse is two gates — a non-empty `:leftover` is no
|
|
8131
|
+
match, and constructing the `Date` catches February 30th. The second does not
|
|
8132
|
+
transfer (**verified**): `Date._strptime("24:00", "%H:%M")` yields
|
|
8133
|
+
`{hour: 24, min: 0}` and `Time.utc(2000,1,1,24,0,0)` is *the next day*;
|
|
8134
|
+
`"13:45:60"` parses and `Time.utc(…,13,45,60)` is `13:46:00`. Both are
|
|
8135
|
+
wrong-values-that-save-cleanly, and the rollover is worse than it looks — a value
|
|
8136
|
+
past midnight lands on a *different date* from every other value the field
|
|
8137
|
+
produces, so comparison and sorting quietly break. So `TimeField` range-checks
|
|
8138
|
+
`hour` 0..23, `min` 0..59, `sec` 0..59 itself, `Time.utc` becomes construction
|
|
8139
|
+
rather than validation, and **`.time_of_day` and `#set_to` share the one gate** —
|
|
8140
|
+
building normalizes just as silently as parsing, and a spec comparing against
|
|
8141
|
+
`time_of_day(24, 0)` would pass against the wrong date. `Date._strptime` already
|
|
8142
|
+
range-checks the *field width* (`"25:00"` and `"13:99"` yield `nil`,
|
|
8143
|
+
**verified**) and gives padding leniency for free (`"1:45"` under `%H:%M`). `24:00`
|
|
8144
|
+
being rejected costs one documented sentence: it is legal ISO 8601 end-of-day
|
|
8145
|
+
and `Time` cannot hold it. No new `require`: the parse is `Date._strptime`
|
|
8146
|
+
(`date` is hoisted per `D_date_field`), `Time` is core, and `Time.strptime` — the
|
|
8147
|
+
one thing needing `require "time"` — is not used.
|
|
8148
|
+
|
|
8149
|
+
**Decision — minute precision by default; `step` is the precision knob; there is
|
|
8150
|
+
no per-field `formats=`.** The ruling that is new to this field:
|
|
8151
|
+
|
|
8152
|
+
> **Precision is not a spelling.** A format list is ordered leniency, and for
|
|
8153
|
+
> time the order also decides *how much of the value survives a commit*. Only the
|
|
8154
|
+
> widening direction is lossless: with `%H:%M:%S` primary, typing `13:45`
|
|
8155
|
+
> canonicalizes to `13:45:00` and adds nothing false. With `%H:%M` primary,
|
|
8156
|
+
> typing `13:45:30` canonicalizes to `13:45` and **loses the seconds** — the
|
|
8157
|
+
> buffer is the single source of truth, so truncating the buffer truncates the
|
|
8158
|
+
> value.
|
|
8159
|
+
|
|
8160
|
+
So the default shows minutes and `13:45:30` is bad input — visible, reportable,
|
|
8161
|
+
fixable — and an app that wants seconds sets `step` below a minute. The field
|
|
8162
|
+
has **one** precision-bearing knob and it is the stride: **`step < 60` shows
|
|
8163
|
+
seconds, `step >= 60` does not**, Vaadin's rule and HTML's, and the whole of it.
|
|
8164
|
+
`formats` is a **read-only report**, derived on every read from `Screen#locale`
|
|
8165
|
+
and `step`: it exists so the placeholder, the rdoc, the specs and a curious app
|
|
8166
|
+
can name the list in force, and for the reason `Component#size` exists — to
|
|
8167
|
+
squat the name, so a writer can only return through the re-grow rule below.
|
|
8168
|
+
The escape hatch is `screen.locale=`, exactly as Vaadin's is `setLocale()`: an
|
|
8169
|
+
app wanting a spelling the probe did not find assigns a `Locale` and every
|
|
8170
|
+
`TimeField` follows, which is what a *convention* is. No `precision` reader
|
|
8171
|
+
either — `formats` squats the name that matters, a `precision` reader invites
|
|
8172
|
+
"where is the writer?", and the API is one rdoc sentence.
|
|
8173
|
+
|
|
8174
|
+
The survey that settled the default (**surveyed** 2026-09-05):
|
|
8175
|
+
|
|
8176
|
+
| Toolkit | Default precision | Seconds | Knob |
|
|
8177
|
+
|---|---|---|---|
|
|
8178
|
+
| Vaadin `TimePicker` | `hh:mm` | yes | `step` (`Duration`), default 1 hour |
|
|
8179
|
+
| HTML `<input type="time">` | `hh:mm` | yes | `step`, default `60` |
|
|
8180
|
+
| MUI X `TimePicker` | hours + minutes (+ meridiem) | yes | `views:` array |
|
|
8181
|
+
| Qt `QTimeEdit` | locale **ShortFormat** (en_US `h:mm AP`) | yes | `displayFormat` string |
|
|
8182
|
+
| Flutter `showTimePicker` | hour + minute | **none** — `TimeOfDay` has no field for them | — |
|
|
8183
|
+
| Taiga UI `InputTime` | `mode` enum, `HH:MM` … `HH:MM:SS.MSS` | yes | `mode` |
|
|
8184
|
+
| WinForms `DateTimePicker` | OS **long time**, `h:mm:ss tt` | shipped | `CustomFormat`, to *remove* them |
|
|
8185
|
+
| Ant Design `TimePicker` | `HH:mm:ss` | shipped | `format` string |
|
|
8186
|
+
|
|
8187
|
+
Six of eight default to minutes. The useful half is *which two dissent, and
|
|
8188
|
+
why*: WinForms inherits the OS *long time* pattern and Ant Design simply picked
|
|
8189
|
+
a format — both a clock-display format used as a form-field format, putting
|
|
8190
|
+
`:00` in front of every user who never asked for it, which is the `t_fmt`
|
|
8191
|
+
failure below observed in the wild. And the split between the two knob shapes
|
|
8192
|
+
has a **cause**, not a style: precision is either a property of the format
|
|
8193
|
+
string (Qt, Ant Design, WinForms) or a selector orthogonal to spelling
|
|
8194
|
+
(Vaadin/HTML `step`, MUI `views`, Taiga `mode`), and **every toolkit that fuses
|
|
8195
|
+
precision into `step` is one where the app cannot write a format at all** —
|
|
8196
|
+
Vaadin's Java API has `setLocale()` and `setStep()` and no format setter
|
|
8197
|
+
(**surveyed**), HTML has no `format` attribute. Every toolkit exposing a format
|
|
8198
|
+
string leaves `step` alone; zero exceptions either way. So the fusion is not a
|
|
8199
|
+
school anyone joined; it falls out of *not having* a format writer — which is
|
|
8200
|
+
exactly why Tuile adopts it cleanly, by removing the writer so the precondition
|
|
8201
|
+
is *made true* rather than importing a workaround into an API that contradicts
|
|
8202
|
+
it.
|
|
8203
|
+
|
|
8204
|
+
**The costs, named so this is chosen and not inherited.** (1) **Seconds precision
|
|
8205
|
+
with a minute stride is unsayable** — a field holding `13:45:30` whose Up key
|
|
8206
|
+
walks to `13:46:30`; asking for the third segment hands you a one-second stride.
|
|
8207
|
+
Narrow, Vaadin lives without it, and additive to fix (a `precision:` whose nil
|
|
8208
|
+
means derived-from-step). (2) **No per-field spelling override**, so the twin is
|
|
8209
|
+
asymmetric: `DateField#formats=` is a writer. Defensible on the merits — a date
|
|
8210
|
+
has a genuine *per-field* axis (one form mixing `dd.mm.` and ISO for a technical
|
|
8211
|
+
value), a time has none; 12- versus 24-hour is a session convention, which is
|
|
8212
|
+
what `Locale` is for. (3) **Deliberate lossy leniency** (`["%H:%M", "%H:%M:%S"]`
|
|
8213
|
+
as an app's own list) is gone; it was a sanctioned edge, never a need. Two
|
|
8214
|
+
things are Tuile's and not Vaadin's: the **default is 60, not 3600** — Vaadin's
|
|
8215
|
+
hour is a dropdown-density number that leaks into the arrow keys, so Up from
|
|
8216
|
+
`13:45` lands on `14:45`, and there is no overlay at all (the dropdown decision
|
|
8217
|
+
below) — and there is **no divisor rule** (Vaadin requires a step to divide an hour or day); Tuile adds and
|
|
8218
|
+
wraps, and a constraint that exists for a picker's row grid is the picker's to
|
|
8219
|
+
add.
|
|
8220
|
+
|
|
8221
|
+
**Decision — the two derived lists are asymmetric, by the ruling above.** At
|
|
8222
|
+
`step < 60`, `formats` is the *full-precision forms, then the stripped forms*, so
|
|
8223
|
+
typing `13:45` parses through the lenient secondary and canonicalizes to
|
|
8224
|
+
`13:45:00` — the lossless direction, free. At `step >= 60` it is the stripped
|
|
8225
|
+
forms *only*: admitting `%H:%M:%S` there is the lossy direction, so `13:45:30`
|
|
8226
|
+
stays bad input. One rule, read from the primary. (The first draft's table
|
|
8227
|
+
showed only what each mode *writes*, which is how it hid that the seconds mode
|
|
8228
|
+
would have demanded `:00` from the user — the reverse of the annoyance the
|
|
8229
|
+
default exists to avoid.)
|
|
8230
|
+
|
|
8231
|
+
**Decision — the seconds strip is the field's, not the locale's.** glibc's
|
|
8232
|
+
`t_fmt` is a *clock display* format and carries seconds nearly everywhere, so
|
|
8233
|
+
honoring it puts `:00` in every form field in the world. The field therefore
|
|
8234
|
+
**keeps the locale's spelling and drops its precision** — separator, digit
|
|
8235
|
+
order and the 12/24-hour choice are conventions; the seconds are not. Qt arrives
|
|
8236
|
+
at the same place independently (`QDateTimeEdit` seeds itself with
|
|
8237
|
+
`loc.timeFormat(QLocale::ShortFormat)`, en_US `h:mm AP` where the long form is
|
|
8238
|
+
`h:mm:ss AP t`), and gets it *free* because CLDR ships short/medium/long time
|
|
8239
|
+
patterns as separate data; glibc ships one `t_fmt` with no short variant to ask
|
|
8240
|
+
for. So the strip is Tuile computing by hand the datum CLDR would have handed
|
|
8241
|
+
it. But it runs in **`TimeField`, not at the `Locale` boundary**, for two
|
|
8242
|
+
reasons — and the second stands on its own: the strip is *policy*, not
|
|
8243
|
+
normalization (`D_locale`'s boundary rule covers the `%T` → `%H:%M:%S` expansion
|
|
8244
|
+
and `first_weekday`'s renumbering, both representation changes; "default to
|
|
8245
|
+
minutes" is a form field's ruling, and `Locale` holds conventions, not UI
|
|
8246
|
+
defaults — its own stated gate); and it is **lossy** where those are not — a
|
|
8247
|
+
status-bar clock, a log timestamp, any consumer rendering a time *display*
|
|
8248
|
+
legitimately wants `t_fmt` with its seconds, and a boundary strip makes that
|
|
8249
|
+
unrecoverable, so they would hardcode a separator and take the same regression
|
|
8250
|
+
one layer down with no knob to fix it. So `Locale#time_formats` carries the
|
|
8251
|
+
locale's spelling at **full detected precision** (fi_FI
|
|
8252
|
+
`["%H.%M.%S", "%H:%M:%S"]`), `Locale::ISO` is `["%H:%M:%S"]` (ISO 8601 permits
|
|
8253
|
+
both; the member holds the fuller one and the field's default reduces it), the
|
|
8254
|
+
expansion table (`%T` → `%H:%M:%S`, `%R` → `%H:%M`, `%r` → `%I:%M:%S %p`) stays
|
|
8255
|
+
at the boundary, and the field strips. `t_fmt_ampm` is **not** read: en_GB's is
|
|
8256
|
+
`%l:%M:%S %P %Z`, a zone name and a blank-padded hour, two directives this
|
|
8257
|
+
field rejects. The strip rule — *drop `%S` and the literal run immediately
|
|
8258
|
+
preceding it*; a format with no `%S` passes through as already-minute; `step <
|
|
8259
|
+
60` over a locale with no seconds falls back to ISO `%H:%M:%S` rather than
|
|
8260
|
+
splicing a separator it would have to invent — is deliberately the smallest
|
|
8261
|
+
grammar surgery that works, and `Locale` places **no `%S` requirement** on an
|
|
8262
|
+
entry, since the fallback already covers the case a raise would cover.
|
|
8263
|
+
|
|
8264
|
+
**Decision — stepping adds and wraps; `step=` at runtime is the locale-change
|
|
8265
|
+
path.** Up/Down add `step` seconds modulo 24 h (`23:59` + 1 → `00:00`; a clock
|
|
8266
|
+
has no day to carry into, so wrapping is arithmetic, not policy). An empty or
|
|
8267
|
+
unparseable field steps to **now**, truncated to the field's precision — the
|
|
8268
|
+
`Date.today` analogue, landing *on* now rather than now ± 1; that this clobbers a
|
|
8269
|
+
half-typed `13:4` is `D_date_field`'s ruling inherited knowingly (an arrow
|
|
8270
|
+
mid-typo is asking for help; a no-op is a dead key) and recorded here as a
|
|
8271
|
+
**shared** ruling — change it in both fields in one commit or the divergence
|
|
8272
|
+
reads as a bug. **Add, never snap:** `step = 900` from `13:07` goes to `13:22`,
|
|
8273
|
+
not the `13:15` grid line — snapping silently moves a value the user did not
|
|
8274
|
+
ask to change; Vaadin adds too ("accepts values that don't align with the
|
|
8275
|
+
specified step"), and HTML's snap is to a `min` base this field lacks. `step=`
|
|
8276
|
+
takes a positive `Integer` in `1...86400` and raises `ArgumentError` otherwise:
|
|
8277
|
+
no `nil` (nil-means-inherit is for locale-derived knobs; the default is set in
|
|
8278
|
+
`initialize`), no `Float`/`Rational` (a sub-second stride implies `%L`, rejected
|
|
8279
|
+
by name), nothing that wraps onto itself. And **`step=` with a value present is
|
|
8280
|
+
"re-derive, then run the locale-change path"**, not a second code path — the
|
|
8281
|
+
rule `on_locale_changed` already has, *re-canonicalize a buffer that still
|
|
8282
|
+
parses, leave one that does not*: widening `60 → 1` rewrites `13:45` to
|
|
8283
|
+
`13:45:00`; narrowing `1 → 60` leaves `13:45:30`, which becomes bad input
|
|
8284
|
+
(`value` reads `nil`, `on_value_change` fires, `bad_input?` says why). Nothing is
|
|
8285
|
+
silent on any channel, and the field never truncates a value it did not type —
|
|
8286
|
+
the *app* narrowed it and hears about it through the seam it already listens on.
|
|
8287
|
+
PageUp/PageDown step an hour whatever the stride — the dropdown decision below
|
|
8288
|
+
says why that is a key and not an overlay; `D_date_field` still defers the month.
|
|
8289
|
+
|
|
8290
|
+
**Refined during implementation: a narrowing step *carries* a value the new
|
|
8291
|
+
primary can still write exactly.** `13:45:00` narrowed to minutes shows `13:45`
|
|
8292
|
+
rather than reddening, because dropping a *zero* second discards nothing — the
|
|
8293
|
+
no-truncation rule is about not losing what the user meant, and there is nothing
|
|
8294
|
+
there to lose. The test is the round-trip the validator already uses
|
|
8295
|
+
(`parse(value.strftime(new_primary), new_primary) == value`), so it needs no new
|
|
8296
|
+
concept; `13:45:30` fails it and is left as typed, exactly as ruled above. Only
|
|
8297
|
+
`step=` can do this, since it can read the outgoing value before switching;
|
|
8298
|
+
`on_locale_changed` fires *after* the conventions have changed and has no such
|
|
8299
|
+
handle, which is the same asymmetry `D_date_field` lives with. Without it, an
|
|
8300
|
+
app toggling precision for display reasons would redden a field whose value was
|
|
8301
|
+
never in doubt — an error state with nothing wrong, which is the failure mode
|
|
8302
|
+
this project treats as worse than a visible one.
|
|
8303
|
+
|
|
8304
|
+
**Decision — the round-trip reference is `Time.utc(2000, 1, 1, 13, 45, 0)`, it
|
|
8305
|
+
validates on `Locale` only, and zone and sub-second directives are rejected by
|
|
8306
|
+
name.** Every property of the canary is load-bearing (all **verified**): *hour ≥
|
|
8307
|
+
13*, so a 12-hour directive with no `%p` fails — `%I:%M` writes `"01:45"` and
|
|
8308
|
+
reads back 1 o'clock, the exact analogue of `%y` not carrying a century, while
|
|
8309
|
+
`%I:%M %p`, `%I.%M %p` and `%I:%M%P` pass; *minute ≠ hour*, so an `%H`/`%M`
|
|
8310
|
+
swap is not masked; *second = 0*, so a minute-precision primary is legal, which
|
|
8311
|
+
the default is. What it deliberately does not catch is a **precision
|
|
8312
|
+
truncation** — `%H:%M` round-trips itself perfectly, and precision is `step`'s
|
|
8313
|
+
business. Pass: `%H:%M`, `%H:%M:%S`, `%T`, `%R`, `%r`, `%H.%M`, `%Hh%M`, `%H%M`,
|
|
8314
|
+
`%k:%M`, `%I:%M %p`. Rejected: `%I:%M`, `%I:%M:%S`, `%l:%M`, `%p`, `%H`, `%M:%S`,
|
|
8315
|
+
`%s`, any `%-H`. Two new by-name rejections, because both **round-trip cleanly
|
|
8316
|
+
and lose information anyway** (**verified**): **zone directives** `%z` `%Z` `%:z`
|
|
8317
|
+
`%::z` `%s` — `"%H:%M:%S%z"` writes `+0000` and reads it back, and
|
|
8318
|
+
`Date._strptime("13:45:00+0200", …)` hands back `zone: "+0200", offset: 7200`
|
|
8319
|
+
which this field would drop on the floor, so a user typing an offset would see it
|
|
8320
|
+
silently reinterpreted as a wall time; and **sub-second directives** `%L` `%N` —
|
|
8321
|
+
`sec_fraction: (1/2)` parsed and dropped. `%x` / `%X` / `%c` stay rejected as
|
|
8322
|
+
locale lookalikes. With no per-field writer the validator has exactly two call
|
|
8323
|
+
sites, both on `Locale`: the `time_formats:` assignment (an author to tell — it
|
|
8324
|
+
raises) and the `t_fmt` probe (nobody to tell — a failing format is dropped and
|
|
8325
|
+
the list falls back to ISO), the same asymmetry as `date_formats`. One known
|
|
8326
|
+
limitation written down rather than fixed: **Ruby's `%p` is fixed English**;
|
|
8327
|
+
under a 12-hour locale whose `am_pm` is not `AM;PM` the field writes English,
|
|
8328
|
+
because implementing `%p` from `Locale#am_pm` would mean owning a second
|
|
8329
|
+
formatting grammar, which `D_date_field`'s "strftime, not Java patterns" refuses.
|
|
8330
|
+
Whether strptime accepts `1:45pm` / `1:45PM` under `%I:%M %p` (the literal space
|
|
8331
|
+
and the case of `%p`) is **to verify before building**, and ruled either way: if
|
|
8332
|
+
strict, accept the cost rather than derive a spaceless secondary — that is
|
|
8333
|
+
grammar surgery on the locale's pattern, and the ISO `%H:%M` secondary means
|
|
8334
|
+
`13:45` always works.
|
|
8335
|
+
|
|
8336
|
+
**Decision — `TimeFormats::HINTS` is its own table.** `%H`/`%I`/`%k`/`%l` →
|
|
8337
|
+
`hh`, `%M` → `mm`, `%S` → `ss`, `%p` → `AM`, `%P` → `am`, `%%` → `%`. Kept apart
|
|
8338
|
+
from `DateFormats::HINTS` because `%m` → `mm` (month) and `%M` → `mm` (minute) are
|
|
8339
|
+
both right in their own table, and a combined one is a question only a
|
|
8340
|
+
`DateTimeField` has to ask. `%p` is admissible where `%b` was not: `mmm` would be
|
|
8341
|
+
an invented token, while `AM` is literally what the field prints and a
|
|
8342
|
+
placeholder is a typing *sample* — so en_US's derived hint is `hh:mm AM` and
|
|
8343
|
+
fi_FI's `hh.mm`. `%k` and `%l` are in because both pass the round-trip and a
|
|
8344
|
+
directive that passes but has no hint makes the placeholder "not at all" for no
|
|
8345
|
+
reason.
|
|
8346
|
+
|
|
8347
|
+
**Decision — no dropdown; PageUp/PageDown is the hour jump; segment stepping is
|
|
8348
|
+
phase 2.** Vaadin's `TimePicker` drops open a list of times spaced by `step`, and
|
|
8349
|
+
`ideas/new-components.md` carried that as this field's cheap phase 2. It is
|
|
8350
|
+
rejected, on `D_mouse`'s rule and two facts of its own. **A list of times computes
|
|
8351
|
+
nothing:** the calendar grid `DateField` will grow answers questions the user
|
|
8352
|
+
cannot (which weekday is the 17th, does this month have a 31st), while every row
|
|
8353
|
+
of a time list is derivable from its neighbour — it is a list of the thing the
|
|
8354
|
+
user was about to type, and typing `1345` beats scrolling to it. And **Vaadin's
|
|
8355
|
+
dropdown is an implementation inheritance, not a UX finding** —
|
|
8356
|
+
`vaadin-time-picker` wraps `vaadin-combo-box-light`, and it hides the list below
|
|
8357
|
+
a 15-minute step (**surveyed**, from recollection of `__generateDropdownList`;
|
|
8358
|
+
re-check before citing it further), so at this field's default stride a faithful
|
|
8359
|
+
port shows nothing. Building it would also re-import the coupling the default of
|
|
8360
|
+
60 was chosen to escape: a useful list needs an hour-ish `step`, which coarsens
|
|
8361
|
+
the arrows, and decoupling them is a second density knob beside the one knob this
|
|
8362
|
+
entry is built on. Last, there is no open gesture: the editor eats printables
|
|
8363
|
+
(Space is legal in `1:45 PM`), the arrows already mean step, and open-on-focus
|
|
8364
|
+
makes a Tab pass through a form flash overlays.
|
|
8365
|
+
|
|
8366
|
+
What the keyboard actually lacked was the *hour jump* — 60 Ups from `13:45` to
|
|
8367
|
+
`14:45` — and that is a key, not an overlay: **PageUp/PageDown step an hour,
|
|
8368
|
+
whatever `step` is**, wrapping like the arrows and landing on now from an empty
|
|
8369
|
+
field like the arrows. The editor declines both, so the field claims them in its
|
|
8370
|
+
own `handle_key` (rung 3, one hop up the bubble) — no new `TextField` slot, and
|
|
8371
|
+
no printable, so a form's letter bindings keep working. The keyboard lineage of
|
|
8372
|
+
every toolkit built for hands-on-keys is **segmented stepping** — Qt's
|
|
8373
|
+
`QTimeEdit`, WinForms' `DateTimePicker`, HTML's `<input type=time>`, and the one
|
|
8374
|
+
TUI ancestor with a time widget, `dialog --timebox`: Up in the hour segment steps
|
|
8375
|
+
an hour, in the minute segment a minute, on `%p` toggles meridiem. That is the
|
|
8376
|
+
honest phase 2 — buildable, since the `Locale::Formats` lexer already yields a
|
|
8377
|
+
caret → directive map, and shared with `DateField` — deferred until PageUp/
|
|
8378
|
+
PageDown proves insufficient, because it changes what `step` means where the
|
|
8379
|
+
caret sits. **Re-grow rule for the dropdown:** only as a mouse affordance, only
|
|
8380
|
+
if a mouse-driven use actually appears, in `Select`'s shape with Vaadin's density
|
|
8381
|
+
gate (`SECONDS_PER_DAY / step <= 96`, so it exists only where the app has already
|
|
8382
|
+
coarsened `step` for its own reasons) — never with a density knob of its own.
|
|
8383
|
+
|
|
8384
|
+
**Rejected alternatives.**
|
|
8385
|
+
|
|
8386
|
+
- **A Vaadin-style dropdown of times by `step`** — computes nothing, empty at
|
|
8387
|
+
the default stride by Vaadin's own gate, and re-couples list density to the
|
|
8388
|
+
arrow stride; the decision above.
|
|
8389
|
+
|
|
8390
|
+
- **`Tuile::LocalTime` as the value** (a `LocalTimeField`). It makes the wrong
|
|
8391
|
+
state unrepresentable — a `NoMethodError` beats the year 2000 — and loses on
|
|
8392
|
+
the UI-not-domain rule plus a cost the table understates: owning a value type
|
|
8393
|
+
means owning `Comparable`, arithmetic, `to_s`, `inspect`, RBS and a spec file,
|
|
8394
|
+
in a toolkit whose job is editing. It buys nothing for the composite either
|
|
8395
|
+
(`DateTimeField` combining a `Date` with either representation is one line).
|
|
8396
|
+
**Re-argue if** an app is observed passing a `TimeField#value` somewhere it
|
|
8397
|
+
means an instant — that is the failure this trades away. As a later
|
|
8398
|
+
*supplement* it stays open and cheap, because the naming rule lets the two
|
|
8399
|
+
coexist with no mode flag: `TimeField#value` is a `Time`,
|
|
8400
|
+
`LocalTimeField#value` a `Tuile::LocalTime`, and an app picks by its model's
|
|
8401
|
+
type. It would be the third copy of this shell — `D_float_field`'s re-argue
|
|
8402
|
+
point — and the honest answer will probably still be "duplicate". It must
|
|
8403
|
+
**not** arrive as a `value_type:` knob on `TimeField`: that is the injected
|
|
8404
|
+
converter `D_integer_field` refused, and it makes `value`'s class un-derivable
|
|
8405
|
+
from the component's name.
|
|
8406
|
+
- **`Integer` seconds since midnight.** Indistinguishable from a duration, and
|
|
8407
|
+
`IntegerField` by the naming rule.
|
|
8408
|
+
- **A per-field `formats=` writer beside `step`** — the two-knob shape this
|
|
8409
|
+
entry held for one day, and MUI's shipped design (`views` plus `format`, with
|
|
8410
|
+
`format` documented as *"Defaults to localized format based on the used
|
|
8411
|
+
`views`"*, **surveyed**). It is the right shape **if** a writer has to exist,
|
|
8412
|
+
and the interaction rule is settled should one return (**re-grow**):
|
|
8413
|
+
`precision` becomes a stored selector (`:minutes` / `:seconds`, nil meaning
|
|
8414
|
+
derived from `step`) and `formats=` the override; precision is a *property of*
|
|
8415
|
+
`formats.first`, so there is one authority and which one depends on whether
|
|
8416
|
+
the derivation ran — `formats` nil → `precision` selects the detected form,
|
|
8417
|
+
`formats` set → `precision` reads back *derived* from `formats.first`;
|
|
8418
|
+
disagreeing explicit values **raise at assignment**, either order, naming both
|
|
8419
|
+
(letting `precision` win silently rewrites a pattern the author wrote, the
|
|
8420
|
+
zone directives' class; letting `formats` win makes `precision` a silent
|
|
8421
|
+
no-op; there is an author to tell — `D_locale`'s asymmetry, and `bg_color=`'s
|
|
8422
|
+
eager `KeyError` is the shape); the check keys on `formats.first` alone, so a
|
|
8423
|
+
lossy-leniency list stays legal beside `precision = :minutes`; and
|
|
8424
|
+
`precision` is stored, never eagerly resolved into a list, or it snapshots the
|
|
8425
|
+
session locale. Not v1 because v1 has no writer to reconcile against and
|
|
8426
|
+
reaches MUI's outcome — the spelling survives the precision switch — with one
|
|
8427
|
+
knob. Qt is the other reading of the same table and the one declined: a
|
|
8428
|
+
format writer *and* a locale default, with the spelling gap that implies, and
|
|
8429
|
+
no fix.
|
|
8430
|
+
- **A fused `step` *with* `formats=`** — importing Vaadin's workaround into an
|
|
8431
|
+
API that has the thing it works around. Two writers for one fact; needed the
|
|
8432
|
+
raise above to hold together.
|
|
8433
|
+
- **Stripping seconds at the `Locale` boundary** — lossy for every non-field
|
|
8434
|
+
consumer; see the strip decision.
|
|
8435
|
+
- **Honoring `t_fmt`'s precision** — `:00` in every form field in the world;
|
|
8436
|
+
WinForms and Ant Design are what that looks like shipped.
|
|
8437
|
+
- **Reading `t_fmt_ampm`** — carries `%Z` and `%l`, two rejected directives.
|
|
8438
|
+
- **Snapping a step to the grid** — silently moves the user's value.
|
|
8439
|
+
- **A `precision` reader** — squats a second name for one fact and invites the
|
|
8440
|
+
writer question; the API is one sentence.
|
|
8441
|
+
- **Splicing a separator to add `%S` to a seconds-less locale format** — grammar
|
|
8442
|
+
surgery the strip rule is kept deliberately minimal about; ISO is the fallback.
|
|
8443
|
+
|
|
8444
|
+
**Consequences.**
|
|
8445
|
+
|
|
8446
|
+
- **`Locale` grows `time_formats`**, its ninth member, passing its own gate
|
|
8447
|
+
(how a value is rendered and parsed; no prose). `KEYWORDS` gains `t_fmt`;
|
|
8448
|
+
`Locale.system`'s `LC_TIME` gate already covers it. Detected list is
|
|
8449
|
+
`[t_fmt expanded, "%H:%M:%S"].uniq` — the `[widened, raw, ISO]` shape of
|
|
8450
|
+
`date_formats_from` with no widening step. Specs drive it through
|
|
8451
|
+
`Locale.from_keywords`; `FakeScreen` keeps pinning `Locale::ISO`.
|
|
8452
|
+
- **The strftime lexer is hoisted** out of `Locale::DateFormats` into a
|
|
8453
|
+
`Locale::Formats` module — `DIRECTIVE`, `each_directive`, `LOCALE_LOOKALIKES` —
|
|
8454
|
+
with `DateFormats` and `TimeFormats` as two validators over it. Two copies of
|
|
8455
|
+
`DIRECTIVE` drifting apart is a silent bug in both, and "duplicate rather than
|
|
8456
|
+
DRY a shallow shell" (`D_float_field`) is about component shells, not a lexer.
|
|
8457
|
+
Moving three constants is one **Breaking** CHANGELOG line in a pre-1.0 gem;
|
|
8458
|
+
taking it now is cheaper than a third format kind later, and it is its own
|
|
8459
|
+
commit — a refactor bundled into the feature is the one split the git rules
|
|
8460
|
+
ask for.
|
|
8461
|
+
- **The field is the second copy of the date-field shell** exactly as
|
|
8462
|
+
`FloatField` is the second of the numeric one; duplicate, re-argue at the
|
|
8463
|
+
fourth.
|
|
8464
|
+
- **The picker phase 2 is segment-aware Up/Down, not a dropdown** (decision
|
|
8465
|
+
above); it is `DateField`'s phase 2 as well, and the calendar grid stays that
|
|
8466
|
+
field's own, because a calendar carries information a list of times does not.
|
|
8467
|
+
- **`DateTimeField` over a `DateField` plus a `TimeField`** is what
|
|
8468
|
+
`ideas/composite-field.md` was filed for, still blocked on which component
|
|
8469
|
+
wears a *combination* error.
|
|
8470
|
+
- **The sampler pane supplies its own locale** — a canned fi_FI-shaped `Locale`
|
|
8471
|
+
via `Locale.from_keywords`, `step: 60` and `step: 1` side by side — because the
|
|
8472
|
+
point is that a non-colon spelling survives the switch, and on an en_US box
|
|
8473
|
+
the host locale demonstrates nothing.
|
|
8474
|
+
- `D_date_field` owes a pointer here; `D_locale`'s "the time formats stay out"
|
|
8475
|
+
sentence resolves to this entry.
|
|
8476
|
+
|
|
8477
|
+
## D_mouse — Keyboard first: the mouse is additive, ranked by activity (2026-09-05)
|
|
8478
|
+
|
|
8479
|
+
**Status:** Accepted 2026-09-05. Decided while ruling out the `TimeField`
|
|
8480
|
+
dropdown (`D_time_field`), which is its worked rejection; nothing implemented,
|
|
8481
|
+
because the entry mostly names what already holds. `virtui` — visualization-heavy,
|
|
8482
|
+
the one downstream app — is what keeps the mouse in scope at all.
|
|
8483
|
+
|
|
8484
|
+
**Context.** Tuile parses the mouse (`MouseEvent`: buttons and the wheel), routes
|
|
8485
|
+
a click down the tree (`Component#handle_mouse`), hit-tests it (`extent_rect`),
|
|
8486
|
+
focuses on click and dismisses overlays on an outside click. Every one of those
|
|
8487
|
+
arrived because a keyboard-motivated component needed it, and none was argued for
|
|
8488
|
+
on its own. The question kept coming back per feature — should a `TimeField` have
|
|
8489
|
+
a dropdown so a mouse user can pick? should hover paint an accent? — with no
|
|
8490
|
+
ruling to point at, so each was re-argued from scratch. This entry is the ruling.
|
|
8491
|
+
|
|
8492
|
+
**Decision — the ranking, by activity.** Mouse is **better** than keyboard for
|
|
8493
|
+
window-moving and dragging; **at least equal** for scrolling; **equal** for focus;
|
|
8494
|
+
a **distant second** for value entry. Rungs, not a blanket: "the mouse is a
|
|
8495
|
+
distant second" is true of *value entry* and false of the wheel, and a rule
|
|
8496
|
+
stated per activity is what stops either reading from being generalized. Focus is
|
|
8497
|
+
"equal" as complementary rather than interchangeable — Tab is sequential (next
|
|
8498
|
+
stop, hands stay put), a click is random access (any widget, one gesture, cost
|
|
8499
|
+
growing with distance rather than stop count); neither dominates, so neither
|
|
8500
|
+
drives design.
|
|
8501
|
+
|
|
8502
|
+
**Decision — the operative rule: every capability is reachable from the keyboard;
|
|
8503
|
+
the mouse is additive.** No capability exists *only* through the mouse, and no
|
|
8504
|
+
component, overlay or knob is built on a mouse argument alone. Applied per rung:
|
|
8505
|
+
|
|
8506
|
+
- **Where the mouse is better or at least equal (dragging, scrolling)** it may get
|
|
8507
|
+
features of its own — the wheel on the scrollers, someday window-moving —
|
|
8508
|
+
provided the keyboard already does the job (PageDown; a move-window key). The
|
|
8509
|
+
mouse gets to be *nicer*, never *necessary*.
|
|
8510
|
+
- **Where it is equal (focus)** both exist and neither shapes design;
|
|
8511
|
+
click-to-focus ungated by geometry is already that.
|
|
8512
|
+
- **Where it is a distant second (value entry)** it gets exactly what
|
|
8513
|
+
`Component#handle_mouse`'s routing hands over for free: a `super`-then-act
|
|
8514
|
+
hit-test on a widget that exists for keyboard reasons. A `Checkbox` toggles on
|
|
8515
|
+
click, a `List` row selects, a `Select` row chooses, a `Button` fires — all
|
|
8516
|
+
shipped, all one line, all staying. What is refused is a feature whose only
|
|
8517
|
+
argument is "so a mouse user can…": the `TimeField` dropdown is the worked case.
|
|
8518
|
+
|
|
8519
|
+
**Rejected alternatives.**
|
|
8520
|
+
|
|
8521
|
+
- **The mouse as an equal peer** — every field gets a picker, every enumeration a
|
|
8522
|
+
clickable face. That is a GUI toolkit's rule, and it produces Vaadin's
|
|
8523
|
+
`TimePicker`: a dropdown that exists because the widget is a `ComboBox` skin,
|
|
8524
|
+
hidden below a 15-minute step because it stops being useful. A TUI user's hands
|
|
8525
|
+
are on the keys; typing `1345` beats scrolling to it.
|
|
8526
|
+
- **A blanket "no mouse value entry"** — the first phrasing, and literally false
|
|
8527
|
+
of shipped code (the four click handlers above). Deleting them would be
|
|
8528
|
+
gratuitous; the rule is about what *motivates* a feature, not what the mouse may
|
|
8529
|
+
touch.
|
|
8530
|
+
- **A `mouse:` knob per component, or a `Screen#mouse = false`** — a setting for
|
|
8531
|
+
something nobody has asked to turn off; the ranking is a design priority, not a
|
|
8532
|
+
runtime mode.
|
|
8533
|
+
- **Mouse-only features "for virtui"** — the app is the reason the mouse is in
|
|
8534
|
+
scope, not a licence to build widgets for it. The framework owes virtui the
|
|
8535
|
+
plumbing (events, routing, extents, the wheel); a mouse-heavy visualization is
|
|
8536
|
+
the app's own component, built on that plumbing.
|
|
8537
|
+
|
|
8538
|
+
**Consequences.**
|
|
8539
|
+
|
|
8540
|
+
- **`D_select` is untouched, and says so where the question will be asked.**
|
|
8541
|
+
Select's dropdown is *enumeration* — options the user cannot type without
|
|
8542
|
+
seeing, the same information test the calendar grid passes and a list of times
|
|
8543
|
+
fails — and its no-printable claim is a keyboard argument. Nothing here demotes
|
|
8544
|
+
Select relative to `ComboBox`; the enum-vs-data criterion stands.
|
|
8545
|
+
- **`ideas/hover.md` is compatible by construction**, not by exemption: its own
|
|
8546
|
+
framing is "opt-in and never load-bearing" — hover adds ink, not a capability,
|
|
8547
|
+
so a keyboard user loses nothing. It stays paused on its own merits.
|
|
8548
|
+
- **The wheel on the scrollers is sanctioned and unscheduled.** `MouseEvent`
|
|
8549
|
+
already parses `:scroll_up` / `:scroll_down`; no `List`, `TextView`, `TextArea`
|
|
8550
|
+
or `ListDropdown` consumes them. It is the one gap the ranking exposes, and it
|
|
8551
|
+
arrives as a small feature whenever someone wants it — PageUp/PageDown already
|
|
8552
|
+
do the job.
|
|
8553
|
+
- **Window-moving needs its own `D_` when it comes:** motion and release events
|
|
8554
|
+
(modes 1002/1006 — Tuile runs X10 mode 1000, press only; the Split Layout
|
|
8555
|
+
blocker in `ideas/new-components.md`, and what `ideas/hover.md` step 1
|
|
8556
|
+
designs), plus the keyboard equivalent the rule above requires *first*.
|
|
8557
|
+
- **Segment-aware Up/Down** (`D_time_field`'s phase 2) is the keyboard's picker,
|
|
8558
|
+
and the shape any future "picker" for a typed field takes before an overlay is
|
|
8559
|
+
considered.
|
|
8560
|
+
- AGENTS.md carries the one-line invariant — no capability reachable only
|
|
8561
|
+
through the mouse — beside the `handle_mouse` routing rule, since a new
|
|
8562
|
+
component can break it from its own file.
|