tuile 0.15.0 → 0.17.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 +229 -80
- data/README.md +49 -24
- data/book/02-repaint.md +47 -19
- data/book/03-layout.md +98 -49
- data/book/04-event-loop.md +17 -16
- data/book/05-focus.md +106 -34
- data/book/06-theming.md +108 -38
- data/book/07-components.md +249 -46
- data/book/08-testing.md +134 -32
- data/book/10-locale.md +3 -3
- data/book/README.md +11 -10
- data/examples/file_commander.rb +52 -32
- data/examples/hello_world.rb +18 -5
- data/examples/sampler.rb +576 -146
- data/lib/tuile/buffer.rb +12 -1
- data/lib/tuile/canvas/backend.rb +46 -0
- data/lib/tuile/canvas.rb +212 -0
- data/lib/tuile/color.rb +38 -9
- data/lib/tuile/component/abstract_string_field.rb +96 -97
- data/lib/tuile/component/abstract_wrapping_field.rb +99 -58
- data/lib/tuile/component/big_decimal_field.rb +7 -6
- data/lib/tuile/component/button.rb +27 -19
- data/lib/tuile/component/checkbox.rb +21 -19
- data/lib/tuile/component/checkbox_group.rb +17 -18
- data/lib/tuile/component/combo_box.rb +69 -64
- data/lib/tuile/component/confirm_window.rb +34 -27
- data/lib/tuile/component/date_field.rb +50 -18
- data/lib/tuile/component/date_time_field.rb +319 -0
- data/lib/tuile/component/fill.rb +93 -0
- data/lib/tuile/component/float_field.rb +7 -6
- data/lib/tuile/component/form_item.rb +250 -0
- data/lib/tuile/component/form_layout.rb +206 -0
- data/lib/tuile/component/has_bad_input.rb +99 -28
- data/lib/tuile/component/has_caption.rb +14 -5
- data/lib/tuile/component/has_content.rb +8 -15
- data/lib/tuile/component/has_placeholder.rb +1 -1
- data/lib/tuile/component/has_validation.rb +40 -14
- data/lib/tuile/component/has_value.rb +71 -17
- data/lib/tuile/component/integer_field.rb +7 -6
- data/lib/tuile/component/label.rb +8 -15
- data/lib/tuile/component/layout/absolute.rb +86 -0
- data/lib/tuile/component/layout/box.rb +38 -60
- data/lib/tuile/component/layout.rb +127 -13
- data/lib/tuile/component/list.rb +233 -120
- data/lib/tuile/component/list_dropdown.rb +151 -91
- data/lib/tuile/component/menu_bar/cascade.rb +102 -32
- data/lib/tuile/component/menu_bar.rb +102 -82
- data/lib/tuile/component/notification.rb +76 -49
- data/lib/tuile/component/overlay.rb +217 -58
- data/lib/tuile/component/password_field.rb +1 -8
- data/lib/tuile/component/picker_window.rb +41 -17
- data/lib/tuile/component/popup.rb +15 -26
- data/lib/tuile/component/progress_bar.rb +17 -11
- data/lib/tuile/component/radio_group.rb +16 -17
- data/lib/tuile/component/scroller.rb +266 -0
- data/lib/tuile/component/select.rb +26 -43
- data/lib/tuile/component/slot.rb +4 -5
- data/lib/tuile/component/tab_sheet.rb +27 -34
- data/lib/tuile/component/tabs.rb +49 -34
- data/lib/tuile/component/text_area/wrapped_text.rb +1 -1
- data/lib/tuile/component/text_area.rb +32 -28
- data/lib/tuile/component/text_field.rb +68 -50
- data/lib/tuile/component/text_view.rb +157 -89
- data/lib/tuile/component/time_field.rb +51 -21
- data/lib/tuile/component/vertical_scroll_bar.rb +257 -0
- data/lib/tuile/component/window.rb +27 -26
- data/lib/tuile/component.rb +653 -323
- data/lib/tuile/component_background.rb +177 -0
- data/lib/tuile/component_util.rb +43 -0
- data/lib/tuile/event.rb +29 -0
- data/lib/tuile/event_queue.rb +18 -4
- data/lib/tuile/fake_event_queue.rb +1 -1
- data/lib/tuile/fake_screen.rb +120 -7
- data/lib/tuile/keys.rb +15 -6
- data/lib/tuile/layout_pass.rb +180 -0
- data/lib/tuile/listeners.rb +219 -0
- data/lib/tuile/mouse/router.rb +233 -0
- data/lib/tuile/mouse.rb +244 -0
- data/lib/tuile/point.rb +6 -0
- data/lib/tuile/rect.rb +33 -0
- data/lib/tuile/screen.rb +510 -138
- data/lib/tuile/screen_pane.rb +185 -67
- data/lib/tuile/strict_layout.rb +127 -0
- data/lib/tuile/styled_string.rb +144 -14
- data/lib/tuile/testing/gestures.rb +35 -0
- data/lib/tuile/testing.rb +316 -42
- data/lib/tuile/theme.rb +192 -53
- data/lib/tuile/theme_def.rb +4 -0
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +53 -0
- data/sig/tuile.rbs +6084 -1507
- metadata +19 -17
- data/COMPARISON.md +0 -101
- data/DECISIONS.md +0 -8562
- data/TERMINOLOGY.md +0 -85
- data/ideas/arrow-key-navigation.md +0 -221
- data/ideas/binder.md +0 -177
- data/ideas/composite-field.md +0 -77
- data/ideas/focus-accent.md +0 -116
- data/ideas/form-layout.md +0 -151
- data/ideas/hover/probe.rb +0 -241
- data/ideas/hover/probe_spec.rb +0 -82
- data/ideas/hover.md +0 -909
- data/ideas/modal-backdrop.md +0 -24
- data/ideas/new-components.md +0 -144
- data/ideas/per-component-buffers.md +0 -55
- data/lib/tuile/mouse_event.rb +0 -68
- data/lib/tuile/vertical_scroll_bar.rb +0 -122
data/TERMINOLOGY.md
DELETED
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
# TERMINOLOGY.md
|
|
2
|
-
|
|
3
|
-
Tuile's house vocabulary — one line per term, looked up by word.
|
|
4
|
-
|
|
5
|
-
This file owns **definitions only**. The *rules that bite* live in AGENTS.md
|
|
6
|
-
("Nomenclature" and the sections each word belongs to); the *why we chose a word
|
|
7
|
-
and not its synonym* lives in DECISIONS.md (`D_scroll_nomenclature` for the
|
|
8
|
-
row/line/item split); the *concepts* live in the book. When a definition here
|
|
9
|
-
needs a paragraph of justification, that paragraph belongs in one of those three.
|
|
10
|
-
|
|
11
|
-
## The grid
|
|
12
|
-
|
|
13
|
-
| term | means |
|
|
14
|
-
|---|---|
|
|
15
|
-
| **row** | one row of the terminal grid — the framework's only word for it. A wrapped unit of text *is* a row; wrapping is what turns text into rows. |
|
|
16
|
-
| **column** | one cell-column of the terminal grid; the unit `display_width` counts. |
|
|
17
|
-
| **cell** | one grid position: a grapheme plus a {Tuile::StyledString::Style}, in {Tuile::Buffer}. |
|
|
18
|
-
| **glyph** | what the terminal draws in one or more cells. Ambiguous-width glyphs count as **one** column (the bet in `D_ambiguous_width`). |
|
|
19
|
-
| **cluster** | a grapheme cluster — the unit measurement, slicing, caret motion and deletion all work in. Never `each_char`. |
|
|
20
|
-
| **row_in_viewport** | a row measured `0...rect.height`, i.e. relative to a component's own rect. |
|
|
21
|
-
| **scroll_top_row** | the content row currently sitting at the top of the viewport. |
|
|
22
|
-
| **left_column** | the content column currently painted in a widget's leftmost cell — the horizontal counterpart of `scroll_top_row`. Private wherever it exists (`TextField`, `Tabs`, `MenuBar`): what a caller relies on is the invariant it maintains — the caret, or the selected segment, is in view — not the number. |
|
|
23
|
-
| **viewport_rows** | how many rows of content are visible — always `rect.height`; kept private, since `rect.height` is the public form. |
|
|
24
|
-
| **row_count** | how many rows the wrapped content occupies. Public on `TextArea` (with `caret_row`, its companion); also on the private `WrappedText` and as `VerticalScrollBar.new(row_count:)`. Not on `TextView` / `List`, which have no caller for it. |
|
|
25
|
-
| **caret_row** | the row a text input's caret sits in, counted from the content's first row. `TextArea` only. |
|
|
26
|
-
| **extent** | the `Size` a widget actually paints inside the `rect` it was given — `Component#extent`, `nil` unless declared, always at the rect's top-left (`Component#extent_rect` positions it). What the widget clears outside of, hit-tests, highlights and anchors its dropdown to. The arithmetic is each widget's own (a `Checkbox`'s glyph plus caption; a `Tabs` strip's segments and separators). Distinct from a *slot extent*. |
|
|
27
|
-
| **handle** | the moving part of a {Tuile::VerticalScrollBar} — the rows standing for the slice of content in view (`handle_start` … `handle_end`, `handle_char`). CSS calls it the *thumb*; Tuile does not. Not drawn at all when the content fits. |
|
|
28
|
-
| **track** | the scrollbar's fixed part: the full viewport height the handle moves within, and the glyph (`track_char`) painted on the rows the handle doesn't cover. Never the bar's *column*, which is "the scrollbar column" (`D_scrollbar_reserve`). |
|
|
29
|
-
| **segment** | one tab's span on a {Tuile::Component::Tabs} strip: its caption plus a padding column either side. The unit a click resolves to; the separator column between two segments belongs to neither. |
|
|
30
|
-
|
|
31
|
-
**Space rule 1.** An object with only one row space leaves `row` unqualified:
|
|
32
|
-
{Tuile::Buffer} *is* the grid, so its rows are screen rows;
|
|
33
|
-
`TextArea::WrappedText` is content, so its rows are content rows.
|
|
34
|
-
|
|
35
|
-
**Space rule 2.** A component holding both spaces qualifies the viewport one
|
|
36
|
-
(`row_in_viewport`); its unqualified `row` and its `scroll_top_row` are
|
|
37
|
-
content-space.
|
|
38
|
-
|
|
39
|
-
## Text and content
|
|
40
|
-
|
|
41
|
-
| term | means |
|
|
42
|
-
|---|---|
|
|
43
|
-
| **line** | a `\n`-delimited unit of a String — exactly what `String#lines` returns. **Never a coordinate.** |
|
|
44
|
-
| **line_count** | a count of `\n` units (`TextView::Region#line_count`). Never a row count. |
|
|
45
|
-
| **item** | a domain object a widget holds and renders — `List#items`, and the enum widgets above it. |
|
|
46
|
-
| **renderer** | the `item -> row` proc a generic component uses to render an item it knows nothing about. |
|
|
47
|
-
| **selection** | which item or tab a selector currently points at. *View state* when nothing would save it ({Tuile::Component::Tabs}`#selected`), a *value* when a form would (`RadioGroup#value`) — the split `D_tabs` calls the "would a form save it?" test. |
|
|
48
|
-
| **item_count** / **item_index** | how a `List::Cursor` counts and addresses; equal to a row count in a `List`, but the cursor indexes *items*. |
|
|
49
|
-
| **text** | the user-editable **value** of an input ({Tuile::Component::HasValue}, aliased as `text` on `AbstractStringField`). |
|
|
50
|
-
| **caption** | app-authored **chrome** text ({Tuile::Component::HasCaption}) — a `Window` title, a `Button` label. Never a value. |
|
|
51
|
-
| **chrome** | framework- or app-authored decoration around content: captions, borders, footers, an app's status line. |
|
|
52
|
-
| **caret** | the index into an input's `text` where editing happens; always on a cluster boundary. Distinct from the *cursor*. |
|
|
53
|
-
|
|
54
|
-
## Tree, paint and theme
|
|
55
|
-
|
|
56
|
-
| term | means |
|
|
57
|
-
|---|---|
|
|
58
|
-
| **component** | a node of the UI tree ({Tuile::Component}); the only thing that paints. |
|
|
59
|
-
| **slot extent** | in a `Layout::Box`, the size a parent *allocates* a child along an axis — what `Fixed` / `Percent` / `Expand` declare, and what `main_extent` / `cross_extent` measure. The parent's allocation, where a component's *extent* is the child's own painted region; `D_extent` turns on the two being different. Here `slot` is the box's allocation for one child and has **nothing** to do with {Tuile::Component::Slot} — the phrase is glossary-only (the code says `main_extent` / `cross_extent`), so read it as one term, never as "the extent of a `Slot`". |
|
|
60
|
-
| **tile** / **tiled** | the non-popup part of the tree — `ScreenPane#content` and its descendants. Also *to tile*: to cover a rect completely. |
|
|
61
|
-
| **attached** | reachable from a {Tuile::ScreenPane} via the parent chain — the one axis `attached?` consults. |
|
|
62
|
-
| **wrapping field** | a field that owns and hides one *inner editor* and carries a typed value over it — {Tuile::Component::AbstractWrappingField} and its subclasses. The editor is private machinery: no public accessor, `children` the only way in. |
|
|
63
|
-
| **inner editor** | the {Tuile::Component::AbstractStringField} a *wrapping field* wraps. Always this phrase — never "the wrapped field", which would name the wrong one of the two fields in play. |
|
|
64
|
-
| **slot** | a named region of a container, reached by identity (`content`, `footer`) as well as through `children`. Two forms: a plain named child the caller populates directly (`HasContent#content`, the primary one); or a {Tuile::Component::Slot}, the one-child region component, wired once so its occupant may be absent or swapped (`Window#footer`). Capital-`S` `Slot` always means the class. |
|
|
65
|
-
| **cascade** | the stack of open {Tuile::Component::ListDropdown} panels a {Tuile::Component::MenuBar} drives, one per level, the last deepest. Each is an overlay on the pane, not a child of the bar. |
|
|
66
|
-
| **submenu** | a menu item that opens a further panel instead of doing something — `MenuBar::Item#submenu?`, true iff the item has children. Painted with a trailing `▸`. |
|
|
67
|
-
| **mnemonic** | a letter that activates one {Tuile::Component::MenuBar} item, underlined in its caption. Always *level-scoped*: matched against the top-level items while the cascade is closed and the deepest open panel while it is open, never across the two. |
|
|
68
|
-
| **strip** | the one-row {Tuile::Component::Tabs} component: captions, one selected, no content of its own. A {Tuile::Component::MenuBar} has one too — same word, and the same extent-based hit testing, deliberately not the same look. |
|
|
69
|
-
| **tab** | a {Tuile::Component::Tabs::Tab} — a caption plus an identity, minted and owned by the strip. Not a component (it never paints itself) and not an *item* (it holds per-element state, and the set is never assigned whole). Say "a tab" and "the Tab key"; never let the two words touch. |
|
|
70
|
-
| **pane** | the component a {Tuile::Component::TabSheet} shows for the selected tab. The unselected ones are *detached* — one of the two ways to take something off the screen, and the one that fires the lifecycle hooks. |
|
|
71
|
-
| **hidden** | carrying `visible? == false` — the component's own flag. *Gone*, not merely unpainted: as if detached, but still in the tree, so no lifecycle hook fires. Says nothing about the ancestors. |
|
|
72
|
-
| **shown** | reachable by the user: this component and every ancestor visible. The effective, ancestor-inclusive state, and always the walk's word (`on_shown_tree`, `Box#shown_children`) — there is deliberately no `shown?` reader. |
|
|
73
|
-
| **invalidate** | record a component as needing repaint; the loop coalesces and repaints once per tick. |
|
|
74
|
-
| **cursor** | *(two senses, both live)* the hardware terminal cursor (`Screen#cursor_position`), and a `List::Cursor` — the selection position within a list. |
|
|
75
|
-
| **well** | the background an input paints over its whole extent (`Theme#input_bg_color` / `#active_bg_color`), declared as its `default_bg_color`. It terminates inheritance — an ancestor's tint doesn't reach it — but loses to a `bg_color` set on the input itself. Exactly one per widget: a composed field owns the well and marks the field it wraps `Component::BG_INHERIT`. |
|
|
76
|
-
| **token** | a semantic colour name on {Tuile::Theme} — an accent, never a global fg/bg. |
|
|
77
|
-
| **scheme** | `:dark` or `:light`; a {Tuile::ThemeDef} pairs one {Tuile::Theme} per scheme. |
|
|
78
|
-
|
|
79
|
-
## Locale
|
|
80
|
-
|
|
81
|
-
| term | means |
|
|
82
|
-
|---|---|
|
|
83
|
-
| **conventions** | the formatting facts a {Tuile::Locale} carries — how a value is *rendered and parsed* (date formats, calendar, month and weekday names, decimal separator). Deliberately the opposite pole from *prose*, which a `Locale` never holds. |
|
|
84
|
-
| **prose** | wording: a message in one language, belonging to one component. Outside `Locale` by rule, and outside Tuile by default — the wording fork of `D_bad_input` is where a translated one arrives. |
|
|
85
|
-
| **primary format** | `formats.first` of a date field or a {Tuile::Locale#date_formats} list — the one a value is *written* in, and the only one that must survive a `strftime`/`strptime` round-trip. The rest only ever parse. |
|
|
@@ -1,221 +0,0 @@
|
|
|
1
|
-
# Arrow keys move focus between fields — as a *behavior*, not a layout class
|
|
2
|
-
|
|
3
|
-
**Status:** design sketch, 2026-08-12. Nothing built. Started from the
|
|
4
|
-
sampler's PasswordField pane ("Up/Down between the three fields would be
|
|
5
|
-
friendlier"), but that pane is a *demo* of the feature, not an argument for
|
|
6
|
-
it — the argument is a ten-field form. Open questions at the bottom are the
|
|
7
|
-
point of this file.
|
|
8
|
-
|
|
9
|
-
## What is being proposed
|
|
10
|
-
|
|
11
|
-
In a form, Up/Down moves focus between fields, in addition to Tab/Shift+Tab.
|
|
12
|
-
Tab keeps its current job (cycle every tab stop in the scope, wrapping);
|
|
13
|
-
arrows do *local* motion within one container and stop at its edges.
|
|
14
|
-
|
|
15
|
-
## Why it's plausible at all: Tuile already has the whole mechanism
|
|
16
|
-
|
|
17
|
-
Two findings from the survey, both load-bearing:
|
|
18
|
-
|
|
19
|
-
1. **`TextField` already declines Up/Down by design.** `text_field.rb:134-140`:
|
|
20
|
-
`on_key_up` / `on_key_down` are nil by default and the nil branch is
|
|
21
|
-
`return false`, with rdoc reading "when nil, UP falls through to the parent
|
|
22
|
-
(default behavior)". The clash we feared was already resolved in the
|
|
23
|
-
direction that enables this.
|
|
24
|
-
2. **Rung 3 of the key ladder is exactly the right hook.** `bubble_key` asks
|
|
25
|
-
the focused widget, then each ancestor. AGENTS.md already names this as the
|
|
26
|
-
sanctioned home for scope-wide keys ("a layout's one-key jumps to its
|
|
27
|
-
panes"). So this is a `handle_key` on a container — no dispatch phase, no
|
|
28
|
-
gate in `Screen#handle_key`, no framework change.
|
|
29
|
-
|
|
30
|
-
Worth stating explicitly for a future reader: the key ladder's "no gate, no
|
|
31
|
-
predicate, no mode flag" rule constrains `Screen#handle_key`, **not** a
|
|
32
|
-
component's own `handle_key`. Adding behavior at rung 3 is sanctioned; a
|
|
33
|
-
per-instance switch on a *component* is not the thing that rule forbids.
|
|
34
|
-
|
|
35
|
-
Everything that must keep the arrows already claims them and wins for free:
|
|
36
|
-
`TextArea`, `TextView`, `List`, the three numeric fields, `ComboBox`.
|
|
37
|
-
|
|
38
|
-
## Prior art
|
|
39
|
-
|
|
40
|
-
Splits by lineage, not by age:
|
|
41
|
-
|
|
42
|
-
- **FTXUI** — closest to Tuile architecturally, and does exactly this.
|
|
43
|
-
`Container::Vertical` is *defined* as "navigated vertically using up/down
|
|
44
|
-
arrow keys"; `Container::Horizontal` gets left/right. Dispatch is our shape:
|
|
45
|
-
active child asked first, container only sees what the child declined
|
|
46
|
-
(`container.cpp`, `OnEvent`). Arrows do **not** wrap (`MoveSelector`); Tab
|
|
47
|
-
wraps (`MoveSelectorWrap`).
|
|
48
|
-
- **Midnight Commander** — "to move between the widgets use the arrow keys or
|
|
49
|
-
the Tab key". The ncurses form-dialog lineage generally (`dialog`, newt)
|
|
50
|
-
behaves this way; only MC was verified.
|
|
51
|
-
- **Bubble Tea** — the canonical `examples/textinputs` cycles focus on
|
|
52
|
-
up/down/tab/shift+tab, but app-side; the framework has no focus model.
|
|
53
|
-
- **Textual, ratatui, Ink, Vaadin** — no. Textual is the explicit web/ARIA
|
|
54
|
-
position: Tab between widgets, arrows only *within* a composite widget.
|
|
55
|
-
|
|
56
|
-
## The shape: a behavior on a layout, not a `Layout::Form`
|
|
57
|
-
|
|
58
|
-
`Layout::Form < Layout::Vertical` was the first sketch and is **rejected**.
|
|
59
|
-
Reasons, in order of force:
|
|
60
|
-
|
|
61
|
-
- **Vaadin's FormGroup precedent.** It coupled `Binder` to layouting and was
|
|
62
|
-
abandoned for it. `Form` here would couple a layout algorithm to a key
|
|
63
|
-
behavior — a smaller version of the same mistake.
|
|
64
|
-
- **It breaks the moment the layout is insufficient.** A real form needs a
|
|
65
|
-
nested `Horizontal` row, or an `Absolute` for a capped-proportion split. Now
|
|
66
|
-
the behavior is attached to the *outer* class and the nested layouts are
|
|
67
|
-
arbitrary, so "does this container navigate?" stops being answerable from
|
|
68
|
-
the class. Policy carried by a class is inherited by every subclass and
|
|
69
|
-
unavailable to every non-subclass; policy carried by a setter is per
|
|
70
|
-
instance and never inherited.
|
|
71
|
-
- **COP says so.** `Layout::Vertical` is a *generic, domain-agnostic*
|
|
72
|
-
component, and the skill's rule for those is to externalize policy via
|
|
73
|
-
injected strategies — not to subclass per policy. A `Form` whose only
|
|
74
|
-
divergence is one `handle_key` is precisely the "shallow divergent
|
|
75
|
-
scaffolding — duplicate or inject, don't fold into a base" case.
|
|
76
|
-
|
|
77
|
-
So: **any layout can be given the behavior; none has it by default.** virtui
|
|
78
|
-
gets nothing and stays exactly as it is; a form opts in. The sampler's `form`
|
|
79
|
-
helper (`sampler.rb:832`) becomes the one place the demo opts in.
|
|
80
|
-
|
|
81
|
-
Concretely, the nesting story this buys — and it is the whole reason for the
|
|
82
|
-
shape: a layout without the behavior **declines** the arrow key, so it bubbles
|
|
83
|
-
to the next ancestor that *does* have it. An inner `Absolute` inside a
|
|
84
|
-
navigating `Vertical` is therefore one opaque slot: focus anywhere inside it,
|
|
85
|
-
Down moves to the next slot of the outer box. No inheritance, no surprise, and
|
|
86
|
-
the answer is the same at any nesting depth.
|
|
87
|
-
|
|
88
|
-
## Semantics that already look settled
|
|
89
|
-
|
|
90
|
-
Recorded here so the open questions below stay narrow.
|
|
91
|
-
|
|
92
|
-
- **Walk direct children, not `on_tree`.** A `Horizontal` row nested in a
|
|
93
|
-
navigating `Vertical`: flattened pre-order would make Down from the row's
|
|
94
|
-
left field jump to the row's *right* field, which is geometrically wrong.
|
|
95
|
-
Direct children makes Down go to the next row. (FTXUI indexes `children()`
|
|
96
|
-
for the same reason.) "Which direct child holds focus" is a parent-chain
|
|
97
|
-
walk from `screen.focused`, so depth doesn't matter.
|
|
98
|
-
- **Skip children with no focusable descendant** (a `Label`, a spacer).
|
|
99
|
-
- **Don't wrap; decline at the edge.** Two payoffs: nesting composes (an inner
|
|
100
|
-
box at its edge declines and the outer box moves to the next sibling group),
|
|
101
|
-
and wrapping stays Tab's distinguishing job. Same split FTXUI landed on.
|
|
102
|
-
- **Descend via the existing focus cascade** — set `screen.focused` to the
|
|
103
|
-
sibling and let `Layout#on_focus` (`layout.rb:203`) forward to its first tab
|
|
104
|
-
stop. See open question on backwards entry.
|
|
105
|
-
- **Mouse is untouched.** Popups are untouched — the bubble is already scoped
|
|
106
|
-
to the topmost modal popup.
|
|
107
|
-
- **{Tuile::Component::Tabs} already left the vertical axis free for this.**
|
|
108
|
-
The strip claims Left/Right and *declines* Up/Down specifically so that this
|
|
109
|
-
feature can move focus out of it vertically while Left/Right keep switching
|
|
110
|
-
tabs inside it (`D_tabs`). It composes for nothing: the strip declines, the
|
|
111
|
-
key bubbles, the navigating ancestor moves. That is also the shape to copy
|
|
112
|
-
for any future one-axis widget — claim one axis, leave the other.
|
|
113
|
-
|
|
114
|
-
## The honest argument against
|
|
115
|
-
|
|
116
|
-
A *partially* live feature is worse than an absent one: if arrows navigate in
|
|
117
|
-
80% of positions the user can't build a model. Inside a form the exception set
|
|
118
|
-
is mostly coherent — `TextArea` / `TextView` / `List` swallow arrows, and
|
|
119
|
-
they're the *tall* widgets, where a user already expects arrows to move
|
|
120
|
-
*inside* the box. That reads as "arrows move within a tall widget, between
|
|
121
|
-
short ones", which is learnable and is the story MC tells.
|
|
122
|
-
|
|
123
|
-
The three numeric fields break that story and are the real problem (see Q6).
|
|
124
|
-
|
|
125
|
-
## Open questions
|
|
126
|
-
|
|
127
|
-
**Q1 — What exactly is the knob?** Candidates, roughly in order of how much
|
|
128
|
-
API they add:
|
|
129
|
-
|
|
130
|
-
a. keyword + accessor on `Layout`: `navigation: :vertical` / `:horizontal` /
|
|
131
|
-
`:both` / `nil` (default `nil`).
|
|
132
|
-
b. a strategy *object*: `layout.navigation = Layout::ArrowNavigation.new(...)`,
|
|
133
|
-
leaving room for per-instance config and app subclassing.
|
|
134
|
-
c. a module the app mixes in: `Vertical.new.extend(Layout::ArrowNavigable)`.
|
|
135
|
-
Composable with any layout without touching `Layout`, but `extend` on a
|
|
136
|
-
singleton class is obscure and hard to document.
|
|
137
|
-
d. a general `Component#on_key` interceptor hook (the shape
|
|
138
|
-
`AbstractStringField#on_key` already has), with the framework shipping a
|
|
139
|
-
ready-made callable to assign. Most decoupled — touches `Layout` not at
|
|
140
|
-
all — but adds a general hook whose merits should be argued on their own,
|
|
141
|
-
not smuggled in under this feature.
|
|
142
|
-
|
|
143
|
-
(a) is the smallest thing that works; (d) is the most COP-pure. Not decided.
|
|
144
|
-
|
|
145
|
-
**Q2 — Where does the axis come from?** If the behavior is layout-agnostic it
|
|
146
|
-
can't be derived from the class. `Vertical` → up/down and `Horizontal` →
|
|
147
|
-
left/right are natural defaults, but `Absolute` has none. Does the knob always
|
|
148
|
-
carry an explicit axis, or default per class and require it on `Absolute`?
|
|
149
|
-
|
|
150
|
-
**Q3 — Should `Horizontal` / left-right navigation exist at all?**
|
|
151
|
-
`AbstractStringField` *always* consumes Left/Right for the caret, so a row of
|
|
152
|
-
text fields will never arrow-navigate while a row of Buttons/Checkboxes will.
|
|
153
|
-
The rule stays uniform (widget wins); the outcome looks selective. Ship both
|
|
154
|
-
axes, or vertical-only until someone asks?
|
|
155
|
-
|
|
156
|
-
**Q4 — Ordering inside an `Absolute`.** Declaration order is all that's
|
|
157
|
-
available and may not match visual order — the original worry that killed the
|
|
158
|
-
idea of putting this on every layout. Options: document "declaration order is
|
|
159
|
-
yours to get right"; or sort direct children geometrically per keypress (by
|
|
160
|
-
`rect.top`, then `rect.left`), which is cheap and actually correct, and would
|
|
161
|
-
make `Absolute` a first-class citizen here. Is geometric ordering worth it?
|
|
162
|
-
|
|
163
|
-
**Q5 — Backwards entry into a multi-widget sibling.** `Layout#on_focus` always
|
|
164
|
-
forwards to the *first* tab stop, so arrowing **Up** into a previous group
|
|
165
|
-
lands on its first widget rather than its last. FTXUI has the same wart. Fix
|
|
166
|
-
with a `last:` variant of the cascade, or accept it?
|
|
167
|
-
|
|
168
|
-
**Q6 — The numeric fields.** `IntegerField` / `FloatField` / `BigDecimalField`
|
|
169
|
-
consume Up/Down to step by ±1, and they are *one row tall* — so they break the
|
|
170
|
-
"arrows move within tall widgets" story silently, with nothing on screen
|
|
171
|
-
explaining why. This is the sharpest concrete collision. Options:
|
|
172
|
-
|
|
173
|
-
a. leave it; document the exception,
|
|
174
|
-
b. move stepping to `Ctrl+Up/Down` or `PgUp/PgDn` (breaking, but the fields
|
|
175
|
-
are young),
|
|
176
|
-
c. make stepping opt-in per field (`step = 1` / `nil`) — a knob, but on the
|
|
177
|
-
widget that actually has the ambiguity, and a numeric field in a form
|
|
178
|
-
usually doesn't want spinner behavior anyway.
|
|
179
|
-
|
|
180
|
-
**Q7 — `List` at its edges.** It clamps and returns true, so arrows can never
|
|
181
|
-
escape a focused list; only Tab does. Keep clamping (a list is a list, and MC
|
|
182
|
-
agrees), or have it decline at its edges so arrows escape? Note this is
|
|
183
|
-
exactly virtui's shape, so the answer matters more there than in a form.
|
|
184
|
-
|
|
185
|
-
**Q8 — `ComboBox` is asymmetric.** Closed, it eats Down to open the menu
|
|
186
|
-
(`combo_box.rb:181`) but declines Up. So Up would jump out of a closed combo
|
|
187
|
-
while Down opens it. Browsers eat both. Deliberate choice or accident to fix?
|
|
188
|
-
|
|
189
|
-
**Q12 — Down out of a `Tabs` strip: to the pane, or past the whole
|
|
190
|
-
`TabSheet`?** The strip is a child of the sheet, not of the navigating layout,
|
|
191
|
-
so "walk direct children" sees the *sheet* holding focus and would move to the
|
|
192
|
-
sheet's next sibling — skipping the pane the user is looking at. Entering the
|
|
193
|
-
pane is almost certainly what a user means by Down here. Options: let a
|
|
194
|
-
`TabSheet` claim Down when focus is on its strip (a `handle_key` on the sheet,
|
|
195
|
-
no framework change, but a second place that binds an arrow); or have the
|
|
196
|
-
navigating walk descend into a child that holds focus deeper than its first
|
|
197
|
-
tab stop. Interacts with Q1's placement question.
|
|
198
|
-
|
|
199
|
-
**Q9 — Naming.** "Form" is the wrong word for the behavior — it's *focus
|
|
200
|
-
navigation*. `navigation` / `arrow_nav` / `key_navigation` /
|
|
201
|
-
`focus_navigation`? Whatever it is, it must not imply validation or submit,
|
|
202
|
-
which Tuile has no notion of.
|
|
203
|
-
|
|
204
|
-
**Q10 — Does Enter participate?** `dialog(1)` moves to the next field on
|
|
205
|
-
Enter. Almost certainly out of scope — `Checkbox`, `Button` and `TextArea` all
|
|
206
|
-
claim Enter already, and book ch5 has the per-widget Enter table — but worth
|
|
207
|
-
rejecting explicitly rather than by omission.
|
|
208
|
-
|
|
209
|
-
**Q11 — Where does the code live?** Zeitwerk wants one top-level constant per
|
|
210
|
-
file. If Q1 lands on (b) or (c) it needs its own file under
|
|
211
|
-
`lib/tuile/component/layout/`; if (a), it's a few lines on `Layout` itself.
|
|
212
|
-
Also: any public signature change means `rake sig` in the same commit.
|
|
213
|
-
|
|
214
|
-
## Graduation
|
|
215
|
-
|
|
216
|
-
If built: the user-facing half goes to book ch5 (the key/Enter tables live
|
|
217
|
-
there), the invariants half to AGENTS.md's key-dispatch section, and the
|
|
218
|
-
choice-plus-rejected-roads half to `DECISIONS.md` as `D_arrow_navigation` —
|
|
219
|
-
which must record the `Layout::Form` rejection and the Vaadin FormGroup
|
|
220
|
-
precedent behind it, since that's the reasoning most likely to be
|
|
221
|
-
re-litigated. Then retire this file.
|
data/ideas/binder.md
DELETED
|
@@ -1,177 +0,0 @@
|
|
|
1
|
-
# A Binder for Tuile — the pattern to copy, and the vocabulary that comes with it
|
|
2
|
-
|
|
3
|
-
**Status:** filed 2026-09-03 as a **placeholder plus a settled vocabulary**.
|
|
4
|
-
Nothing is designed here and nothing is implemented; the forms layer is not
|
|
5
|
-
started. Two things earn the file: (1) Vaadin's `Binder` is the pattern to
|
|
6
|
-
copy, and it is worth writing down *which parts* before anyone improvises one,
|
|
7
|
-
and (2) the four-layer terminology below was settled while designing
|
|
8
|
-
`HasBadInput` and has nowhere else to live until a `D_` entry exists — it is
|
|
9
|
-
the reason that channel is called `bad_input` and not `presentation_error`.
|
|
10
|
-
|
|
11
|
-
`D_has_value` is the standing authority on what belongs above the field:
|
|
12
|
-
"Model-mapping (presentation ⟷ domain) is left to a future forms/binder layer
|
|
13
|
-
*above* the field, never baked into field state", and it parks converters,
|
|
14
|
-
`read_only` and the required-indicator there too. Nothing here overrides that.
|
|
15
|
-
|
|
16
|
-
## The terminology (settled — this is the file's real content)
|
|
17
|
-
|
|
18
|
-
Four layers, and the two arrows that matter. A `Date`-valued field bound to
|
|
19
|
-
`Person#birth_date`:
|
|
20
|
-
|
|
21
|
-
| layer | example | who speaks it |
|
|
22
|
-
|---|---|---|
|
|
23
|
-
| **model** | `Person#birth_date` | Binder only |
|
|
24
|
-
| **transformations** | `birth_year` + `birth_month` + `birth_day` → a `Date`; a `birth_date_iso_string` → a `Date` | Binder only |
|
|
25
|
-
| **value** | the `Date`, or `nil` — `HasValue#value` | **both** — the shared layer, which is why `HasValue` is *the* seam |
|
|
26
|
-
| **input** | the glyphs the user typed, a calendar click, a mask's partial fill: `"2020-05-01"`, `"xyz"` | the field owns it; the Binder must be able to *ask about* it |
|
|
27
|
-
|
|
28
|
-
| arrow | word | note |
|
|
29
|
-
|---|---|---|
|
|
30
|
-
| input → value | **parse** | *partial* — it can fail, and that failure is **bad input** (`D_bad_input`) |
|
|
31
|
-
| value → input | **format** | *total* — formatting a `Date` into glyphs cannot fail |
|
|
32
|
-
| the parse/format pair, inside a field | the field's **converter** | already the house word (`D_integer_field`: "the converter stays private and hardcoded"; `DECISIONS.md:2010`: a `parse`/`format` hook pair *is* the converter strategy) |
|
|
33
|
-
| model ⟷ value, in the Binder | a **transformation** / the Binder's converters | a *chain*, possibly several steps |
|
|
34
|
-
|
|
35
|
-
Why these words and not Vaadin's, in three lines:
|
|
36
|
-
|
|
37
|
-
- **`presentation` is unavailable.** Vaadin's `Converter<PRESENTATION, MODEL>`
|
|
38
|
-
uses it for the **value** layer — `convertToModel` "receives a value that
|
|
39
|
-
originates from the user", `convertToPresentation` one "that originates from
|
|
40
|
-
the business object" — so it never names the glyph layer at all, and
|
|
41
|
-
`D_has_value` already adopted the same axis in prose. `Date` is the tell: it
|
|
42
|
-
*is* "the presentation" in Binder-speak while saying nothing about
|
|
43
|
-
formatting.
|
|
44
|
-
- **A pair cannot name a chain.** model→value may be several transformations
|
|
45
|
-
(an old schema splitting a date across three columns), so naming one arrow
|
|
46
|
-
after the endpoints of a different chain is what made this confusing.
|
|
47
|
-
- **`input` had to be freed.** Tuile's rdoc used "an input" for the *widget*
|
|
48
|
-
(~40 real sites, plus `Theme#input_bg_color`). The rule going forward:
|
|
49
|
-
**"input" alone is what the user put in; the widget is always a `field`**,
|
|
50
|
-
never "an input" — Tuile already says `field` 188 times, and TERMINOLOGY
|
|
51
|
-
already calls that background a **well**, so the token name is grandfathered
|
|
52
|
-
legacy. The sweep is a standalone mechanical pass, deliberately not bundled
|
|
53
|
-
with an unimplemented design. Note ~30 further hits are the ordinary English
|
|
54
|
-
sense ("a renderer whose *inputs* changed", "both shorter and longer *inputs*
|
|
55
|
-
are bugs") and must **not** change.
|
|
56
|
-
|
|
57
|
-
**Reserved:** `model`, `transformations` and `presentation` belong to the
|
|
58
|
-
layers above `value`; `domain` is the word `D_has_value` uses for the topmost
|
|
59
|
-
one. Don't spend them on a field-level concept. At graduation the layer words
|
|
60
|
-
go to TERMINOLOGY.md (one line each, beside `text` and `caption`) and the
|
|
61
|
-
choice to a `D_` entry — a nomenclature ruling in the `D_scroll_nomenclature`
|
|
62
|
-
mould.
|
|
63
|
-
|
|
64
|
-
## Why Vaadin's `Binder` is the pattern
|
|
65
|
-
|
|
66
|
-
It is the only one of the surveyed toolkits that keeps *form validity* as a
|
|
67
|
-
single source of truth without pushing rules into the widgets — which is
|
|
68
|
-
exactly the split Tuile has already committed to — the field reports what its
|
|
69
|
-
own parse could not represent and never judges (`D_bad_input`), and the verdict
|
|
70
|
-
lives in a slot only an outside validator writes (`D_has_validation`). The parts
|
|
71
|
-
worth copying, from v25.2:
|
|
72
|
-
|
|
73
|
-
- **`forField(field).withValidator(pred, message).bind(getter, setter)`** — a
|
|
74
|
-
builder per binding, rules declared beside the binding and nowhere else;
|
|
75
|
-
`asRequired("msg")` as the shorthand.
|
|
76
|
-
- **`withConverter`** for the model⟷value transformations, including a
|
|
77
|
-
conversion-error message, and chains where each step sees the previous
|
|
78
|
-
step's output.
|
|
79
|
-
- **`readBean` / `writeBean` / `writeBeanIfValid`**, with a write that refuses
|
|
80
|
-
when anything is invalid.
|
|
81
|
-
- **`isValid` / `hasChanges` / `validate` → `BinderValidationStatus`** as the
|
|
82
|
-
aggregate the app asks.
|
|
83
|
-
- **`binding.validate()`** for cross-field revalidation ("cannot return before
|
|
84
|
-
departing"), driven from the other field's value-change listener.
|
|
85
|
-
- **Escape hatches that exist for real reasons:** `setValidatorsDisabled`,
|
|
86
|
-
`setDefaultValidatorsEnabled(false)` / `withDefaultValidator(false)`, and
|
|
87
|
-
`setIsAppliedPredicate` for a binding that shouldn't participate at all.
|
|
88
|
-
|
|
89
|
-
What Tuile should *not* copy: `HasValidator#getDefaultValidator` and
|
|
90
|
-
`addValidationStatusChangeListener`. Both exist to repair a *shared*
|
|
91
|
-
`invalid`/`errorMessage` cell on the component; Tuile puts the two facts in two
|
|
92
|
-
places instead, so the repair has nothing to fix (`D_bad_input`, and
|
|
93
|
-
`Component::HasValidation`, shipped 2026-09-03 — `D_has_validation`: one stored
|
|
94
|
-
`error_message` the field never writes, so the Binder is its sole writer and
|
|
95
|
-
sets-or-clears it on every validate pass). Ruby also deletes most of the
|
|
96
|
-
ceremony: a validator is a proc returning a message or `nil`, so there is no
|
|
97
|
-
`Validator` interface, no `ValidationResult`, and no `Result.ok`.
|
|
98
|
-
|
|
99
|
-
## What the Binder must consume from a field
|
|
100
|
-
|
|
101
|
-
- **`is_a?(HasValue)` is the marker** for "this is bindable" — `D_integer_field`
|
|
102
|
-
says so explicitly.
|
|
103
|
-
- **`field.respond_to?(:bad_input?) && field.bad_input?`** is the bad-input
|
|
104
|
-
question — **this half exists today** (`D_bad_input`) — asked at bind, at
|
|
105
|
-
click, at write, and whenever a sibling forces a revalidation. The *capability* is a class fact and may be cached at bind
|
|
106
|
-
time; the *status* may never be cached.
|
|
107
|
-
- **Bad input must block the write even for an optional field.** This is the
|
|
108
|
-
failure Vaadin names and the whole reason the channel exists — an optional
|
|
109
|
-
field means "may be empty", not "may be garbage". And it cannot be reached
|
|
110
|
-
through `empty?`, which reports `true` for a field full of glyphs the value
|
|
111
|
-
cannot represent.
|
|
112
|
-
- **A push notice (`on_bad_input_change`) is deliberately *not* built** — it
|
|
113
|
-
is needed only by a consumer that must react *between* clicks, which a Binder
|
|
114
|
-
gated at the click is not (`D_bad_input`, `D_on_blur`).
|
|
115
|
-
|
|
116
|
-
## The Tuile-specific part: gating Save
|
|
117
|
-
|
|
118
|
-
**The gate goes at the click, not on the button's enabled state.** Save asks
|
|
119
|
-
the Binder when pressed and, on "no", opens an alert naming the problems
|
|
120
|
-
(`ConfirmWindow.alert` exists — `D_confirm_window`). Vaadin's idiom is the
|
|
121
|
-
other one (v25.2 `flow/binding-data/components-binder-load.md`):
|
|
122
|
-
|
|
123
|
-
```java
|
|
124
|
-
binder.addStatusChangeListener(event -> {
|
|
125
|
-
saveButton.setEnabled(event.getBinder().hasChanges()
|
|
126
|
-
&& event.getBinder().isValid());
|
|
127
|
-
});
|
|
128
|
-
```
|
|
129
|
-
|
|
130
|
-
Three reasons not to copy it, ascending:
|
|
131
|
-
|
|
132
|
-
- **`Button` has no disabled state** — no `enabled` axis on `Component`, no
|
|
133
|
-
`:disabled` in `BG_STATES` (AGENTS.md is explicit that the key is absent
|
|
134
|
-
because the *state* is absent). The enabled design needs framework work
|
|
135
|
-
first; the click design needs none.
|
|
136
|
-
- **A disabled control says nothing about why**, and a TUI has no channel to
|
|
137
|
-
explain it: no tooltip, and hover is not even received (mode 1000 is
|
|
138
|
-
press-only — `ideas/hover.md`).
|
|
139
|
-
- **It removes the only *continuous* consumer of the bad-input signal**, so
|
|
140
|
-
nothing needs a settling policy: a Binder asked only at the click sees one
|
|
141
|
-
settled state, and the flicker `D_bad_input` describes never arises on this
|
|
142
|
-
side. (The *well* still owes one, now that it ORs `bad_input?` — that debt is
|
|
143
|
-
unassigned and belongs to the first continuous consumer, per
|
|
144
|
-
`D_has_validation`.)
|
|
145
|
-
|
|
146
|
-
If a settling policy is ever wanted here anyway, copy Vaadin's display rule
|
|
147
|
-
rather than inventing one: errors count only after the user has edited a field
|
|
148
|
-
and submitted.
|
|
149
|
-
|
|
150
|
-
## Open, and deliberately not designed here
|
|
151
|
-
|
|
152
|
-
Where a rule's message is *stored* and *shown* is answered by
|
|
153
|
-
`D_has_validation`: stored on the field as `HasValidation#error_message`, shown
|
|
154
|
-
as the field's own red *well* plus text in whatever cells surround it (the
|
|
155
|
-
layout's inline-right message is still unbuilt — `ideas/form-layout.md`). The
|
|
156
|
-
Binder writes it, and does not hold a per-binding cell of its own. Two
|
|
157
|
-
consequences for the port: the write is a plain `field.error_message = msg_or_nil`
|
|
158
|
-
per pass, and the Binder must **subscribe nothing** to show it — but it does
|
|
159
|
-
compete for the single `on_error_message_change` slot with a `FormLayout` and
|
|
160
|
-
with the app, which that note flags. Whether Tuile grows a `Signal` type to
|
|
161
|
-
mirror Vaadin 25's `validationStatusSignal()` is untouched — Tuile's listener
|
|
162
|
-
idiom is a plain proc, and nothing has asked for more.
|
|
163
|
-
|
|
164
|
-
## Related
|
|
165
|
-
|
|
166
|
-
`D_bad_input` (the field-side channel this consumes, already shipped),
|
|
167
|
-
`D_on_blur` (the commit point that shipped, and why the push notice stays
|
|
168
|
-
deferred), `D_has_validation` (the
|
|
169
|
-
verdict slot this Binder is the sole writer of; where a message lives and who
|
|
170
|
-
paints it), `ideas/form-layout.md` (the unbuilt container that would paint it),
|
|
171
|
-
`ideas/new-components.md` (Tier 2 Form Layout, Custom Field; infra items 2–3),
|
|
172
|
-
`D_has_value` (the forms layer owns converters, `read_only`, the
|
|
173
|
-
required-indicator; the typed-value survey), `D_integer_field` (the field's own
|
|
174
|
-
converter stays private; `is_a?(HasValue)` is the Binder's marker),
|
|
175
|
-
`D_scroll_nomenclature` (the nomenclature ruling this vocabulary copies),
|
|
176
|
-
`D_confirm_window` (the alert the Save button opens), `D_status_bar` (why the
|
|
177
|
-
framework places no error row).
|
data/ideas/composite-field.md
DELETED
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
# `CompositeField`: several fields behind one value
|
|
2
|
-
|
|
3
|
-
**Status:** filed 2026-09-04, as what was left over when
|
|
4
|
-
`Component::AbstractWrappingField` shipped and its note graduated — read
|
|
5
|
-
`D_wrapping_field` first, this note assumes it. Nothing here is built, and it
|
|
6
|
-
waits for a **real consumer**: the two questions it turns on have no answer
|
|
7
|
-
that today's components would test.
|
|
8
|
-
|
|
9
|
-
The shape: a `DateTimeField` over a `DateField` plus a `TimeField`, i.e. several
|
|
10
|
-
fields arranged in a layout behind one typed value. `AbstractWrappingField` is
|
|
11
|
-
deliberately *one editor, full stop*, and was built as the prototype this learns
|
|
12
|
-
from.
|
|
13
|
-
|
|
14
|
-
What is already known about its shape:
|
|
15
|
-
|
|
16
|
-
- **A sibling of `AbstractWrappingField`, not a subclass of it.** That base's
|
|
17
|
-
whole value is that one child removes the layout and the ordering; inheriting
|
|
18
|
-
from it would put both back.
|
|
19
|
-
- **No auto-discovery of the fields — ever.** A tree walk for "the fields inside
|
|
20
|
-
me" would descend *through* a wrapping field into the private editor it exists
|
|
21
|
-
to hide. Registration is explicit.
|
|
22
|
-
- **`active=` already gives it the right commit semantics** — see
|
|
23
|
-
`D_wrapping_field`, where that seam is chosen partly *because* it survives
|
|
24
|
-
here: focus moving between two of a composite's own fields keeps the composite
|
|
25
|
-
active, so it does not spuriously commit. The one hard part it inherits solved.
|
|
26
|
-
- **The abstract pair generalizes by pluralizing.** `value` / `value=` already
|
|
27
|
-
mean "read the value out of my field(s)" and "apply the value into my
|
|
28
|
-
field(s)"; a composite changes nothing else about that contract, which is the
|
|
29
|
-
evidence the prototype transfers.
|
|
30
|
-
- **It cannot inherit the "the base adds the child" guarantee**, and that is
|
|
31
|
-
another reason it is a sibling. `AbstractWrappingField` calls `add_child`
|
|
32
|
-
itself, which is what makes *own and hide* a guarantee rather than a
|
|
33
|
-
convention. A composite must let its subclass populate a layout, so something
|
|
34
|
-
else has to replace it — explicit registration of which descendants are its
|
|
35
|
-
fields.
|
|
36
|
-
- **What it must solve, and a wrapping field never had to:** assembling `value`
|
|
37
|
-
from several children with a diff guard; deciding whether `bad_input?` is "any
|
|
38
|
-
child" or "the combination"; which child takes focus on `on_focus`; and how
|
|
39
|
-
the layout is expressed without becoming a container.
|
|
40
|
-
|
|
41
|
-
**The hard one: which component wears the error, and it is already half
|
|
42
|
-
answered.** Picture `Date: [DateField] Time: [TimeField]` — a `Horizontal` of
|
|
43
|
-
four children, two of them labels. Two strategies: the composite marks *itself*
|
|
44
|
-
invalid, or it marks each of its *fields*. **They are not symmetric — the first
|
|
45
|
-
is already broken by the background chain.** `error_bg_color` sits at the top of
|
|
46
|
-
the same chain a child walks, so a child inherits its parent's *error* level,
|
|
47
|
-
not merely its normal well. Verified:
|
|
48
|
-
|
|
49
|
-
```ruby
|
|
50
|
-
f = Component::IntegerField.new
|
|
51
|
-
f.error_message = "nope"
|
|
52
|
-
label_under_it.effective_bg_color # => Color 88 — the error well
|
|
53
|
-
```
|
|
54
|
-
|
|
55
|
-
So a composite that marks itself reddens its `Date:` and `Time:` labels, which
|
|
56
|
-
is wrong for the same reason `D_caption_ownership` keeps a caption off a field:
|
|
57
|
-
that text is chrome, and chrome is not the thing that failed. That points at
|
|
58
|
-
marking the fields — but it leaves the genuinely hard case open, and it is the
|
|
59
|
-
case a composite exists for: a **combination** error (`start > end`) where no
|
|
60
|
-
single field is wrong. Marking one is a lie, marking all of them is loud, and
|
|
61
|
-
marking none loses the signal. Unsolved, and the reason the whole area waits for
|
|
62
|
-
a real consumer.
|
|
63
|
-
|
|
64
|
-
**BG_INHERIT is the same question wearing a different hat** — does a composite's
|
|
65
|
-
whole subtree inherit its well (and the labels sit in it), or only the fields
|
|
66
|
-
(and the layout's gaps show terminal default, looking patchy)? Both readings are
|
|
67
|
-
defensible, neither has a consumer, so nothing guesses yet.
|
|
68
|
-
|
|
69
|
-
## Related
|
|
70
|
-
|
|
71
|
-
`D_wrapping_field` (the one-editor base this generalizes — its admission test,
|
|
72
|
-
its forwarding test, and `active=` as the commit point), `D_has_validation` and
|
|
73
|
-
`D_bad_input` (the two error channels a composite has to combine),
|
|
74
|
-
`D_caption_ownership` (why an inner label is chrome, and chrome is not what
|
|
75
|
-
failed), `D_bg_surface` (the background chain that makes "mark self" redden the
|
|
76
|
-
labels), `D_date_field` (`DateTimeField` is the plausible first
|
|
77
|
-
consumer).
|