tuile 0.10.0 → 0.12.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 +109 -64
- data/DECISIONS.md +1299 -22
- data/README.md +18 -13
- data/TERMINOLOGY.md +61 -0
- data/book/02-repaint.md +1 -1
- data/book/03-layout.md +154 -9
- data/book/05-focus.md +2 -0
- data/book/06-theming.md +1 -1
- data/book/07-components.md +202 -37
- data/book/README.md +3 -1
- data/examples/file_commander.rb +5 -4
- data/examples/sampler.rb +320 -133
- data/ideas/arrow-key-navigation.md +205 -0
- data/ideas/new-components.md +26 -12
- data/lib/tuile/buffer.rb +7 -7
- data/lib/tuile/component/big_decimal_field.rb +199 -0
- data/lib/tuile/component/button.rb +1 -1
- data/lib/tuile/component/checkbox.rb +11 -10
- data/lib/tuile/component/checkbox_group.rb +31 -26
- data/lib/tuile/component/combo_box.rb +18 -33
- data/lib/tuile/component/float_field.rb +161 -0
- data/lib/tuile/component/info_window.rb +1 -1
- data/lib/tuile/component/label.rb +14 -14
- data/lib/tuile/component/layout/box.rb +316 -0
- data/lib/tuile/component/layout/horizontal.rb +40 -0
- data/lib/tuile/component/layout/vertical.rb +41 -0
- data/lib/tuile/component/layout.rb +149 -1
- data/lib/tuile/component/list.rb +291 -216
- data/lib/tuile/component/list_dropdown.rb +82 -24
- data/lib/tuile/component/notification.rb +317 -0
- data/lib/tuile/component/picker_window.rb +3 -3
- data/lib/tuile/component/popup.rb +8 -10
- data/lib/tuile/component/progress_bar.rb +1 -1
- data/lib/tuile/component/radio_group.rb +32 -30
- data/lib/tuile/component/select.rb +251 -0
- data/lib/tuile/component/text_area/wrapped_text.rb +320 -0
- data/lib/tuile/component/text_area.rb +79 -273
- data/lib/tuile/component/text_field.rb +1 -1
- data/lib/tuile/component/text_view.rb +191 -177
- data/lib/tuile/component/window.rb +8 -8
- data/lib/tuile/component.rb +5 -5
- data/lib/tuile/screen.rb +1 -1
- data/lib/tuile/styled_string.rb +25 -15
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile/vertical_scroll_bar.rb +6 -6
- data/lib/tuile.rb +4 -0
- data/sig/tuile.rbs +1670 -406
- metadata +11 -1
data/DECISIONS.md
CHANGED
|
@@ -65,7 +65,7 @@ descendants pick it up; a widget with its own explicit bg
|
|
|
65
65
|
(`TextField`/`TextArea` wells) keeps its look. `nil` keeps its existing
|
|
66
66
|
meaning — "inherit upward," with the terminal default as the root of the
|
|
67
67
|
chain. Self-painters route the effective bg through a single choke point,
|
|
68
|
-
`Component#
|
|
68
|
+
`Component#draw_text` / `#draw_char`.
|
|
69
69
|
|
|
70
70
|
**Alternatives rejected.**
|
|
71
71
|
- *Explicit per-component, no inheritance* (Textual/ratatui end):
|
|
@@ -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
|
|
789
|
-
is the native gesture (Vaadin's checkbox is Space-only
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
widget
|
|
798
|
-
|
|
799
|
-
|
|
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
|
|
@@ -995,7 +1004,7 @@ much of `List` to reuse and what the value should be. (A single-select group
|
|
|
995
1004
|
from the list. Correct, and about 15 lines of forwarding plus a subclass, all
|
|
996
1005
|
to protect a promise nothing relied on (see `D-boolean-fields`' rejected Enter
|
|
997
1006
|
reservation). Reach for it only if a driver genuinely needs Enter for itself.
|
|
998
|
-
- *Paint the rows directly (`< Component`, `
|
|
1007
|
+
- *Paint the rows directly (`< Component`, `draw_text` per row):* wrong here.
|
|
999
1008
|
The cursor-distinct-from-selection structure *is* `List`, a checkbox group is
|
|
1000
1009
|
the one most likely to be long enough to scroll, and painting rows means
|
|
1001
1010
|
re-implementing the cursor, the viewport, the scrollbar and the mouse
|
|
@@ -1101,7 +1110,7 @@ list floors at 0 and a `Cursor::Limited` keeps its own notion of "last". The
|
|
|
1101
1110
|
moving a piece of user-facing navigation state. It also does not scroll into
|
|
1102
1111
|
view (`move_viewport_to_cursor` is private to `List`'s own key/mouse paths),
|
|
1103
1112
|
so on a scrolling group it parks the cursor off-screen. Left to the app.
|
|
1104
|
-
- *Paint the rows directly (`< Component` + `
|
|
1113
|
+
- *Paint the rows directly (`< Component` + `draw_text`), the fallback the idea
|
|
1105
1114
|
note held open:* it existed to escape the four frictions above, which the
|
|
1106
1115
|
interaction model removes. Composing a `List` then costs nothing and keeps the
|
|
1107
1116
|
cursor, viewport, scrollbar and mouse arithmetic in one place.
|
|
@@ -1152,7 +1161,7 @@ caret on the index axis was never in question — edits, word jumps and
|
|
|
1152
1161
|
redefinition.
|
|
1153
1162
|
|
|
1154
1163
|
**Decision — scroll horizontally instead of capping to the width.** `left_column`
|
|
1155
|
-
follows the caret by the minimum needed, mirroring `TextArea#
|
|
1164
|
+
follows the caret by the minimum needed, mirroring `TextArea#scroll_top_row`.
|
|
1156
1165
|
This deletes the width-derived capacity rule rather than fixing its arithmetic:
|
|
1157
1166
|
the old `rect.width - 1` cap existed to reserve a column for the caret parked
|
|
1158
1167
|
past the last glyph, and that reservation now lives in the scroll clamp
|
|
@@ -1197,7 +1206,7 @@ exactly the width-vs-length confusion this note removes.
|
|
|
1197
1206
|
- **Cache the index↔column mapping.** A single line of text is short and
|
|
1198
1207
|
`Buffer.display_width` is memoized per grapheme, so each walk is a few hash
|
|
1199
1208
|
reads. A cache would need invalidating on every mutation — `TextArea`'s
|
|
1200
|
-
`@
|
|
1209
|
+
`@wrap` hazard — for no measured gain.
|
|
1201
1210
|
|
|
1202
1211
|
**Consequences.** `TextField` no longer has a maximum length by default;
|
|
1203
1212
|
an app that wants one sets `max_text_length`. `ComboBox` and `IntegerField`
|
|
@@ -1265,7 +1274,7 @@ not just a bug fix, and it matches every editor.
|
|
|
1265
1274
|
- **Iterate characters, summing per-character widths.** Gets the column totals
|
|
1266
1275
|
right (a mark measures 0, a wide glyph 2) and is a smaller diff, but it can
|
|
1267
1276
|
split a cluster across a row break — leaving a bare base letter on one row and
|
|
1268
|
-
a mark with no base on the next, which `Buffer#
|
|
1277
|
+
a mark with no base on the next, which `Buffer#set_text` drops entirely. It
|
|
1269
1278
|
also keeps termination accidental.
|
|
1270
1279
|
- **Wait for the cluster-caret redesign and do both at once.** The redesign is
|
|
1271
1280
|
parked, and this fix does not depend on it: the caret stays a character index
|
|
@@ -1307,7 +1316,7 @@ the slice end.
|
|
|
1307
1316
|
**(2) `Buffer` could not model a cluster wider than two columns.** `put_char`
|
|
1308
1317
|
special-cased `w == 2` and wrote exactly one continuation cell. A cluster
|
|
1309
1318
|
measuring 4 wrote its origin, no continuations, and left the next three cells
|
|
1310
|
-
holding whatever was there before — while `
|
|
1319
|
+
holding whatever was there before — while `set_text` advanced the column by 4.
|
|
1311
1320
|
Stale cells plus a cursor the flush positions from a wrong model.
|
|
1312
1321
|
|
|
1313
1322
|
**Decision — `emoji: :rgi`, in one named constant, at every call site.**
|
|
@@ -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
|
|
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
|
|
|
@@ -1931,7 +1946,7 @@ makes the orphan bug unreachable.
|
|
|
1931
1946
|
question about a loud rename migration purely to convert those silent breaks
|
|
1932
1947
|
into `NoMethodError`s. (2) `max_text_length` would silently change meaning,
|
|
1933
1948
|
characters → clusters. (3) It adds a second invalidated cache to a class that
|
|
1934
|
-
already carries one (`TextArea`'s `@
|
|
1949
|
+
already carries one (`TextArea`'s `@wrap`), for state a per-keystroke
|
|
1935
1950
|
walk recomputes in 62µs.
|
|
1936
1951
|
- **Store an `Array` of clusters instead of a `String`.** Insertion is where
|
|
1937
1952
|
cluster-native storage bites back: typing a combining mark after `e` would
|
|
@@ -1959,3 +1974,1265 @@ 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.
|
|
2567
|
+
|
|
2568
|
+
## D-list-items — `List` takes items + a renderer, rendered lazily (2026-08-14)
|
|
2569
|
+
|
|
2570
|
+
**Status:** Accepted; implemented 2026-08-14, with the five composers folded onto
|
|
2571
|
+
it in the same series. Builds on `D-has-value` (typed, not stringly),
|
|
2572
|
+
`D-combobox` (resolve an index, never store one), `D-float-field` (duplicate
|
|
2573
|
+
rather than fold a shallow commonality) and the top-down layout rule
|
|
2574
|
+
(`D-box-layouts`). Delivers the first half of the "typed items + data provider on
|
|
2575
|
+
`List`" item that gated List Box, Grid and Virtual List.
|
|
2576
|
+
|
|
2577
|
+
**Context.** `List` took pre-rendered rows: `lines=` stored `Array<StyledString>`
|
|
2578
|
+
and the callbacks handed one back. Two symptoms, both of them the same missing
|
|
2579
|
+
seam:
|
|
2580
|
+
|
|
2581
|
+
- Six internal call sites read `->(index, _line) { @items[index] }` — every
|
|
2582
|
+
composer obeying the resolve-an-index rule *by hand*, against its own array,
|
|
2583
|
+
because the framework handed back a string.
|
|
2584
|
+
- Four components (`ComboBox`, `Select`, `RadioGroup`, `CheckboxGroup`) kept a
|
|
2585
|
+
private copy of the `@items` / `@item_label` / `label_for` / `rebuild_rows`
|
|
2586
|
+
shell. `D-select` set the trigger for re-arguing a shared base at the *fourth*
|
|
2587
|
+
copy; this is it.
|
|
2588
|
+
|
|
2589
|
+
**Decision — externalize rendering on the generic component.** `List` holds
|
|
2590
|
+
`items` (any objects) plus a `renderer` (item → row); `on_item_chosen` and
|
|
2591
|
+
`on_cursor_changed` hand back the item. This is the `cop` rule the gem already
|
|
2592
|
+
follows elsewhere — a domain component takes data, a generic one takes strategies
|
|
2593
|
+
— arriving late at the one component that had grown up without it.
|
|
2594
|
+
|
|
2595
|
+
**Not a shared base class.** The alternative reading of four duplicated shells is
|
|
2596
|
+
"extract `AbstractItemsComponent`". That is exactly the `parse`/`format`-hook base
|
|
2597
|
+
`D-float-field` rejected, one level up: it would need a render hook, a
|
|
2598
|
+
commit-gesture hook and a where-do-rows-live hook to span a dropdown driver and a
|
|
2599
|
+
row-per-item group. The duplication was a symptom of a missing *seam*, not of a
|
|
2600
|
+
missing *ancestor*, and adding the seam deleted the duplication that actually
|
|
2601
|
+
mattered while leaving each widget's own gesture policy alone.
|
|
2602
|
+
|
|
2603
|
+
**Decision — render lazily, at paint, memoized per row.** Only the rows in the
|
|
2604
|
+
viewport are rendered; the cache is dropped by `items=`, `renderer=`, a width
|
|
2605
|
+
change or `scrollbar_visibility=`. Eager rendering (render everything in `items=`,
|
|
2606
|
+
keeping today's shape) was the smaller diff and was rejected on three counts:
|
|
2607
|
+
|
|
2608
|
+
- It made `renderer=` and every width change O(all items). That cost was already
|
|
2609
|
+
being paid — a 50k-row `LogWindow` re-ellipsized all 50k rows on *every*
|
|
2610
|
+
terminal resize — and the lazy version deletes `@padded_lines`,
|
|
2611
|
+
`rebuild_padded_lines` and the blank-row field along with it. The refactor came
|
|
2612
|
+
out net *smaller*.
|
|
2613
|
+
- It would have forced a redesign for a lazy data provider later. Rendering
|
|
2614
|
+
on demand is the half of "virtual list" that touches every method; sourcing on
|
|
2615
|
+
demand can then be added behind `items` without moving anything.
|
|
2616
|
+
- It makes `refresh_rows` (below) cheap enough to be the *normal* answer to
|
|
2617
|
+
"my rendering changed", which is what let the groups stop rebuilding rows.
|
|
2618
|
+
|
|
2619
|
+
Two prices, both accepted and both documented in the class rdoc: **a renderer runs
|
|
2620
|
+
at paint time**, so it must be pure and cheap (work that reaches a service belongs
|
|
2621
|
+
in the item), and **search must render without memoizing** — `select_next` scans
|
|
2622
|
+
with the uncached path, since one failed scan over a long list would otherwise
|
|
2623
|
+
grow the cache to one row per item. That asymmetry is invisible in the code and
|
|
2624
|
+
silent under test, so it is pinned by a spec that asserts the cache is still empty
|
|
2625
|
+
after a failed scan.
|
|
2626
|
+
|
|
2627
|
+
**Decision — `refresh_rows` for a renderer whose *inputs* moved.** A renderer
|
|
2628
|
+
closing over mutable state (`RadioGroup`'s selection, `CheckboxGroup`'s `Set`)
|
|
2629
|
+
produces different rows from the same items and the same proc, which no setter can
|
|
2630
|
+
detect. The alternatives were worse: re-assigning `content.renderer =
|
|
2631
|
+
content.renderer` is a ritual whose meaning isn't visible at the call site, and
|
|
2632
|
+
having `value=` rebuild every row is the O(n) pass this decision just deleted.
|
|
2633
|
+
|
|
2634
|
+
**Consequences.**
|
|
2635
|
+
|
|
2636
|
+
- **`lines=` stays, and is not deprecated.** It splits on `\n`, rstrips, and
|
|
2637
|
+
stores the resulting `StyledString`s *as the items* under the default renderer —
|
|
2638
|
+
so for a line-populated list "the item" is exactly what the callbacks handed
|
|
2639
|
+
back before, and all 2191 pre-existing examples passed unmodified. It is the
|
|
2640
|
+
honest API for a log or a static report, not a compatibility shim.
|
|
2641
|
+
Reconsidered right after implementation ("shouldn't `items=` be the only
|
|
2642
|
+
input?") and re-affirmed on a checkable difference: `items = ["a\nb"]` is one
|
|
2643
|
+
row, `lines = ["a\nb"]` is two, and the split-plus-style-preserving-rstrip a
|
|
2644
|
+
caller would have to repeat lives in two privates. Retiring it would need
|
|
2645
|
+
`StyledString.parse_lines(entries)` as a public class method so the coercion
|
|
2646
|
+
sits with the type — worth doing only if a second input flavor ever wants it.
|
|
2647
|
+
- **The appenders were removed, because they are the one thing a provider can't
|
|
2648
|
+
have.** `add_item` / `add_items` / `add_line` / `add_lines` are gone. This
|
|
2649
|
+
decision's second half is sourcing on demand, and the promise that it "can then
|
|
2650
|
+
be added behind `items` without moving anything" is only true while every input
|
|
2651
|
+
is a whole-collection assignment: `add_items` mutates `@items`, which a provider
|
|
2652
|
+
that computes a window on request has nothing to mutate, so the method would
|
|
2653
|
+
have had to either raise for provider-backed lists (a mode) or force the
|
|
2654
|
+
provider to materialize (defeating it). Removing four methods now is cheaper
|
|
2655
|
+
than either. No caller existed — in the gem, in the examples, or in the two
|
|
2656
|
+
downstream apps: every surviving `add_line` is `TextView`'s, including
|
|
2657
|
+
`LogWindow`'s, which is the coherent line to draw (**incremental append is a
|
|
2658
|
+
`TextView` feature; a `List` is a snapshot of a collection**). The price, paid
|
|
2659
|
+
knowingly: an app that tails re-assigns and so drops the row cache, re-rendering
|
|
2660
|
+
a viewport's worth of rows per incoming row where an append preserved every
|
|
2661
|
+
cached row. That is bounded by the viewport, not the list — the 50k-row case
|
|
2662
|
+
this decision was measured against is `TextView`'s now.
|
|
2663
|
+
- **The naming wart around them was deleted, not deprecated for long:** the
|
|
2664
|
+
`lines` **reader** and `ListDropdown#lines=` / `#lines` are gone. The reader
|
|
2665
|
+
returned `items` — it could have returned the *rendered* rows instead, which
|
|
2666
|
+
would have kept two specs asserting rendered text through it, but that forces a
|
|
2667
|
+
full render on a getter and lies about what a list of typed items contains
|
|
2668
|
+
(those specs moved to asserting what is painted, which is what they were really
|
|
2669
|
+
about). The dropdown's pass-throughs had exactly one caller in the wild —
|
|
2670
|
+
pikuri-tui's `SlashMenuPopup`, which pre-rendered its rows and kept `@matches`
|
|
2671
|
+
beside them, i.e. the parallel array this decision exists to delete. All three
|
|
2672
|
+
first shipped as a docs-only deprecation (`@deprecated` + a CHANGELOG line,
|
|
2673
|
+
since a runtime notice would have to go through `Tuile.logger` — `Kernel.warn`
|
|
2674
|
+
writes stderr into the frame a TUI is painting, and a logger defaulting to
|
|
2675
|
+
`IO::NULL` is a notice nobody reads), then were removed *inside the same
|
|
2676
|
+
unreleased 0.12.0* once both downstream apps had migrated: a deprecation
|
|
2677
|
+
nobody ever consumed is dead weight in the API, and virtui's surviving
|
|
2678
|
+
`build_lines` / `lines=` calls confirm the split was drawn in the right place.
|
|
2679
|
+
- **The block form moved to `build_lines`, keeping `lines` a plain reader.** The
|
|
2680
|
+
defect was the overload — `lines` meant "read the items" or "replace them all"
|
|
2681
|
+
depending on `block_given?`, which is half of why the reader read as a lie. A
|
|
2682
|
+
verb name splits the two with no semantic change (virtui's two `update` paths
|
|
2683
|
+
migrate by one word), and leaves `build_items` as the obvious sibling if a
|
|
2684
|
+
typed-items builder is ever wanted. Deleting it outright was the alternative —
|
|
2685
|
+
the body is three lines a caller can write — and was rejected because virtui
|
|
2686
|
+
reads `buffer.size` mid-build to record `Cursor::Limited` positions, so the
|
|
2687
|
+
buffer being a plain growing `Array` is part of the contract worth pinning with
|
|
2688
|
+
a spec rather than re-deriving per app.
|
|
2689
|
+
- **One item is one row.** A multi-line rendering keeps its first line: a `\n`
|
|
2690
|
+
reaching the buffer corrupts the frame, and any other rule (raise, split into
|
|
2691
|
+
several rows) breaks the index-is-the-item identity the whole change rests on.
|
|
2692
|
+
- **`items=` still leaves a stale cursor alone**, and the clamp stays in the
|
|
2693
|
+
caller (`RadioGroup#items=`), *before* the assignment so the single
|
|
2694
|
+
`on_cursor_changed` reports the final row. Moving the clamp into `List` was
|
|
2695
|
+
tempting and rejected: it would change behavior for tailing lists and would
|
|
2696
|
+
break that ordering guarantee for the one component that needs it.
|
|
2697
|
+
- **No measuring was added.** `Select` still measures its own labels caller-side
|
|
2698
|
+
and assigns the rect it computed; `List` gained no width reader. The top-down
|
|
2699
|
+
re-grow rule is unchanged.
|
|
2700
|
+
- **`file_commander`'s `descend` was broken** and this is what surfaced it: it
|
|
2701
|
+
called `Rainbow.uncolor` on the callback's second argument, which had been a
|
|
2702
|
+
`StyledString` (no `#gsub`) since long before this change, so Enter on a
|
|
2703
|
+
directory raised. Holding the entry hashes as items — the name separate from its
|
|
2704
|
+
rendering — is the shape that makes the bug unsayable, and the PTY test now
|
|
2705
|
+
presses Enter.
|
|
2706
|
+
|
|
2707
|
+
## D-scroll-nomenclature — `row` is the grid, `line` is `\n`, `items` are domain objects (2026-08-14)
|
|
2708
|
+
|
|
2709
|
+
**Status:** Accepted; implemented 2026-08-14. Builds on `D-list-items` (which
|
|
2710
|
+
made the item vocabulary real), `D-text-area-columns` and `D-text-field-axes`
|
|
2711
|
+
(which named the index-vs-column axes inside the inputs) and
|
|
2712
|
+
`D-ambiguous-width` (whose `display_width` is the column authority).
|
|
2713
|
+
|
|
2714
|
+
**Context.** Three scrolling components had grown three vocabularies for the
|
|
2715
|
+
same four concepts — a content unit, a wrapped unit, a viewport-relative row,
|
|
2716
|
+
and the offset between the last two. `TextView` said `hard_line` /
|
|
2717
|
+
`physical_line` / `row_in_viewport` / `top_line`; `List` said `item` / `item` /
|
|
2718
|
+
`row_in_viewport` / `top_line`; `TextArea`, the newest, invented "display row"
|
|
2719
|
+
and was the outlier on every axis. Worse, the *foundation* disagreed with
|
|
2720
|
+
itself: `Buffer#row_text` said row while `Buffer#set_line` said line, in one
|
|
2721
|
+
class; `line_count` meant screen rows in `VerticalScrollBar.new` and `\n` units
|
|
2722
|
+
in `TextView::Region`; and `List::Cursor#handle_key(key, line_count,
|
|
2723
|
+
viewport_lines)` carried an item count and a row count in one public signature,
|
|
2724
|
+
calling both "lines".
|
|
2725
|
+
|
|
2726
|
+
**Decision.** `row` is the terminal grid unit, everywhere, with no exceptions; a
|
|
2727
|
+
wrapped unit *is* a row, because wrapping is the operation that turns text into
|
|
2728
|
+
rows. `line` means exactly what `String#lines` returns and is never a
|
|
2729
|
+
coordinate. `items` are the domain objects a widget renders. The offset is
|
|
2730
|
+
`scroll_top_row`, the extent `viewport_rows`, the viewport-relative coordinate
|
|
2731
|
+
`row_in_viewport`. Two space rules carry the rest: an object with only one row
|
|
2732
|
+
space leaves `row` unqualified; a component holding both qualifies the viewport
|
|
2733
|
+
one. AGENTS.md's *Nomenclature* section holds the invariants, TERMINOLOGY.md the
|
|
2734
|
+
definitions.
|
|
2735
|
+
|
|
2736
|
+
**The survey that decided it — and it cuts against the conclusion.** The
|
|
2737
|
+
*official* word for a terminal row is `line`, not `row`: ECMA-48 addresses the
|
|
2738
|
+
presentation component by "line position", and its scroll primitives are named
|
|
2739
|
+
`IL` **INSERT LINE** / `DL` **DELETE LINE** operating on screen rows; terminfo's
|
|
2740
|
+
capabilities are `lines`/`cols`; POSIX's env vars are `LINES`/`COLUMNS`; VT100
|
|
2741
|
+
documented "24 lines by 80 columns"; and Textual's `Widget.render_line(y)`
|
|
2742
|
+
returns a `Strip` for screen row *y*. The kernel and the modern TUI world say
|
|
2743
|
+
row (`struct winsize.ws_row`, `stty rows`, `crossterm::terminal::size() ->
|
|
2744
|
+
(columns, rows)`, and decisively `TTY::Screen.rows`, which Tuile is built on).
|
|
2745
|
+
**`line` is unavailable to Tuile for exactly the reason ECMA-48 never hit the
|
|
2746
|
+
problem: ECMA-48 has no text buffer and no word wrap.** It had one meaning for
|
|
2747
|
+
"line", so it took the good word. Tuile has two and must give the free word to
|
|
2748
|
+
one of them — `row` is free, `line` is not, because Ruby owns it.
|
|
2749
|
+
|
|
2750
|
+
**The objection, and what actually answers it.** `row` and `line` are
|
|
2751
|
+
near-synonyms in English *and* in terminal usage, so a load-bearing distinction
|
|
2752
|
+
resting on them looked like a permanent confusion source — and the survey found
|
|
2753
|
+
that failure in the wild: prompt_toolkit's `WindowRenderInfo.displayed_lines` is
|
|
2754
|
+
documented as "List of all the visible rows" but holds **input buffer line
|
|
2755
|
+
numbers**. What defuses it is not picking better words but *removing the house
|
|
2756
|
+
convention*: `row` is the terminal's unit and `line` is Ruby's, verifiable by
|
|
2757
|
+
typing `"a\nb".lines` in irb. prompt_toolkit's bug was a coordinate-space mixup,
|
|
2758
|
+
which this scheme makes unwriteable — `line` is never a coordinate.
|
|
2759
|
+
|
|
2760
|
+
**Alternatives rejected.**
|
|
2761
|
+
|
|
2762
|
+
- **One noun `line`, unqualified meaning the wrapped unit** (TextView's scheme,
|
|
2763
|
+
extended to TextArea). The smallest possible break, and `line_count(width)`
|
|
2764
|
+
has direct ratatui precedent. Rejected: it contradicts `line` = the logical
|
|
2765
|
+
unit, and in `TextArea` — one String full of `\n` — an unqualified `line` is at
|
|
2766
|
+
its most ambiguous exactly where it is used most.
|
|
2767
|
+
- **`row` for coordinates, `line` for content, scoped to the components.** This
|
|
2768
|
+
is the decision's core, but as first scoped it left `Buffer#set_line`,
|
|
2769
|
+
`Component#draw_line` and `StyledString#wrap`'s "physical lines" alone — the
|
|
2770
|
+
synonym confusion preserved in the foundation — and it lacked the `String#lines`
|
|
2771
|
+
anchor that answers the objection above.
|
|
2772
|
+
- **`line` everywhere with the wrapped unit always qualified** (`physical_line_count`).
|
|
2773
|
+
Zero ambiguity by construction, but verbose, and "physical line" collides with a
|
|
2774
|
+
*famous opposite* usage: Python's language reference calls the raw `\n` lines
|
|
2775
|
+
*physical* and the joined ones *logical* — inverted from TextView's meaning.
|
|
2776
|
+
Borrowing a term with a well-known opposite reading is worse than inventing one.
|
|
2777
|
+
- **Drop the unit noun and name the space** (`virtual_height` / `viewport_height`
|
|
2778
|
+
/ `scroll_offset`, per CSS and Textual). Follows the survey's own lesson —
|
|
2779
|
+
nobody disambiguates via the noun, everybody qualifies the space — and has no
|
|
2780
|
+
Tuile collision. Rejected because it names *extents*, not *positions*, and a
|
|
2781
|
+
`Component`-level `virtual_height` seam edges toward the bottom-up sizing
|
|
2782
|
+
channel deleted in 0.9.0.
|
|
2783
|
+
- **`Buffer#set_row` / `Component#draw_row`,** for parallelism with the reader
|
|
2784
|
+
`row_text`. Rejected for `set_text` / `draw_text`: these write a
|
|
2785
|
+
{Tuile::StyledString} *starting at* `(x, y)` and do not fill the row, so
|
|
2786
|
+
`set_row` would be a new inaccuracy introduced by a cleanup whose point is to
|
|
2787
|
+
stop using row-words loosely. Naming no row is not an exception to "row
|
|
2788
|
+
everywhere".
|
|
2789
|
+
- **`List#items` → `List#rows`,** which a List item arguably is. Rejected:
|
|
2790
|
+
`items` is where `cop` wants the domain-object noun (`D-list-items` had just
|
|
2791
|
+
landed it), and it is the word the enum widgets above `List` already use.
|
|
2792
|
+
- **`scroll_top`** (CSS's `scrollTop`, shorter). Rejected for `scroll_top_row`:
|
|
2793
|
+
it names no unit, and `list.scroll_top` reads as an imperative — *scroll to
|
|
2794
|
+
top* — which a getter must not.
|
|
2795
|
+
- **A general `Component` scroll seam.** `scroll_top_row` stays per-component; a
|
|
2796
|
+
framework-consulted seam is the 0.9.0 re-grow rule's tripwire.
|
|
2797
|
+
|
|
2798
|
+
**Consequences.**
|
|
2799
|
+
|
|
2800
|
+
- **`item_count`, not `row_count`, on `List::Cursor`** — the two are numerically
|
|
2801
|
+
equal in a `List`, but a cursor's `position` indexes *items*
|
|
2802
|
+
(`on_item_chosen` resolves it against `items`, and a `Cursor::Limited`'s
|
|
2803
|
+
allowed positions are item indices). The one place the identity is legitimately
|
|
2804
|
+
used is the scrollbar call, which is screen-space and says
|
|
2805
|
+
`row_count: @items.size`. Same number, two names, each right in its own space.
|
|
2806
|
+
- **Every surviving `line` symbol takes or returns `\n`-delimited text** —
|
|
2807
|
+
`List#lines=`, `#build_lines`, `TextView#add_line`, `Region#line_count`,
|
|
2808
|
+
`StyledString#lines`, `InfoWindow.new(caption, lines)`. That is the property to
|
|
2809
|
+
check a future rename against, and it is why `Buffer#set_line` had to go.
|
|
2810
|
+
- **`spec/tuile/nomenclature_spec.rb` guards it with no allowlist.** A grep
|
|
2811
|
+
enforces words that are *always* wrong; `line_count` is deliberately absent,
|
|
2812
|
+
since `Region#line_count` is correct. A word that is right in one space and
|
|
2813
|
+
wrong in another is the glossary's job — that limit is accepted, not a gap to
|
|
2814
|
+
close later, and a rename needing an allowlist entry is evidence the rename is
|
|
2815
|
+
wrong.
|
|
2816
|
+
- **`row_count` was reserved here, then created separately.** Making it a public
|
|
2817
|
+
reader was held to be a behavioural addition needing its own argument; that
|
|
2818
|
+
argument is `D-text-area-rows`, which granted it on `TextArea` only. The point
|
|
2819
|
+
this entry settled — that the *name* is already taken, so the addition need not
|
|
2820
|
+
re-litigate its spelling — held.
|
|
2821
|
+
- **`CHANGELOG.md` was not swept.** Its 0.4.0 entry announcing the `set_line` /
|
|
2822
|
+
`fill` / `set_char` buffer API stays as written: the changelog is append-only
|
|
2823
|
+
and describes what shipped *then*, so retro-editing it would make a released
|
|
2824
|
+
migration note reference a method that release did not have.
|
|
2825
|
+
|
|
2826
|
+
## D-text-area-rows — `TextArea#caret_row` / `#row_count`, not a hook and not the wrap (2026-08-15)
|
|
2827
|
+
|
|
2828
|
+
**Status:** Accepted; implemented 2026-08-15. Grants the reader
|
|
2829
|
+
`D-scroll-nomenclature` reserved the name for. Answers
|
|
2830
|
+
[#3](https://github.com/mvysny/tuile/issues/3).
|
|
2831
|
+
|
|
2832
|
+
**Context.** Shell-style prompt-history recall in a `TextArea`: Up recalls the
|
|
2833
|
+
previous message, Down the next — but only once the caret has nowhere left to go
|
|
2834
|
+
that way, so Up/Down keep moving the caret inside wrapped text and only *leave*
|
|
2835
|
+
the buffer at its edge. That needs one question answered — **is the caret in the
|
|
2836
|
+
first / last row?** — and half of it was already public (`scroll_top_row` plus
|
|
2837
|
+
`cursor_position`), while the row *count* lived only on the private
|
|
2838
|
+
`WrappedText`. Meanwhile `move_caret_vertical` already computes exactly that
|
|
2839
|
+
condition (`new_row == cur_row` after a clamp) and already has an opinion about
|
|
2840
|
+
it: it snaps to the absolute start/end of the text.
|
|
2841
|
+
|
|
2842
|
+
**Decision — two public readers on `TextArea`, forwarding to the private wrap.**
|
|
2843
|
+
`caret_row` and `row_count`, one line each. The caller claims the key in a seam
|
|
2844
|
+
that already exists — `handle_text_input_key` in a subclass, or the `on_key`
|
|
2845
|
+
interceptor for app code that would rather not subclass — and delegates to
|
|
2846
|
+
`super` everywhere else, which leaves the edge snap intact for anyone who
|
|
2847
|
+
doesn't claim it. The recipe lives in the `TextArea` rdoc.
|
|
2848
|
+
|
|
2849
|
+
Both readers are needed and neither is redundant: history recall uses both, and
|
|
2850
|
+
the auto-growing prompt strip — the case the name was reserved for — uses
|
|
2851
|
+
`row_count` alone to size the strip top-down.
|
|
2852
|
+
|
|
2853
|
+
**Alternatives rejected.**
|
|
2854
|
+
|
|
2855
|
+
- **A protected `on_caret_vertical_overflow(delta)` hook**, consulted inside
|
|
2856
|
+
`move_caret_vertical` before the snap. This was the issue's own preferred
|
|
2857
|
+
shape, on the grounds that it avoids re-deriving a decision `TextArea` already
|
|
2858
|
+
makes. Rejected on five counts. It would be a *fourth* key-interception
|
|
2859
|
+
mechanism in a class that already has three (`on_key`,
|
|
2860
|
+
`handle_text_input_key`, the rung-3 ancestor bubble), where the house style is
|
|
2861
|
+
"claim the key, or decline it". It names an implementation *moment* rather than
|
|
2862
|
+
an event — one point inside a private method, after a clamp — so a later branch
|
|
2863
|
+
in the Up path (desired-column memory, say) would shift its firing condition
|
|
2864
|
+
silently under every subclass, where `caret_row == 0` cannot drift. It points
|
|
2865
|
+
the arrow the wrong way: a hook is the framework consulting the app, and the
|
|
2866
|
+
0.9.0 layout re-grow rule explicitly sanctions the opposite — capability
|
|
2867
|
+
returning as "an *optional, read-only, caller-side query* … never as an
|
|
2868
|
+
automatic channel the framework consults" — which is also why
|
|
2869
|
+
`D-scroll-nomenclature` rejected a general `Component` scroll seam. It serves
|
|
2870
|
+
one question, in one direction, at one moment, where the readers also serve the
|
|
2871
|
+
prompt strip, a "row 3/7" readout and a caller-drawn scrollbar. And it needs a
|
|
2872
|
+
subclass, where the readers serve `on_key` too. In COP terms it is neither a
|
|
2873
|
+
listener (nothing changed) nor a provider (no data pulled) — a template-method
|
|
2874
|
+
escape valve where two COP-shaped seams already exist. As for the
|
|
2875
|
+
re-derivation it was meant to avoid: the decision is literally
|
|
2876
|
+
`caret_row == 0` / `caret_row == row_count - 1`, so there is nothing to
|
|
2877
|
+
re-derive but a `- 1`.
|
|
2878
|
+
- **Publish `wrap` / `WrappedText` itself**, exposing the object that does the
|
|
2879
|
+
arithmetic rather than forwarding its methods one at a time. Tempting: it looks
|
|
2880
|
+
like it belongs in the published value-type family (`Point`, `Size`, `Rect`,
|
|
2881
|
+
`Color`, `StyledString`, `Fraction`), and it caps delegation at one method
|
|
2882
|
+
forever where readers grow one forwarder per question. Rejected on four counts.
|
|
2883
|
+
**(1) Value versus cache handle** — `Rect` is safe to publish because it is
|
|
2884
|
+
immutable *and* authoritative, with no truer copy that drifts; `@wrap` is a
|
|
2885
|
+
lazy cache nilled by `on_text_mutated` and `on_width_changed`, so a held
|
|
2886
|
+
reference goes *silently* stale, answering confidently about text the widget no
|
|
2887
|
+
longer holds, and never raising. The natural place for a subclass to hold it is
|
|
2888
|
+
an ivar — exactly the shape the "never cache a theme value in an ivar" and
|
|
2889
|
+
`effective_bg_color` rules already forbid. Documenting "always call it fresh"
|
|
2890
|
+
reduces the only safe usage to `area.wrap.row_at(area.caret)`, a longer
|
|
2891
|
+
spelling of `caret_row` with a foot-gun attached. **(2) It blesses the very
|
|
2892
|
+
coupling the issue objected to** — the stated complaint about reaching into
|
|
2893
|
+
privates was the coupling to the wrap's shape; publishing it makes that
|
|
2894
|
+
coupling permanent, putting `WrappedText` into `sig/tuile.rbs` and rubydoc and
|
|
2895
|
+
turning any future change to how `TextArea` wraps into a breaking one.
|
|
2896
|
+
**(3) Tell, don't ask** — `area.wrap.row_at(area.caret)` has the caller reading
|
|
2897
|
+
two public bits and doing the component's arithmetic with its borrowed engine,
|
|
2898
|
+
responsible for keeping them consistent. **(4) It flips a written invariant for
|
|
2899
|
+
no argued caller** — AGENTS.md holds the class private "until a second caller
|
|
2900
|
+
actually exists", and nobody has asked for `row_text` / `index_at` from
|
|
2901
|
+
outside. Forwarders grow on demand at one line each; `D-float-field`'s
|
|
2902
|
+
temperament ("a fourth copy is when to re-argue it") applies.
|
|
2903
|
+
- **A `wrapped_text` method documented "do not store".** Same staleness, renamed.
|
|
2904
|
+
- **A validity token on `WrappedText`,** so a holder can detect a stale snapshot.
|
|
2905
|
+
Cache-invalidation protocol in public API, to fix a problem created by
|
|
2906
|
+
publishing the cache.
|
|
2907
|
+
- **`caret_at_first_row?` / `caret_at_last_row?` predicates** instead of raw
|
|
2908
|
+
readers. Reads better at the call site and removes the `- 1`, but `row_count`
|
|
2909
|
+
is still needed for the prompt-strip case, making it three methods to the
|
|
2910
|
+
readers' two while covering less.
|
|
2911
|
+
|
|
2912
|
+
**Consequences.**
|
|
2913
|
+
|
|
2914
|
+
- **`TextArea` only.** `TextView` and `List` share the reserved name and have no
|
|
2915
|
+
argued caller; adding them now would be speculative. A future caller argues its
|
|
2916
|
+
own case, and the spelling is settled either way.
|
|
2917
|
+
- **The edge snap is now a documented default, not just behavior.** A subclass
|
|
2918
|
+
that claims one direction and delegates the other keeps the snap on the
|
|
2919
|
+
unclaimed side — pinned by a spec, since it is the part a reader of the recipe
|
|
2920
|
+
would assume rather than check.
|
|
2921
|
+
- **`caret_row` and `row_count` read the wrap live**, never a stored value —
|
|
2922
|
+
which is the whole reason the object stays private. Specs pin that both track a
|
|
2923
|
+
text change and a width change.
|
|
2924
|
+
|
|
2925
|
+
## D-text-view-scroll-verbs — `TextView#scroll_half_page_up` / `#scroll_half_page_down` (2026-08-15)
|
|
2926
|
+
|
|
2927
|
+
**Status:** Accepted; implemented 2026-08-15.
|
|
2928
|
+
|
|
2929
|
+
**Context.** A chat TUI keeps focus in the input field beneath its transcript,
|
|
2930
|
+
so the transcript's own scroll keys never fire: {Component::TextView#handle_key}
|
|
2931
|
+
opens with `return false unless active?`. The host wants PageUp/PageDown at the
|
|
2932
|
+
*prompt* to page the *view*, half a screen at a time so the reader keeps an
|
|
2933
|
+
overlap while output streams in. `TextView` already knows how to do exactly
|
|
2934
|
+
that — `Ctrl+U` / `Ctrl+D` have scrolled by half a viewport since the scroll
|
|
2935
|
+
ladder landed — but every clamped primitive behind those bindings
|
|
2936
|
+
(`move_scroll_top_row_by`, `move_scroll_top_row_to`, `viewport_rows`,
|
|
2937
|
+
`scroll_top_row_max`) is private, and the one public setter is not a safe
|
|
2938
|
+
substitute (see the alternatives).
|
|
2939
|
+
|
|
2940
|
+
**Decision — two public verbs, and the key bindings route through them.**
|
|
2941
|
+
`scroll_half_page_up` and `scroll_half_page_down`, one line each, delegating to
|
|
2942
|
+
the private movers; the `Ctrl+U` / `Ctrl+D` cases in `handle_key` now call the
|
|
2943
|
+
verbs rather than repeating the arithmetic, so key and API cannot drift apart.
|
|
2944
|
+
Half a page is `viewport_rows / 2` floored at one row. The host's question is
|
|
2945
|
+
"scroll this view half a page", and that is exactly the granularity exposed —
|
|
2946
|
+
it never learns the row count, never clamps, and never touches focus.
|
|
2947
|
+
|
|
2948
|
+
**Alternatives rejected.**
|
|
2949
|
+
|
|
2950
|
+
- **Publish `move_scroll_top_row_by` + `viewport_rows` and let the app halve.**
|
|
2951
|
+
Moves the definition of "half a page" out of the widget and into every app
|
|
2952
|
+
that wants it, where the two spellings drift. `TERMINOLOGY.md` also pins
|
|
2953
|
+
`viewport_rows` private on purpose — `rect.height` is its public form.
|
|
2954
|
+
- **Let the host forward a synthetic key** (`view.handle_key(Keys::CTRL_U)`).
|
|
2955
|
+
Dead on arrival — the `active?` guard rejects it, which is the whole problem —
|
|
2956
|
+
and a keystroke aimed at an unfocused widget is a lie about where focus is.
|
|
2957
|
+
- **App-side arithmetic on the existing public `scroll_top_row=`.** It raises
|
|
2958
|
+
below `0` and is deliberately *not* clamped above, so a caller who overshoots
|
|
2959
|
+
the last row leaves `at_bottom?` false and silently kills `auto_scroll`
|
|
2960
|
+
tailing — the exact bug a transcript pane cannot afford.
|
|
2961
|
+
- **Redefine PageUp/PageDown as half-page moves in `TextView`.** A key named
|
|
2962
|
+
"Page" should page, it would break `Ctrl+U`/`Ctrl+D`'s reason to exist, and it
|
|
2963
|
+
fixes nothing anyway: an unfocused view still sees no keys.
|
|
2964
|
+
- **Ship the whole ladder as verbs** (full page, top, bottom, by-row). No caller
|
|
2965
|
+
yet; `D-text-area-rows`'s temperament applies — a future caller argues its own
|
|
2966
|
+
case, and these two settle the spelling for the rest.
|
|
2967
|
+
|
|
2968
|
+
**Consequences.**
|
|
2969
|
+
|
|
2970
|
+
- **The floor at one row is a behavior change to `Ctrl+D` / `Ctrl+U`** in a
|
|
2971
|
+
one-row viewport, where `1 / 2 == 0` used to make both keys silent no-ops.
|
|
2972
|
+
A public verb that does nothing is worse than a key that does nothing, and the
|
|
2973
|
+
fix is the same line for both.
|
|
2974
|
+
- **Verbs return `void`, not "did it move?"** — consistent with the movers they
|
|
2975
|
+
wrap. A caller wanting the answer reads `scroll_top_row` or `following?`; one
|
|
2976
|
+
claiming a key should claim it unconditionally, since a clamped scroll at the
|
|
2977
|
+
edge is still a handled key (`handle_key` has always returned `true` there).
|
|
2978
|
+
- **`following?` still does the tailing bookkeeping**: paging up un-arms it,
|
|
2979
|
+
paging back to the last row re-arms it. The host gets read-while-streaming for
|
|
2980
|
+
free and has nothing to wire.
|
|
2981
|
+
|
|
2982
|
+
## D-notification — One corner toast, N messages, one ticker draining them (2026-08-17)
|
|
2983
|
+
|
|
2984
|
+
**Status:** Accepted and implemented, `Component::Notification`. Builds on
|
|
2985
|
+
`D-attach-hooks` (the synced-from-an-invariant ticker), `D-color-slots` (the
|
|
2986
|
+
per-message color), and Tier 1 of the component survey. Book ch7 "Notifications"
|
|
2987
|
+
is the user-facing half; the rdoc owns the per-symbol contract. What this entry
|
|
2988
|
+
owns is *why each choice*, and the alternatives that looked right first.
|
|
2989
|
+
|
|
2990
|
+
**Context.** Vaadin's `Notification`, on a TTY. The requirements that shape
|
|
2991
|
+
everything: it must not interrupt (no focus, no keys, no click blocking), it must
|
|
2992
|
+
be raisable from one line of app code, and *several* may be raised at once — a
|
|
2993
|
+
batch job reporting five results, a burst of failures.
|
|
2994
|
+
|
|
2995
|
+
### One box, N entries — not a stack of boxes
|
|
2996
|
+
|
|
2997
|
+
Two toasts would need placement arithmetic (each box's `top` depends on the
|
|
2998
|
+
heights of those above it) and every expiry would reflow the rest: a layout
|
|
2999
|
+
system for a widget nobody asked to lay out. One box with N entries costs a
|
|
3000
|
+
`"\n"`. So `Notification.show` **finds the live notification and appends to it**.
|
|
3001
|
+
|
|
3002
|
+
### Expiry: one repeating ticker over a deque, not a timer per message
|
|
3003
|
+
|
|
3004
|
+
The first formulation was "the second message's 3 s starts when the first
|
|
3005
|
+
disappears", which implies per-message deadline arithmetic (when does #4's clock
|
|
3006
|
+
start? what if #2 is dismissed early?). It collapses to something with no
|
|
3007
|
+
arithmetic at all: **one repeating `tick(3.0)`; each firing retires the oldest;
|
|
3008
|
+
the box closes when the last one goes.** Identical behavior, and it makes the
|
|
3009
|
+
non-obvious rule explicit:
|
|
3010
|
+
|
|
3011
|
+
- **The ticker is never restarted when a message arrives.** Restarting would
|
|
3012
|
+
extend the oldest message's life on every append, so a stream arriving every
|
|
3013
|
+
2.5 s would retire nothing and the box would live forever. The early return in
|
|
3014
|
+
`sync_ticker` is what enforces it, and `notification_spec` pins the ticker's
|
|
3015
|
+
*identity* across an append.
|
|
3016
|
+
- A message arriving 2.9 s into a cycle is not short-changed: it is retired only
|
|
3017
|
+
once it becomes the oldest *and* a full tick elapses, so its visible lifetime
|
|
3018
|
+
is ≥ 3 s and the bottom entry of a full box lives ~3·N seconds. That is the
|
|
3019
|
+
property the staggering was reaching for — the box lingers exactly as long as
|
|
3020
|
+
there is something left to read.
|
|
3021
|
+
|
|
3022
|
+
Independent timers were the rejected alternative and are worse in the case that
|
|
3023
|
+
motivated the widget: five raised in the same instant would appear *and vanish*
|
|
3024
|
+
together, a flash nobody can read.
|
|
3025
|
+
|
|
3026
|
+
### The cap is 5 messages, from reading time — and overflow goes to the log
|
|
3027
|
+
|
|
3028
|
+
The drain rate is fixed at one message per `DISPLAY_SECONDS`, so **the queue
|
|
3029
|
+
length is a duration**: 20 pending messages is a full minute of toast, and the
|
|
3030
|
+
failure mode a cap must prevent is an app bug (a loop notifying per iteration)
|
|
3031
|
+
turning the box into a permanent fixture. 5 × 3 s ≈ 15 s is about the longest a
|
|
3032
|
+
corner box should own the screen, and about as many short lines as anyone reads.
|
|
3033
|
+
The two numbers agreeing is the reason to trust the bound.
|
|
3034
|
+
|
|
3035
|
+
Consequence: **the pending queue is a short-terminal accommodation, not a
|
|
3036
|
+
feature.** With ≤3-row messages the 40 % height cap only binds below ~20 rows; on
|
|
3037
|
+
any normal terminal all five fit, nothing ever waits, and the concept is
|
|
3038
|
+
invisible. Overflow drops the **newest** (in an error storm the first messages are
|
|
3039
|
+
the diagnostic ones, the rest is cascade noise — and it never reorders) and warns
|
|
3040
|
+
via `Tuile.logger`, the gem's first internal log write.
|
|
3041
|
+
|
|
3042
|
+
- **Rejected: a `… and N more` tail**, first sketched as `Window#footer_text`
|
|
3043
|
+
(border chrome, so it costs no row and skips expiry — elegant machinery, which
|
|
3044
|
+
is a bad reason to put something on screen). It fails on *meaning*: the count is
|
|
3045
|
+
cumulative while the list shrinks, so it reads as a promise — "3 more are
|
|
3046
|
+
coming" — that is never kept, and one message beside `+3 more` is that promise
|
|
3047
|
+
at its most absurd. And when it fires the user is already looking at a full box
|
|
3048
|
+
with nothing to act on: no way to retrieve a dropped message, nothing to click.
|
|
3049
|
+
Information with no action. The party who *can* act is the app author, so the
|
|
3050
|
+
report goes to the log, where it says "use a `LogWindow`".
|
|
3051
|
+
- **If it is ever revived**, the fix is *not* "hide while fewer than `MAX` are
|
|
3052
|
+
showing": that resurrects the counter (8 arrive → 5 + `+3`; a tick hides it; one
|
|
3053
|
+
new message refills the box → `+3` reappears though nothing was just dropped).
|
|
3054
|
+
Zero the counter on every tick instead — self-clearing, no resurrection, and the
|
|
3055
|
+
claim becomes honest ("3 dropped in the last 3 seconds").
|
|
3056
|
+
- **Deferred, not rejected:** coalescing identical messages into `"Sync failed
|
|
3057
|
+
×47"`. `StyledString` has structural equality so it is cheap, and it handles a
|
|
3058
|
+
storm better than any cap — but it is a second mechanism against the same
|
|
3059
|
+
problem. Build it if the storm case proves real.
|
|
3060
|
+
|
|
3061
|
+
### `show` is the only door: `new` is private
|
|
3062
|
+
|
|
3063
|
+
The class has no correct standalone use — `reposition` derives its rect from the
|
|
3064
|
+
screen corner, so a second instance lands on *exactly* the same rect and the two
|
|
3065
|
+
overdraw each other with no error. `show`'s find-or-create is the only thing that
|
|
3066
|
+
makes "at most one" true.
|
|
3067
|
+
|
|
3068
|
+
- `TextView::Region` already establishes the idiom (`private_class_method :new`
|
|
3069
|
+
plus a "don't construct these directly" rdoc line), so this is its second use.
|
|
3070
|
+
- The usual objection — that a private constructor forces every knob through the
|
|
3071
|
+
factory — dissolves here: **`color:` is a property of the message, not of the
|
|
3072
|
+
box** (one box holds an error line and an info line), and duration / caps /
|
|
3073
|
+
corner are constants. The whole surface is `show(text, color: nil)`.
|
|
3074
|
+
- Corollary for a future factory: `self.show` calls bare `new`, never
|
|
3075
|
+
`Notification.new`, so a subclass's `show` builds the subclass.
|
|
3076
|
+
- This widget is what surfaced `Popup.self.open` as a subclass trap (it had to be
|
|
3077
|
+
privatized here too, until the factory was deleted outright — `D-popup-open`).
|
|
3078
|
+
|
|
3079
|
+
### The singleton lives in the popups stack, never in a class ivar
|
|
3080
|
+
|
|
3081
|
+
`show` finds it with `Screen.instance.pane.popups.find { _1.is_a?(Notification) }`.
|
|
3082
|
+
A `@@current` would be **process**-global while the notification is
|
|
3083
|
+
*screen*-global: it would survive `Screen.close` and leak a detached popup into
|
|
3084
|
+
the next `Screen.fake`. Clearing it would mean either `Screen#close` knowing about
|
|
3085
|
+
a component (dependencies point toward data, never toward UI) or a
|
|
3086
|
+
component-specific reset hook nothing else needs. The popups stack is already the
|
|
3087
|
+
single source of truth for "what overlays are up" and `ScreenPane#detach_all`
|
|
3088
|
+
empties it on close — the same "readers *over* the array, never a second copy"
|
|
3089
|
+
rule the tree API rests on. Cost is an `is_a?` scan of a 0–3 element array.
|
|
3090
|
+
|
|
3091
|
+
### Flush to the corner — both axes, one reason
|
|
3092
|
+
|
|
3093
|
+
`top = 0`, right edge at the last column, no margin and no knob. Against a
|
|
3094
|
+
full-screen framed app the toast's top and right borders land **coincident** with
|
|
3095
|
+
the window's, so its corner replaces the window's corner and nothing doubles;
|
|
3096
|
+
what you see is a box hanging off the top border, the toast's `┌` interrupting the
|
|
3097
|
+
window's `─`. Verified in the sampler at 100×30.
|
|
3098
|
+
|
|
3099
|
+
**A 1×1 margin is the disease, not the cure** — it is what puts two parallel rules
|
|
3100
|
+
one cell apart (toast right border at `W-2` beside the window's at `W-1`, toast
|
|
3101
|
+
top on row 1 below the window's on row 0). This also settles the vertical question
|
|
3102
|
+
("should `top` clear a content title bar?"), which was never independent: same
|
|
3103
|
+
argument, same answer. The one case wanting `top: 1` is an app whose row 0 is a
|
|
3104
|
+
*title bar* rather than a border — but then there is nothing to double, and the
|
|
3105
|
+
framework cannot see which it is. That is the `anchor:`/`margin:` knob, deferred
|
|
3106
|
+
until an app complains.
|
|
3107
|
+
|
|
3108
|
+
### Width is grow-only; a content floor is not needed
|
|
3109
|
+
|
|
3110
|
+
The box widens to fit a new message and never shrinks while it lives: **width is a
|
|
3111
|
+
property of the burst, not of the current message.** A high-water mark in
|
|
3112
|
+
*desired* columns, with the cap applied last.
|
|
3113
|
+
|
|
3114
|
+
- **Rejected: recompute freely.** On a 160-column terminal `"Saved"` is a
|
|
3115
|
+
7-column box at `x = 153`; a 31-column message jumps the left edge 24 columns
|
|
3116
|
+
left; three seconds later `"Saved"` retires and it jumps back. Every breath
|
|
3117
|
+
re-wraps and repaints every visible message *and* moves the rect, which makes
|
|
3118
|
+
`Popup#rect=` escalate to a full-scene repaint. Simultaneously the ugliest and
|
|
3119
|
+
the most expensive option.
|
|
3120
|
+
- **Rejected: fixed at the cap.** A 64×3 box holding `"Saved"` with 58 blank
|
|
3121
|
+
columns reads as a rendering bug. It works for macOS/GNOME toasts because
|
|
3122
|
+
padding, shadows and icons fill the space; a TTY box has nothing.
|
|
3123
|
+
- **The clamp must not be stored in the mark.** If `@high_water` held the clamped
|
|
3124
|
+
value, a SIGWINCH that narrows the terminal would ratchet the box permanently
|
|
3125
|
+
down to the narrow cap with nothing to restore it on widening.
|
|
3126
|
+
- `MIN_CAP_WIDTH = 34` floors the *cap* (40 % of an 80-column terminal is 32
|
|
3127
|
+
columns — about five words before the ellipsis). That is a different knob from a
|
|
3128
|
+
**content** floor, which was considered and dropped: the sampler shows a
|
|
3129
|
+
7-column `┌─────┐` / `│Saved│` reading as a proper small toast, not a glyph.
|
|
3130
|
+
|
|
3131
|
+
### A click dismisses the whole box
|
|
3132
|
+
|
|
3133
|
+
Not "one message per click". The box covers the corner where a
|
|
3134
|
+
`VerticalScrollBar` renders and header widgets sit, so **the stray click is the
|
|
3135
|
+
common click** — the user is aiming at something underneath. Whole-box dismissal
|
|
3136
|
+
clears the obstruction in one click; per-message would leave the widget covered
|
|
3137
|
+
and demand up to five. Gated on `:left`, because `MouseEvent` also carries
|
|
3138
|
+
`:scroll_up`/`:scroll_down` and a wheel spin must not nuke the box.
|
|
3139
|
+
|
|
3140
|
+
**Accepted wart:** a wheel spin over the toast is swallowed, so the list beneath
|
|
3141
|
+
does not scroll. No fix stays inside the widget — falling through would mean
|
|
3142
|
+
`ScreenPane#handle_mouse` re-running its search past the toast (a framework change
|
|
3143
|
+
for one widget), and having the toast re-route into `screen.pane.content` itself
|
|
3144
|
+
is a component reaching sideways across the tree. It lives ≤15 s.
|
|
3145
|
+
|
|
3146
|
+
### Content: a `TextView`, rebuilt wholesale — `Region` per message was dropped
|
|
3147
|
+
|
|
3148
|
+
The expiry unit is a **message**, not a row (eating a 3-row message one row per
|
|
3149
|
+
tick is not a thing any UI does), which rules out `Component::List` — one item is
|
|
3150
|
+
one row there, so a list cannot hold a wrapped message.
|
|
3151
|
+
|
|
3152
|
+
The design called for one `TextView::Region` per message, retired with
|
|
3153
|
+
`region.text = nil`. **Implementation dropped the regions** and rebuilds the
|
|
3154
|
+
view's text on every change instead, for two reasons found while writing it:
|
|
3155
|
+
|
|
3156
|
+
1. **Regions are unremovable.** Only `TextView#text=` clears them, so a
|
|
3157
|
+
long-lived box (a trickle of messages that never lets it empty) would
|
|
3158
|
+
accumulate one dead region per message forever — and `region_start_index` sums
|
|
3159
|
+
the line counts of every preceding region, so the per-append cost grows with
|
|
3160
|
+
the number of *retired* messages.
|
|
3161
|
+
2. **A rebuild is what a width change needs anyway.** Grow-only width and SIGWINCH
|
|
3162
|
+
both change the wrap width, so every message must be re-wrapped and
|
|
3163
|
+
re-ellipsized regardless. With ≤5 short messages that is trivially cheap, and
|
|
3164
|
+
it makes size, wrap, position and text one computation in `reposition` — which
|
|
3165
|
+
is why every mutation routes through there.
|
|
3166
|
+
|
|
3167
|
+
`TextView` still earns its place: pre-wrapped rows go in as hard lines (so its
|
|
3168
|
+
own wrap is a no-op over them), and it supplies the painting, the blank-row
|
|
3169
|
+
padding, the bg inheritance and the viewport clipping that makes an over-tall
|
|
3170
|
+
queue simply wait, unpainted, with no visible/pending bookkeeping at all.
|
|
3171
|
+
|
|
3172
|
+
### Two traps this widget is the first to hit
|
|
3173
|
+
|
|
3174
|
+
Both are framework-level and belong to *any* future non-modal popup; AGENTS.md
|
|
3175
|
+
carries them as invariants and the specs pin them.
|
|
3176
|
+
|
|
3177
|
+
1. **A click on a non-modal popup kills the keyboard.** `Popup#focusable?` is
|
|
3178
|
+
`true` and `ScreenPane#handle_mouse` routes an in-rect click to the popup,
|
|
3179
|
+
which reaches `Component#handle_mouse`'s `screen.focused = self`. Focus is then
|
|
3180
|
+
inside a subtree that is *not* the key scope (`modal_popup || content`), so
|
|
3181
|
+
`bubble_key` delivers to nobody and every keystroke goes dead until Tab
|
|
3182
|
+
recovers. `ListDropdown` dodges it by being `focusable? = false`; a
|
|
3183
|
+
notification must also override `handle_mouse`, since being unfocusable alone
|
|
3184
|
+
only makes the click a silent no-op.
|
|
3185
|
+
2. **`Popup#reposition` strands a derived position.** For a non-modal popup it
|
|
3186
|
+
re-resolves the size but keeps the caller-assigned `rect.left` — correct for an
|
|
3187
|
+
overlay someone placed by hand, wrong for a corner anchor, which is off-screen
|
|
3188
|
+
entirely after the terminal narrows.
|
|
3189
|
+
|
|
3190
|
+
**The `Popover` extraction still waits.** A screen-corner anchor is arguably the
|
|
3191
|
+
second *kind* of anchoring that would unlock it (per the component survey), but
|
|
3192
|
+
`Notification` ships its own `reposition` first so the extraction is judged with
|
|
3193
|
+
two real implementations rather than one and a guess.
|
|
3194
|
+
|
|
3195
|
+
## D-popup-open — No class-level `Popup.open` factory; `#open` returns `self` (2026-08-17)
|
|
3196
|
+
|
|
3197
|
+
**Status:** Accepted and implemented; `Component::Popup.open` **removed**, and
|
|
3198
|
+
`Popup#open` now returns `self`. Surfaced while building
|
|
3199
|
+
{Tuile::Component::Notification} (`D-notification`), which had to privatize the
|
|
3200
|
+
inherited factory to stop it undermining a private constructor.
|
|
3201
|
+
|
|
3202
|
+
**Context.** `Popup.open(content:, modal:, size:)` was one-line sugar for
|
|
3203
|
+
`Popup.new(...).tap(&:open)`. It hardcoded `Popup.new`, so **every subclass
|
|
3204
|
+
inherited a factory that silently built the wrong class**:
|
|
3205
|
+
`ListDropdown.open(...)` and `Notification.open(...)` each returned a bare
|
|
3206
|
+
`Popup` — no dropdown behavior, no message, no ticker, and no error to say so.
|
|
3207
|
+
|
|
3208
|
+
**Decision — delete it, and there is no fixed version to keep.** The obvious
|
|
3209
|
+
repair is late binding (`new(...)` instead of `Popup.new(...)`), and it does not
|
|
3210
|
+
work: a subclass's constructor takes different parameters — `ListDropdown.new`
|
|
3211
|
+
takes its list, `Notification.new` takes nothing and is *private* — so there is
|
|
3212
|
+
no argument list a base-class factory could forward. A factory that can be
|
|
3213
|
+
inherited neither correctly nor safely should not exist. (Privatizing it per
|
|
3214
|
+
subclass, which `Notification` did first, treats the symptom once per subclass
|
|
3215
|
+
and leaves the trap armed for the next one; and it barely works — a private
|
|
3216
|
+
method is still callable with an implicit receiver, so a *late-bound*
|
|
3217
|
+
`Popup.open` would have cheerfully built a second `Notification` from inside the
|
|
3218
|
+
inherited method.)
|
|
3219
|
+
|
|
3220
|
+
**Decision — `#open` returns `self`, which is what makes the deletion free.**
|
|
3221
|
+
The migration is `Popup.new(content: window).open`, one expression, no `.tap`:
|
|
3222
|
+
|
|
3223
|
+
```ruby
|
|
3224
|
+
popup = Component::Popup.new(content: window, size: Fraction::FULL).open
|
|
3225
|
+
```
|
|
3226
|
+
|
|
3227
|
+
The previous return value was undocumented junk (whatever `Screen#add_popup`
|
|
3228
|
+
happened to hand back), so nothing could depend on it. Both internal callers got
|
|
3229
|
+
*shorter*: `InfoWindow.open` is now a single line, and `PickerWindow.open` drops
|
|
3230
|
+
its trailing bare `popup` — and that method is the standing demonstration that
|
|
3231
|
+
the deleted factory could never have served the general case anyway, since it
|
|
3232
|
+
needs the popup *before* mounting it in order to wire `on_pick`.
|
|
3233
|
+
|
|
3234
|
+
**Not extended to the batteries-included windows.** `InfoWindow.open` and
|
|
3235
|
+
`PickerWindow.open` stay: each names its own class explicitly, takes that class's
|
|
3236
|
+
own arguments, and wraps the popup rather than *being* one — none of them is an
|
|
3237
|
+
inherited factory, so the trap does not apply. `popup_spec` asserts that neither
|
|
3238
|
+
`Popup` nor `ListDropdown` responds to `open` at the class level.
|