tuile 0.10.0 → 0.11.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.
data/DECISIONS.md CHANGED
@@ -785,18 +785,25 @@ will follow.
785
785
  the mixin's split says chrome is `caption`. Tuile has no field-label seam
786
786
  yet; when one lands, a checkbox's caption should stay what it is — the
787
787
  clickable target, not a caption *for* another widget.
788
- - **Space toggles; Enter is left unclaimed, but not *promised*.** Space-to-flip
789
- is the native gesture (Vaadin's checkbox is Space-only too) and a checkbox has
790
- no default action to confirm, so there is nothing for Enter to do here.
791
- Claiming a key you don't need is the irreversible direction — teaching Enter a
792
- meaning later breaks nobody, taking it back breaks apps — and that, alone, is
793
- why `handle_key` ignores it. It is emphatically **not** a promise that a
794
- form's Enter-to-submit can bubble past a focused checkbox: no widget owes
795
- that (`TextArea` claims Enter for newline, `Button` to activate itself), and
796
- book ch5's Enter table states it per widget precisely because it is per
797
- widget. A checkable row in a `List` toggles on Enter (`D-checkbox-group`) —
798
- `List`'s own *choose the item under the cursor*, not a checkbox gesture, so
799
- the two don't read as inconsistent.
788
+ - **Space and Enter both toggle** (Enter added 2026-08-03; unclaimed through
789
+ 0.10.0). Space-to-flip is the native gesture (Vaadin's checkbox is Space-only),
790
+ and the original ruling left Enter alone on the grounds that claiming a key you
791
+ don't need is the irreversible direction. What tipped it is *consistency with
792
+ the group components*: a checkable row inside a `List` toggles on Enter, since
793
+ Enter is `List`'s own choose-the-item-under-the-cursor gesture
794
+ (`D-checkbox-group`, `D-radio-group`). So `[ ] Verbose` flipped on Enter when
795
+ it sat in a `CheckboxGroup` and did nothing when it sat alone in a form — a
796
+ distinction the user cannot see, and one that reads as a bug in the standalone
797
+ widget rather than as restraint. One gesture set, both shapes, is worth more
798
+ than the option value of a key a checkbox has no other use for.
799
+ The consequence is explicit and accepted: a focused checkbox now **consumes**
800
+ Enter, so an ancestor's Enter-to-submit does not see it. That was never
801
+ promised — no widget owes it (`TextArea` claims Enter for newline, `Button` to
802
+ activate itself), and book ch5's Enter table states it per widget precisely
803
+ because it is per widget (see the rejected reservation below, which is why the
804
+ promise doesn't exist to break). An app wanting Enter-anywhere-submits binds it
805
+ on the ancestor *and* accepts that its focusable widgets each get first refusal.
806
+ Now that Enter is claimed, taking it back is the breaking direction — don't.
800
807
  - **No constructor block, but a `value:` kwarg.** `Button.new(caption,
801
808
  &on_click)` and `PickerWindow` are the gem's only ctor blocks, and both exist
802
809
  to *produce one outcome* — the callback is mandatory in practice. A checkbox
@@ -854,7 +861,9 @@ will follow.
854
861
  `ListDropdown::Menu` shape — a non-focusable `List` subclass plus
855
862
  hand-forwarded movement keys — to protect a guarantee nothing relied on
856
863
  (`D-checkbox-group`). Enter-reaches-your-form is a per-assembly property the
857
- app verifies for its own focusable widgets, not a framework invariant.
864
+ app verifies for its own focusable widgets, not a framework invariant. Still
865
+ rejected, and now moot in both directions: the standalone widget claims Enter
866
+ too, which is what made the two shapes agree.
858
867
  - *Hit-test the whole `rect`:* activates clicks that visibly land on nothing,
859
868
  and `Rect#contains?` spans every row, so a click two rows below a visible
860
869
  `[ ]` would toggle it. Vaadin agrees — a 100%-wide checkbox ignores clicks
@@ -1860,7 +1869,13 @@ than sugar, since the composed label is what reads them — which is why both
1860
1869
  scale through one helper with exact endpoints (a full bar means done, and
1861
1870
  anything above zero lights a cell). And the bar is the first *animated*
1862
1871
  component, which is what turned an ordinary `super` in `repaint` into a
1863
- measurable wire-traffic bug; AGENTS.md carries the resulting rule.
1872
+ measurable wire-traffic bug: `super` clears the background first, so
1873
+ `Cell#set` saw a real change on every cell of the bar and `flush` re-emitted
1874
+ the *entire* row five times a second instead of the one or two cells that had
1875
+ moved — **976 block glyphs per 1.2 s on the wire, versus 18** once the clear
1876
+ was scoped to the unpainted tail. That measurement is the evidence for the
1877
+ rule; AGENTS.md carries the rule itself ("never blank a cell you are about to
1878
+ paint over").
1864
1879
 
1865
1880
  ---
1866
1881
 
@@ -1959,3 +1974,593 @@ its row records keep character offsets and `chars_for_column` /
1959
1974
  plan the parked note assumed collapsed to one. Still out of scope and unfixed: a
1960
1975
  lone combining mark remains constructible via `text=` or by typing a mark into
1961
1976
  an empty field, which is input validation, not an axis question.
1977
+
1978
+ ---
1979
+
1980
+ ## D-float-field — `FloatField`: named for its Ruby type, and a deliberate copy of `IntegerField` (2026-08-07)
1981
+
1982
+ **Status:** Accepted; implemented 2026-08-07 (`Component::FloatField`). The
1983
+ `Float` half of `D-integer-field`'s "derived parse" case — same wrapper shape,
1984
+ same taxonomy slot, so only what *differs* is recorded here.
1985
+
1986
+ **Context.** Vaadin calls this a *Number Field*; the survey in
1987
+ `ideas/new-components.md` filed it as an "`IntegerField` twin". A second numeric
1988
+ field is where the naming rule and the shared-base temptation both had to be
1989
+ settled, because a third (`BigDecimalField`) is foreseeable.
1990
+
1991
+ **Decision — name a typed field after the Ruby class its `value` is.**
1992
+ `FloatField#value` is a `Float`, so `FloatField`; `IntegerField#value` is an
1993
+ `Integer`. The name is then derivable rather than remembered, it says the
1994
+ precision out loud at the call site (`Float` is a binary double — the wrong type
1995
+ for money), and it leaves the obvious room for `BigDecimalField` /
1996
+ `RationalField`. `NumberField` was rejected: it names Vaadin's *widget*
1997
+ category, not this field's value, and it would force the eventual sibling to be
1998
+ "the other number field."
1999
+
2000
+ **Decision — duplicate `IntegerField` rather than grow a base.** The two share
2001
+ ~90% of their body (the `HasContent` shell, the `on_key` filter interceptor, the
2002
+ `fire_if_changed` guard) and differ in exactly the three places that matter: the
2003
+ filter, the parse, and the format. An `AbstractNumericField` with abstract
2004
+ `parse`/`format` hooks **is** the converter strategy `D-integer-field` kept out,
2005
+ reached through inheritance instead of a setter — and the `cop` rule is to
2006
+ duplicate rather than fold a shallow commonality into a base. The duplication is
2007
+ visible and boring; the base would be machinery.
2008
+
2009
+ **Decision — the parse is lenient about partial buffers, the input filter is
2010
+ shallow.** `value` is a regexp-gated `String#to_f` — the private `NUMERIC`
2011
+ pattern: an optional sign, digits with an optional fractional part (either side
2012
+ may be empty, not both), an optional exponent. Not `Float()`, which raises on
2013
+ both `"1."` and `".5"`, so a `Float()`-based parse would blink the value to `nil` and back
2014
+ on the single keystroke between `"1"` and `"1.5"` — one spurious `nil` per
2015
+ decimal point, straight into every `on_value_change` listener. The regexp gate
2016
+ is what makes `to_f`'s garbage-tolerance harmless (it never sees garbage). The
2017
+ filter is correspondingly shallow — a digit anywhere, `-` only at index 0, `.`
2018
+ only if the buffer has none — so it keeps the buffer *typeable*, not always
2019
+ valid; `value` decides what parses. (`IntegerField` already worked this way: it
2020
+ lets a digit be typed before a leading `-`.)
2021
+
2022
+ **Decision — the exponent is parseable but not typeable.** `Float#to_s` writes
2023
+ `1.0e-05` for extreme magnitudes, so `value = 1e-5` must read back — the parse
2024
+ accepts an exponent. No key types an `e`, though: admitting one would drag in
2025
+ "`-` after `e`" and break the "`-` only at index 0" rule for a notation nobody
2026
+ types into a form.
2027
+
2028
+ **Decision — `value=` coerces with `Float()` and refuses a non-finite.**
2029
+ `Float::NAN.to_s` is `"NaN"`, which nothing parses, so writing one would make
2030
+ the field silently read back `nil` — a lost value with no error. It raises
2031
+ instead. Coercion also means `field.value = 3` shows `"3.0"`, which is the
2032
+ honest display of a `Float`-valued field.
2033
+
2034
+ **Decision — Up/Down step by exactly `1.0`; there is no `step=`.** Same fixed
2035
+ spinner as `IntegerField`. A settable step is not free on a binary float:
2036
+ stepping by `0.1` accumulates `0.30000000000000004` straight into the visible
2037
+ buffer, so the knob would need a rounding policy (decimals? significant
2038
+ digits?), and rounding is formatting — a forms concern, parked with `min`/`max`
2039
+ in `D-integer-field`.
2040
+
2041
+ **Alternatives rejected.**
2042
+ - *`BigDecimal` as the value type:* correct for money, but it needs the
2043
+ `bigdecimal` gem, a decimals/scale policy, and `"0.1"` → `BigDecimal("0.1")`
2044
+ string-round-tripping — a different field with a different name, not this one.
2045
+ - *Normalize the buffer on parse (`"007"` → `"7"`, `".5"` → `"0.5"`):*
2046
+ rejected for the same reason as in `IntegerField` — canonicalizing needs a
2047
+ blur/commit point a TUI lacks, and rewriting the buffer under the caret while
2048
+ typing is worse than an ugly buffer.
2049
+ - *A locale decimal comma:* no locale seam exists in Tuile, and inventing one
2050
+ for a single field would put i18n in the wrong layer.
2051
+
2052
+ ---
2053
+
2054
+ ## D-bigdecimal-field — `BigDecimalField`, and Tuile's first optional dependency (2026-08-07)
2055
+
2056
+ **Status:** Accepted; implemented 2026-08-07 (`Component::BigDecimalField`).
2057
+ The third numeric field, so it inherits `D-float-field` wholesale (named for
2058
+ its Ruby value type, a deliberate copy rather than a shared base) — only the
2059
+ two things that are new are recorded here: exactness, and the packaging.
2060
+
2061
+ **Context.** `D-float-field` closes with "the wrong field for money — hold that
2062
+ as `Integer` cents"; this is the field that makes the honest answer available.
2063
+ `BigDecimal`, though, is not a language built-in: it was a *default* gem
2064
+ through Ruby 3.3 and became a **bundled** gem in 3.4, so from 3.4 on a Bundler
2065
+ app must name it in its `Gemfile` or `require "bigdecimal"` raises.
2066
+
2067
+ **Decision — ship it as an optional dependency, not a gemspec entry.**
2068
+ RubyGems has no optional/extras scope (no Maven `provided`, no Python extras),
2069
+ so the mechanism is convention: `lib/tuile/component/big_decimal_field.rb`
2070
+ carries the `require` itself, and Zeitwerk's laziness confines the cost — an
2071
+ app that never names the constant never executes the file. Three pieces make
2072
+ that hold, and all three are load-bearing:
2073
+ - The `require` is wrapped in a `rescue LoadError` that re-raises with the
2074
+ actual fix (`gem "bigdecimal"`), since the bare message ("cannot load such
2075
+ file") explains nothing about a gem that *is* installed but unbundled.
2076
+ - `loader.do_not_eager_load` on that one file, so a host app calling
2077
+ `Zeitwerk::Loader.eager_load_all` — which Rails-shaped apps do — doesn't
2078
+ raise on a component it never asked for. Pinned by a subprocess spec that
2079
+ eager-loads everything and asserts `$LOADED_FEATURES` stays free of it.
2080
+ - The `require` **must not** be hoisted into `lib/tuile.rb` with the other
2081
+ gem-level requires; that would impose the load on every user and defeat the
2082
+ whole arrangement. This is the exception AGENTS.md's no-requires rule is
2083
+ worded for.
2084
+ The accepted cost, stated plainly: the failure moves from `bundle install` to
2085
+ first use, so a missing gem surfaces mid-render in a raw-mode terminal rather
2086
+ than at boot. Worth it for one opt-in component; **not** a licence to make
2087
+ this Tuile's default posture — a second optional dependency needs its own
2088
+ argument.
2089
+
2090
+ **Decision — normalize and format on both ends, rather than trusting
2091
+ `bigdecimal`.** Two of the three inputs behave differently across the versions
2092
+ Tuile supports: `bigdecimal` 3.1 (Ruby 3.3's default gem) *rejects*
2093
+ `BigDecimal("1.")` and `BigDecimal(0.1)`, while 4.x accepts both. So the field
2094
+ does its own work: a half-typed buffer is normalized (`".5"`→`"0.5"`,
2095
+ `"1."`→`"1"`) before parsing, and display goes through `to_s("F")` — plain
2096
+ notation, since `BigDecimal#to_s` writes `"0.1999e2"` for `19.99` and would put
2097
+ engineering notation in a form. The field's behavior is therefore identical on
2098
+ both, instead of tracking whichever parser the host resolved. Honest gap: the
2099
+ `Gemfile` resolves 4.x, so CI only ever exercises that one — 3.1 was verified
2100
+ by hand, and the normalization is what makes the difference unreachable rather
2101
+ than merely tested.
2102
+
2103
+ **Decision — a `Float` is refused, not converted.** `field.value = 19.99`
2104
+ raises with a message naming the fix (`BigDecimal("19.99")`). The literal has
2105
+ already lost the decimal by the time it reaches the setter, and a field whose
2106
+ entire purpose is exactness should not be the place that quietly papers over
2107
+ it. That 4.x *would* accept it (via a shortest-round-trip conversion) and 3.1
2108
+ would not is the second reason: silently version-dependent precision is worse
2109
+ than a loud refusal. `Integer` and `String` coerce as normal.
2110
+
2111
+ **Decision — the buffer is still never rewritten.** `"19.90"` keeps its
2112
+ trailing zero and `"007"` its leading ones, exactly as in the other two numeric
2113
+ fields: a display *scale* (pad to 2 decimals) is formatting, and formatting is
2114
+ the forms layer's, parked with `min`/`max`. Note the one place this shows
2115
+ through the value seam: `"1.0"`→`"1.00"` fires nothing, because the two
2116
+ `BigDecimal`s compare equal.
2117
+
2118
+ **Alternatives rejected.**
2119
+ - *A hard `spec.add_dependency "bigdecimal"`:* makes every Tuile app carry a
2120
+ gem for a component most won't use — and Tuile's dependency list is
2121
+ otherwise TTY primitives and a loader.
2122
+ - *Accept a `Float` by converting through `to_s`:* that is a precision policy
2123
+ ("shortest decimal that round-trips") hidden inside a setter. If it is ever
2124
+ wanted, it belongs at the call site, where it is visible.
2125
+ - *A `scale=` / `decimals=` knob to pad the display:* it would have to rewrite
2126
+ the buffer under the caret while typing (`19.9` → `19.90` mid-edit), which
2127
+ needs a blur/commit point a TUI lacks — the same reason `D-integer-field`
2128
+ gave for not normalizing.
2129
+ - *A settable `step=`:* `D-float-field` rejected it over binary-float noise,
2130
+ which genuinely doesn't apply here (`BigDecimal` steps exactly). Kept out
2131
+ anyway, so the three numeric fields stay one shape; this is the field to
2132
+ revisit first if the knob is ever wanted.
2133
+
2134
+ ---
2135
+
2136
+ ## D-box-layouts — `Vertical` / `Horizontal`: declarative sugar with no `Auto` (2026-08-07)
2137
+
2138
+ **Status:** Accepted; implemented 2026-08-07 (`Component::Layout::Box`,
2139
+ `::Vertical`, `::Horizontal`, and the `Fixed` / `Percent` / `Expand` / `Insets`
2140
+ value types on `Layout`). Book ch3 pre-approved the shape and named the
2141
+ acceptance criterion — "added if and when the convenience pays for itself" —
2142
+ so what this entry records is that it did, and every choice inside it.
2143
+
2144
+ **Context.** `Layout::Absolute` was the only container: you override `rect=`
2145
+ and compute each child's rectangle. That is right for genuinely
2146
+ two-dimensional geometry and tedious for a stack. `examples/sampler.rb` carried
2147
+ **59 `Rect.new` sites**, dominated by vertical stacks with hand-accumulated
2148
+ offsets (`inner.top + 1`, `+ 4`, `+ 6`, `+ 8`, `+ 10`, `+ 12` in the
2149
+ PasswordField pane alone — renumbered by hand whenever a prompt gained a line),
2150
+ plus hand-rolled expansion (`[inner.height - 8, 2].max`) and hand-rolled cross
2151
+ clamps (`[inner.width, 30].min`). The cost was not that hand-rolling is
2152
+ impossible but that the code newcomers read to *learn* Tuile demonstrated the
2153
+ tedious version. The port took the sampler to 7 `Rect.new`.
2154
+
2155
+ **Decision — the vocabulary is `Fixed` / `Percent` / `Expand`, and there is no
2156
+ `Auto`.** Shrink-to-fit is the bottom-up `content_size` channel deleted in
2157
+ v0.9.0, and AGENTS.md's re-grow rule allows measurement back only as an
2158
+ optional, caller-side query. So urwid's `PACK`, CSS `auto`, FTXUI's non-`flex`
2159
+ default and Swing's `GroupLayout.PREFERRED_SIZE` are all out by construction.
2160
+ **This single omission is what keeps the feature sugar rather than a reopened
2161
+ wound:** a box is an `Absolute` subclass with a `rect=` override — no new
2162
+ dispatch phase, no framework hook, no child consultation — so it deletes
2163
+ cleanly if it ever fails to earn its place.
2164
+
2165
+ **Decision — alignment is legal because the cross extent is caller-supplied.**
2166
+ `align: :start | :center | :end` *looks* like it needs the child's width, which
2167
+ would be `content_size` again. It doesn't: it needs *a* width, and a `cross:`
2168
+ constraint provides one, so there is nothing to measure. This is the
2169
+ reframing that unblocked the cross axis after it had been parked as
2170
+ undesignable. Corollary: `:start/:center/:end` rather than
2171
+ `:left/:right` + `:top/:bottom`, because one concept should not have two
2172
+ vocabularies across the two classes.
2173
+
2174
+ **Decision — `Expand`, not `Fill`.** Every toolkit that models *both* concepts
2175
+ reserves *fill* for cross-axis stretch, not for claiming slack: GTK's
2176
+ `pack_start(child, expand, fill, padding)` takes them as separate booleans and
2177
+ `fill` only acts when `expand` is already true; Swing splits them as `weightx`
2178
+ vs `fill`; JavaFX as `setHgrow` vs `fillHeight`. Vaadin 8 names only the first
2179
+ and calls it `setExpandRatio`. Naming our main-axis constraint `Fill` would
2180
+ therefore use the industry's word for cross-axis stretch — sitting right next to
2181
+ `Percent[100]`, the thing that actually stretches. `Expand` also leaves `Fill`
2182
+ permanently free, so it can never return as a confusing near-synonym. (ratatui
2183
+ does call it `Fill` and CSS `flex-grow`; neither models the stretch concept
2184
+ separately, so neither had the collision to avoid.)
2185
+
2186
+ **Decision — defaults are `Fixed[1]` on the main axis and `Percent[100]`
2187
+ across it.** `Fixed[1]` because forms are the use case and almost every field is
2188
+ one row tall — the same reason Vaadin 8 bumps everything to the top by default.
2189
+ `Percent[100]` rather than `Expand[1]` because the cross axis holds exactly one
2190
+ child per slot, so nothing competes and a weight has nothing to mean there;
2191
+ **`Expand` therefore raises when passed as `cross:`**, which makes "what would
2192
+ `Expand[2]` mean across the axis?" unaskable rather than merely undocumented.
2193
+ JavaFX reached both defaults independently (`VBox.fillWidth` is `true`,
2194
+ alignment is `Pos.TOP_LEFT`).
2195
+
2196
+ **Decision — `spacing` and `padding` are box-global, never per-child.** Beyond
2197
+ brevity: *a gap between two items is a property of the sequence, not of either
2198
+ child*, so a per-child gap has an unresolvable ownership question — does child N
2199
+ own the gap after it, or child N+1 the gap before it? Both conventions exist and
2200
+ both confuse. Non-uniform gaps are expressed by **nesting** instead: a
2201
+ `Vertical.new(spacing: 0)` inside a `Vertical.new(spacing: 1)` groups rows
2202
+ tightly within a looser stack, which *states* the grouping rather than faking it.
2203
+ `GridBagConstraints.ipadx`/`ipady` is the per-child version, and that class —
2204
+ eleven fields, and the layout manager everyone agrees is hardest to learn — is
2205
+ the named tripwire for this tuple growing past three.
2206
+
2207
+ **Decision — `Percent` and `Expand` divide space that is actually available**
2208
+ (`extent - padding - spacing * (children - 1)`), so two `Percent[50]` children
2209
+ fit exactly instead of overflowing by the gap between them.
2210
+
2211
+ **Decision — the weighted-`Expand` remainder goes to the earliest children, one
2212
+ cell each.** Five equal `Expand`s in 12 rows give `3,3,2,2,2`. Auditable in one
2213
+ sentence, exact sum structural (`base * n + remainder == total`), and leftmost-
2214
+ first is the ecosystem convention (CSS `flex-grow`, ratatui `Fill`, urwid
2215
+ `weight`) so a user coming from elsewhere guesses right.
2216
+
2217
+ **Decision — over-subscription starves in declaration order; it never raises.**
2218
+ `Fixed` and `Percent` clamp to what is unassigned, so a child with nothing left
2219
+ gets an empty rect and paints nothing (`Rect#empty?` already covers zero *and*
2220
+ negative). Padding wider than the layout does the same to every child. No error,
2221
+ no solver, no reflow.
2222
+
2223
+ **Decision — `Insets` is keyword-only.** `java.awt.Insets` orders the four
2224
+ numbers top-left-bottom-right and `javafx.geometry.Insets` top-right-bottom-left
2225
+ — the same class name and the same four numbers, silently different: a live
2226
+ migration bug between two toolkits *in the same language*. `Insets[top: 1]` has
2227
+ no order to get wrong. `Data`'s inherited `[]` never dispatches through a `new`
2228
+ override, so both class methods carry the guard (found by the spec, not by
2229
+ reading).
2230
+
2231
+ **Decision — `Box` is a shared base, against the duplicate-don't-DRY rule.**
2232
+ `D-float-field` says duplicate rather than fold a *shallow* commonality into a
2233
+ base. This isn't shallow: the greedy pass is substantial and byte-for-byte
2234
+ identical except for which of `(left, top)` / `(width, height)` it reads, so
2235
+ `Box` parameterizes it behind two private hooks and `Vertical` / `Horizontal`
2236
+ are ~10-line concretes. That is the sanctioned cohesive base
2237
+ (`AbstractMasterDetail`), not an `AbstractView` junk drawer.
2238
+
2239
+ **Alternatives rejected.**
2240
+ - *A constraint attribute on `Component` (`child.layout_constraint = …`):*
2241
+ `content_size` wearing a hat. Even with the parent still doing the arithmetic,
2242
+ it re-establishes "the child declares its size wish", and every non-layout
2243
+ parent would have to ignore it. The constraint belongs to the parent–child
2244
+ *relationship*, which is why it lives at the `add` call. **JavaFX is this
2245
+ option in production and confirms the cost:** `HBox.setHgrow(node, …)` stores
2246
+ the constraint on the node (hence `HBox.clearConstraints`), so you must recall
2247
+ which container's static setter applies and a reparented node silently keeps
2248
+ stale constraints.
2249
+ - *A block-valued cross constraint (`Left { |avail| [avail, 30].min }`):*
2250
+ permitted by the re-grow rule, but no case needs it — `Fixed` already clamps to
2251
+ available, which is exactly the `[inner.width, 30].min` the sampler wrote by
2252
+ hand. A block is un-inspectable, awkward to spec, and `Absolute` remains the
2253
+ escape hatch for a genuinely computed width.
2254
+ - *"Last `Expand` absorbs the remainder":* matches ch3's hand-written idiom and
2255
+ guarantees an exact sum structurally, but degrades badly past two children —
2256
+ five equal `Expand`s in 12 rows floor to 2 each and dump **4** on the last, a
2257
+ visible 2× discrepancy, which is ch3's "one cell off is plainly visible on a
2258
+ character grid" amplified rather than avoided.
2259
+ - *Trailing-first one-at-a-time (`2,2,2,3,3`):* same fairness, and it would match
2260
+ ch3's remainder-to-the-right for the two-child case. Genuinely close; lost to
2261
+ ecosystem convention. **Known consequence:** for two children the layout gives
2262
+ the spare cell to the left/top while ch3's hand-written example gives it to the
2263
+ right. Different mechanisms, no shared code; ch3 says so.
2264
+ - *Largest-remainder / Hare quota:* fairest, least auditable — reverse-
2265
+ engineering which child got the extra cell is precisely the solver opacity ch3
2266
+ rejects.
2267
+ - *Priority tiers instead of weights (JavaFX `Priority.ALWAYS/SOMETIMES/NEVER`):*
2268
+ sidesteps remainder arithmetic entirely, but cannot express a 1:2 split, which
2269
+ is what a sidebar wants. Vaadin 8's ratio and CSS `flex-grow` both chose
2270
+ weights.
2271
+ - *`Min` / `Max` constraints:* deliberately not shipped, and the sampler shows
2272
+ what that costs — its main split (`(width / 3).clamp(20, 40)`) and its two
2273
+ sidebars (`min(16, width / 3)`) are caps on a *proportion*, unsayable in three
2274
+ constraints, so they keep a rect-callback `Absolute`. That is the intended
2275
+ division of labour: only the part needing arithmetic has any. Revisit only if
2276
+ capped proportions turn out to be common.
2277
+ - *`BorderLayout` / `BorderPane` / Textual's `dock:`:* nesting
2278
+ `Vertical(Fixed, Expand, Fixed)` covers it, and `ScreenPane` (content + status
2279
+ bar) already *is* one, hard-coded.
2280
+ - *Swing glue (`Box.createVerticalGlue`, `createRigidArea`, struts):* invisible
2281
+ filler *components*, needed only because `BoxLayout` has no per-child weight
2282
+ and doesn't pack from the start. Packing from the start plus `Expand` needs
2283
+ none, and grouped gaps are handled by nesting.
2284
+ - *Baseline alignment (Swing's `anchor` has `BASELINE`, `ABOVE_BASELINE_LEADING`,
2285
+ …):* a text-*rendering* concept. Every row of a character grid shares one
2286
+ baseline, so it is meaningless here.
2287
+ - *A full engine (Textual's CSS, Ink's embedded Yoga, ratatui's Cassowary
2288
+ solver):* those frameworks must ship one — Ink and Textual are retained-mode
2289
+ declarative, where the author never sees a rect, and ratatui's `Layout::split`
2290
+ is the only way to obtain one. Tuile hands the author coordinates, so **once
2291
+ `rect=` exists a layout is strictly optional sugar**, declinable per component,
2292
+ which none of them can offer. (This nuances ch3's "validated by the ecosystem":
2293
+ simple layout is validated by TUI *app architecture*, not by framework feature
2294
+ sets.)
2295
+
2296
+ **Consequences.**
2297
+ - Vaadin 8's perennial support question — *"`setExpandRatio` does nothing"*,
2298
+ answered by "the child also needs `setSizeFull()`" — exists precisely because a
2299
+ Vaadin 8 component has **both** its own size and an expand ratio: two size
2300
+ channels that must agree. Tuile cannot have that bug, because there is no
2301
+ component-side size to disagree with the constraint. The most common confusion
2302
+ in the toolkit we took `Expand` from is a direct consequence of the channel
2303
+ v0.9.0 deleted.
2304
+ - A future `Layout::Grid` should reuse `Fixed`/`Percent`/`Expand` verbatim per
2305
+ row and column, as JavaFX's `ColumnConstraints(percentWidth, hgrow)` does,
2306
+ rather than inventing a second vocabulary. That is also the path to the Form
2307
+ Layout `ideas/new-components.md` wants — which is blocked on a field
2308
+ label/helper seam, not on layout.
2309
+
2310
+ ---
2311
+
2312
+ ## D-wrap-leading-space — An indent is content; no flag, and no hanging indent (2026-08-12)
2313
+
2314
+ **Status:** Accepted; implemented 2026-08-12 (`StyledString#wrap_one`). Fixes
2315
+ [issue #2](https://github.com/mvysny/tuile/issues/2). The continuation half —
2316
+ hanging indent — is deliberately deferred, see the last section.
2317
+
2318
+ **Context.** `wrap_one` dropped a leading whitespace run whenever `line_w` was
2319
+ zero, which is equally true at the start of the *first* row as at the start of
2320
+ a continuation. So an indent never survived, even when the line fit the width
2321
+ and no wrapping happened at all. Since every `TextView` line goes through
2322
+ `wrap`, indented text could not be displayed: the downstream report was a
2323
+ nested agent/tool tree flattened into an ambiguous list, siblings and children
2324
+ indistinguishable and repeated leaf names reading as duplicates.
2325
+
2326
+ **Decision — this is a bug, patched in place; no opt-in flag.** `wrap`'s own
2327
+ rdoc already promised the fixed semantics ("leading whitespace dropped on
2328
+ wrapped *continuations*"), so the code was not implementing a design, it was
2329
+ missing a condition. Every widely-used wrapper agrees, and they differ only on
2330
+ what happens to continuations — the half Tuile already had right:
2331
+
2332
+ | Implementation | First-line indent | Continuation |
2333
+ |---|---|---|
2334
+ | Python `textwrap` (`drop_whitespace`) | kept — the docs carve it out explicitly | dropped |
2335
+ | CSS `pre-wrap` | kept | hangs past the margin |
2336
+ | GNU `fmt`, Emacs adaptive-fill | kept | **reused as the prefix** |
2337
+ | `fold -s` | kept (whitespace untouched) | kept |
2338
+ | Rust `textwrap`, Go wordwrap | kept (`initial_indent`) | `subsequent_indent` |
2339
+
2340
+ CSS `white-space: normal` is the one that looks like a counter-example and is
2341
+ not: eating the indent happens in the **collapsing** stage, which also squashes
2342
+ every interior run to a single space. Tuile does not collapse (`"one two"`
2343
+ keeps both spaces when they fit), so it is in the `pre-wrap` family, and doing
2344
+ half of collapsing — eat the indent, keep interior runs — was the incoherence.
2345
+
2346
+ **A flag was rejected on three counts.** It has no defensible default
2347
+ (default-preserve is the patch plus dead config; default-drop keeps the bug
2348
+ reachable and makes every caller learn a piece of trivia); `TextView` calls
2349
+ `wrap` itself with the viewport width, so a flag on `StyledString#wrap` is
2350
+ useless until mirrored as a `TextView` setter, turning one wart into two knobs
2351
+ across two layers; and the blast radius of just fixing it is confined to
2352
+ strings whose first row opens with space or tab, with `TextView` the sole
2353
+ in-gem caller.
2354
+
2355
+ **Decision — an over-wide indent is dropped, not given a row.** An indent that
2356
+ alone exceeds `width` folds into the same guard
2357
+ (`line_w.zero? && (!result.empty? || w > width)`) rather than falling through
2358
+ to the flush branch, which emitted an empty leading row. An indent wider than
2359
+ the viewport conveys no nesting, so losing it beats spending a row on it.
2360
+
2361
+ **Decision — whitespace-only input is preserved, diverging from Python.**
2362
+ `plain(" ").wrap(5)` now returns `[" "]` rather than `[""]`. Python drops
2363
+ it (its rule is "not dropped *if non-whitespace follows*"), but matching that
2364
+ needs a lookahead and buys nothing visible: `TextView#pad_to` pads to width, so
2365
+ the two render identically. The simpler rule — the first row keeps its leading
2366
+ run, period — wins.
2367
+
2368
+ **Deferred: the hanging indent.** A continuation still starts at column 0, so a
2369
+ leaf long enough to wrap re-lies about the tree — worse than the flattening,
2370
+ since a wrapped fragment of a deep leaf looks exactly like a new top-level
2371
+ entry. There is no app-side workaround (`TextView` owns the width and calls
2372
+ `wrap` internally, so a caller cannot wrap at `width - indent` and prefix). It
2373
+ is left out of this entry because it is a genuine behavior decision of its own:
2374
+ auto-inherit the first row's whitespace run as the continuation prefix, à la
2375
+ `fmt`/Emacs, versus an explicit knob that would again need mirroring on
2376
+ `TextView`. Current lean is auto with no flag — prose carries no leading space,
2377
+ so it is a no-op there, and the indented case is the only one with an opinion.
2378
+
2379
+ ---
2380
+
2381
+ ## D-select — `Select`: the enum field, claiming no printable key but Space (2026-08-12)
2382
+
2383
+ **Status:** Accepted; `Component::Select` implemented 2026-08-12, demoed in the
2384
+ sampler. Builds on `D-has-value`, `D-combobox` (the chrome/value split and the
2385
+ resolve-don't-store-an-index rule, both adopted verbatim), `D-radio-group` (the
2386
+ cursor-is-chrome rule) and `D-ambiguous-width`.
2387
+
2388
+ **Context.** A one-row closed-choice field: a face showing the selected item's
2389
+ label plus a `▾`, dropping open a `ListDropdown` of the options. `D-combobox`
2390
+ deferred it once ("filterable first"), on the assumption that it needed the
2391
+ read-only-field axis `D-has-value` parked for the forms layer. That assumption
2392
+ was an artifact of picturing a read-only `TextField` as the face; nothing gates
2393
+ this component.
2394
+
2395
+ **Decision — the criterion is enum vs. data, not item count.** A Select is for
2396
+ labels the *developer* authored: a closed set, stable order, known when the code
2397
+ is written (log level, sort order, line endings, Yes/No/Ask). A `ComboBox` is for
2398
+ items the app supplies at runtime, open-ended, with labels you don't control
2399
+ (countries, users, branches). Count is a *symptom*: a 12-value enum is still a
2400
+ Select, and a three-row country list from a DB is still a ComboBox, because next
2401
+ release it is 200 rows and the widget choice must not have to change. The
2402
+ discarded rule — "≤ 7 items → Select" — is actively harmful: it invites that
2403
+ country list in, which is how the type-ahead hole below was found.
2404
+
2405
+ **Decision — it claims no printable key but Space.** Enter, Space, ESC,
2406
+ `ListDropdown::MOVE_KEYS` and the mouse; *every other* printable bubbles past it
2407
+ to the app (key-dispatch rung 3). That is the capability unreachable by
2408
+ configuring a `ComboBox`, whose field eats printables unconditionally, and it is
2409
+ worth more than the type-ahead it replaces: a form's `s`-to-save and a layout's
2410
+ `1`/`2`/`3` pane jumps keep working while focus sits in a Select. Combined with
2411
+ having no caret — the strongest affordance a TTY has, not to be spent promising
2412
+ free-text entry over a four-value enum — that is the whole case for the
2413
+ component existing next to `RadioGroup`.
2414
+
2415
+ Space is safe as the single exception because **it was never available as a
2416
+ bubble key anyway**: `Button`, `Checkbox` and `RadioGroup` all already claim it,
2417
+ so no app can rely on it reaching past an interactive widget. Contrast a letter
2418
+ like `g`, which reaches the app from every one of those and is exactly what the
2419
+ rule protects. Space mirrors Enter throughout (opens when closed, commits when
2420
+ open), as on `Button`/`Checkbox`; `RadioGroup` claiming Space but not Enter is
2421
+ inherent — it has no open/closed state to move between — not an inconsistency.
2422
+
2423
+ **Decision — Home/End are declined, and `MOVE_KEYS` is unchanged.** They stay
2424
+ reaching the app, which `Screen::EDITING_KEYS` deliberately allows ("binding them
2425
+ app-wide to scroll the log pane is a real use case"). The PgUp/PgDn asymmetry is
2426
+ principled: those arrive *free* inside `MOVE_KEYS` and do real work on a dropdown
2427
+ that scrolls, whereas Home/End would need Select-side branches to do what a second
2428
+ arrow press already does. This also resolves what read as an open question in
2429
+ `ListDropdown`'s rdoc: the *exclusion* survives, the *rationale* doesn't — "they
2430
+ belong to the driving field, for caret movement" is a ComboBox policy, not a
2431
+ property of dropdowns. The driver decides, and both drivers decline.
2432
+
2433
+ **Decision — `Select` paints its own row; it composes no field.** A leaf widget
2434
+ (`< Component` + `HasValue`, `tab_stop? = true`, no children) that owns the
2435
+ dropdown as an overlay. Two consequences worth naming:
2436
+
2437
+ - **The face is *derived* from `value` at paint time, never a synced copy.** A
2438
+ `Label` child would have meant a second copy of the face text to keep in step
2439
+ from `value=`, `item_label=` and construction — the drift `ComboBox` pays for
2440
+ only because its field is genuinely editable and holds a *query*. Nothing here
2441
+ needs that, so nothing here has it. The well is read from the theme each paint
2442
+ for the same reason.
2443
+ - **The tab-stop rule stays ordinary.** The composing wrappers (`ComboBox`,
2444
+ `IntegerField`, the groups) leave `tab_stop?` false because their inner widget
2445
+ carries the stop; a Select has no inner widget, so it claims the stop itself,
2446
+ exactly as `Checkbox` does. Had the face been an (inert, non-tab-stop) `Label`
2447
+ child, Select would have been the first composing wrapper needing to claim the
2448
+ stop anyway — the letter of the rule reversed to preserve its purpose. Not
2449
+ having the child removes the wrinkle instead of documenting it.
2450
+
2451
+ **Decision — promote `ComboBox#anchor` to `ListDropdown#anchor_to`.** Select needs
2452
+ byte-identical vertical geometry, and `D-float-field`'s duplicate-don't-DRY rule
2453
+ **does not apply**: that licensed copying a *shell* around three genuine
2454
+ differences, whereas this is the same computation with zero differences, so a
2455
+ later fix to the flip rule would land in one copy and silently not the other —
2456
+ and the symptom appears only near a screen edge, which is invisible under test.
2457
+ The promotion threshold is the project's existing one (`D-color-slots`: "a
2458
+ *second* built-in needing the same thing"). Two rulings ride along:
2459
+
2460
+ - **Width stays a caller-supplied parameter** (defaulting to the anchor's), so
2461
+ `ComboBox` keeps its lines-up-with-the-field policy and Select keeps its
2462
+ measured one, and `anchor_to` never measures content itself. Same shape as
2463
+ `D-box-layouts`' "`align:` is legal only because the cross extent is
2464
+ caller-supplied", and it keeps Select's measuring within the top-down re-grow
2465
+ rule: an optional, caller-side query feeding a rect the caller then assigns.
2466
+ - **Horizontally we slide, vertically we flip.** Covering the driver would hide
2467
+ the value being chosen, so vertically there are only above and below; sharing
2468
+ the driver's columns is exactly what's wanted, so an overrun slides left and
2469
+ keeps the left edges aligned. A horizontal flip would either overlap the face
2470
+ or leave a gap. A label wider than the screen clips — `List` has no horizontal
2471
+ scrolling.
2472
+
2473
+ This is deliberately *not* the full anchored-Popover extraction, which wants
2474
+ generalizing for callers whose anchoring genuinely differs (a context menu
2475
+ anchors to a *point*, a submenu to a right edge with horizontal flipping). Build
2476
+ Popover when the second *kind* of anchoring appears, not the second caller of the
2477
+ same kind; `anchor_to` then moves down to it with nothing thrown away.
2478
+
2479
+ **Decision — a scrolling `ListDropdown` gets a scrollbar** (a `ComboBox` fix
2480
+ shipped in the same work, and the only non-additive part of it). `anchor_to` owns
2481
+ the toggle, being the one place that knows both the row count and the height it
2482
+ just chose. *Rejected: an `:auto` mode on `List`.* It looks like the general fix
2483
+ and carries a silent corruption — visibility would become a function of
2484
+ `rect.height`, but the padded-line cache is rebuilt from `on_width_changed`, a
2485
+ width-only hook, so a height-only resize would flip the scrollbar, shrink
2486
+ `content_width`, and leave every row padded to the old width: one column off,
2487
+ no exception, nothing in the diff to notice. Making it safe means a height-change
2488
+ hook and a wider cache-invalidation surface for every `List` in the gem, to serve
2489
+ two callers that already know the answer.
2490
+
2491
+ **Alternatives rejected.**
2492
+ - *Prefix type-ahead, single-key* (`g` jumps to the first item starting with
2493
+ `g`): silently wrong. With Finland / Fiji / Jamaica, typing `fij` selects
2494
+ *Jamaica* — each key is a fresh single-char match — and nothing tells the user
2495
+ anything went wrong.
2496
+ - *Prefix type-ahead, timed accumulating buffer* (the standard GUI fix: `JList`,
2497
+ GTK, Finder): it **is** the ComboBox query, hidden. A buffer that filters the
2498
+ candidate set is a query string; concealing it and clearing it on a timer makes
2499
+ it worse, not lighter, and reintroduces the second piece of state Select exists
2500
+ to avoid. If you are holding query state, showing it is strictly better — and
2501
+ showing it is a ComboBox. Worse here than in a GUI for a TUI-specific reason:
2502
+ the timeout leans on inter-keystroke timing, and a terminal degrades exactly
2503
+ that signal (bytes arriving in one read burst merge into a single key; a paste
2504
+ has no gaps at all). Retiring type-ahead also retires the "make labels
2505
+ prefix-unique" workaround that existed only to rescue it.
2506
+ - *Cycle-in-place* (`◂ Dark ▸`, Space/Left/Right, no popup) for 2–4 options: you
2507
+ select blindly. The values you are choosing *between* are never on screen — you
2508
+ discover them one at a time by cycling, with no way to see the set or know how
2509
+ many there are. The dropdown is better at every item count, so the
2510
+ `ListDropdown` face is the only face, and the vocabulary does not grow a fourth
2511
+ closed-choice widget (cf. `D-box-layouts`' "there is no `Auto`"). *Re-grow
2512
+ rule:* if it returns it is a **face** on this component (a `dropdown: false`
2513
+ knob over the identical value seam), never a separate component, and it needs a
2514
+ real argument about visibility rather than a row-budget one.
2515
+ - *A read-only `TextField` as the face* (the survey's framing): a read-only text
2516
+ field is still a text field — the inherent-bg well, the caret machinery, the
2517
+ horizontal scroll window, and an opt-out from `bg_color` inheritance. None of
2518
+ it is wanted, and none of it has to be reasoned about once the widget paints
2519
+ one row itself.
2520
+ - *A shared base with `RadioGroup`* (`AbstractClosedChoiceField`): the ~15-line
2521
+ `items=` / `item_label=` / `label_for` shell is duplicated instead, per
2522
+ `D-float-field`. The test is whether the commonality is a *shell around genuine
2523
+ differences* or the *same computation* — `anchor_to` is the latter (extract), the
2524
+ items shell is the former (duplicate). The three differences a base would have
2525
+ to paper over with hooks: row rendering (`(*) label` glyphs vs. a bare label,
2526
+ since a Select shows its selection on the *face*), cursor semantics (roams and
2527
+ Space commits the row it's on, vs. the highlight *being* the pending selection),
2528
+ and where the rows live (always, in the component's own rect, vs. only while
2529
+ open, in a `Popup`'s). Three hooks over fifteen lines, reached through
2530
+ inheritance, is the converter-strategy-by-inheritance shape `D-float-field`
2531
+ rejected — and it would couple two widgets that should stay free to diverge.
2532
+ This is the third copy of that shell, the same count `IntegerField` /
2533
+ `FloatField` / `BigDecimalField` reached; a *fourth* is when to re-argue it.
2534
+
2535
+ **Consequences.**
2536
+ - *Empty value* (`value = nil`, items present) is legal and normal — the optional
2537
+ enum field — so there is no placeholder string: a blank face plus the `▾`, and
2538
+ the dropdown opens with the highlight on row 0.
2539
+ - *Empty items* does not open a dropdown at all, keeping `ComboBox`'s auto-close
2540
+ behavior: a 10-row empty tinted panel reads as a broken list rather than as
2541
+ "nothing to pick". An item-less Select is almost always a programming bug, not
2542
+ a state to design a UI for, so nothing is spent on it beyond not misleading the
2543
+ user — no placeholder row, no "(no items)" label, no status hint. A
2544
+ `Tuile.logger.warn` on the open attempt was considered and declined: the
2545
+ attempt is keystroke-driven, so it would flood a host's log on autorepeat, and
2546
+ an app may legitimately pass through item-less while loading. Enter/Space/Down
2547
+ are claimed either way — one rule, no branch. (An item-less Select is arguably
2548
+ a *disabled* field, which touches the read-only/disabled axis `D-has-value`
2549
+ parked for the forms layer. Not designed here, not foreclosed either.)
2550
+ - The dropdown is measured to the widest label plus `List`'s **two** row gutters
2551
+ (`pad_to_row` ellipsizes to `content_width - 2`, one leading and one trailing
2552
+ column), plus the scrollbar column when the items outnumber the visible rows —
2553
+ and never narrower than the Select itself. The field width is a *floor* rather
2554
+ than an alternative to measuring: a panel narrower than its own face reads as an
2555
+ unrelated widget instead of as that field's menu (a ~8-column menu under a
2556
+ 30-column field, in the sampler), so the common case lines both edges up exactly
2557
+ as a `ComboBox`'s does and only an over-long label pushes it wider. A dropdown
2558
+ the *screen* clamps shorter still scrolls without having bought the scrollbar
2559
+ column, so its labels ellipsize one early — the `ComboBox` trade, in the one
2560
+ case measuring cannot predict.
2561
+ - A second driver **confirms** three of `ListDropdown`'s speculative rulings
2562
+ rather than straining them: ESC and Enter really do carry driver-specific tails
2563
+ (Select's ESC closes without committing and has no query to revert), the
2564
+ non-focusable `Menu` really does give the same re-entrancy safety
2565
+ `ComboBox#active=` leans on, and filtering / row rendering / the commit action
2566
+ really do vary.