tuile 0.11.0 → 0.13.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 +77 -0
- data/DECISIONS.md +1970 -14
- data/README.md +136 -491
- data/TERMINOLOGY.md +70 -0
- data/book/01-first-app.md +22 -17
- data/book/02-repaint.md +19 -6
- data/book/03-layout.md +12 -11
- data/book/05-focus.md +133 -18
- data/book/06-theming.md +6 -3
- data/book/07-components.md +498 -38
- data/book/08-testing.md +18 -4
- data/book/README.md +7 -5
- data/examples/file_commander.rb +27 -20
- data/examples/hello_world.rb +17 -5
- data/examples/sampler.rb +422 -66
- data/ideas/arrow-key-navigation.md +16 -0
- data/ideas/new-components.md +16 -10
- data/lib/tuile/ansi.rb +10 -0
- data/lib/tuile/buffer.rb +7 -7
- data/lib/tuile/component/abstract_string_field.rb +36 -0
- data/lib/tuile/component/button.rb +1 -1
- data/lib/tuile/component/checkbox.rb +1 -1
- data/lib/tuile/component/checkbox_group.rb +31 -26
- data/lib/tuile/component/combo_box.rb +13 -8
- data/lib/tuile/component/info_window.rb +1 -1
- data/lib/tuile/component/label.rb +14 -14
- data/lib/tuile/component/list.rb +313 -216
- data/lib/tuile/component/list_dropdown.rb +100 -10
- data/lib/tuile/component/menu_bar/cascade.rb +255 -0
- data/lib/tuile/component/menu_bar.rb +582 -0
- data/lib/tuile/component/notification.rb +320 -0
- data/lib/tuile/component/picker_window.rb +3 -8
- data/lib/tuile/component/popup.rb +83 -19
- data/lib/tuile/component/progress_bar.rb +1 -1
- data/lib/tuile/component/radio_group.rb +32 -30
- data/lib/tuile/component/select.rb +10 -8
- data/lib/tuile/component/tab_sheet.rb +242 -0
- data/lib/tuile/component/tabs.rb +528 -0
- data/lib/tuile/component/text_area/wrapped_text.rb +320 -0
- data/lib/tuile/component/text_area.rb +84 -277
- data/lib/tuile/component/text_field.rb +24 -7
- data/lib/tuile/component/text_view.rb +197 -180
- data/lib/tuile/component/window.rb +8 -8
- data/lib/tuile/component.rb +43 -18
- data/lib/tuile/event_queue.rb +25 -1
- data/lib/tuile/fake_screen.rb +14 -0
- data/lib/tuile/keys.rb +65 -0
- data/lib/tuile/screen.rb +95 -78
- data/lib/tuile/screen_pane.rb +109 -27
- data/lib/tuile/styled_string.rb +52 -12
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile/vertical_scroll_bar.rb +6 -6
- data/sig/tuile.rbs +2307 -516
- metadata +9 -3
- data/mise.toml +0 -2
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):
|
|
@@ -1004,7 +1004,7 @@ much of `List` to reuse and what the value should be. (A single-select group
|
|
|
1004
1004
|
from the list. Correct, and about 15 lines of forwarding plus a subclass, all
|
|
1005
1005
|
to protect a promise nothing relied on (see `D-boolean-fields`' rejected Enter
|
|
1006
1006
|
reservation). Reach for it only if a driver genuinely needs Enter for itself.
|
|
1007
|
-
- *Paint the rows directly (`< Component`, `
|
|
1007
|
+
- *Paint the rows directly (`< Component`, `draw_text` per row):* wrong here.
|
|
1008
1008
|
The cursor-distinct-from-selection structure *is* `List`, a checkbox group is
|
|
1009
1009
|
the one most likely to be long enough to scroll, and painting rows means
|
|
1010
1010
|
re-implementing the cursor, the viewport, the scrollbar and the mouse
|
|
@@ -1110,7 +1110,7 @@ list floors at 0 and a `Cursor::Limited` keeps its own notion of "last". The
|
|
|
1110
1110
|
moving a piece of user-facing navigation state. It also does not scroll into
|
|
1111
1111
|
view (`move_viewport_to_cursor` is private to `List`'s own key/mouse paths),
|
|
1112
1112
|
so on a scrolling group it parks the cursor off-screen. Left to the app.
|
|
1113
|
-
- *Paint the rows directly (`< Component` + `
|
|
1113
|
+
- *Paint the rows directly (`< Component` + `draw_text`), the fallback the idea
|
|
1114
1114
|
note held open:* it existed to escape the four frictions above, which the
|
|
1115
1115
|
interaction model removes. Composing a `List` then costs nothing and keeps the
|
|
1116
1116
|
cursor, viewport, scrollbar and mouse arithmetic in one place.
|
|
@@ -1161,7 +1161,7 @@ caret on the index axis was never in question — edits, word jumps and
|
|
|
1161
1161
|
redefinition.
|
|
1162
1162
|
|
|
1163
1163
|
**Decision — scroll horizontally instead of capping to the width.** `left_column`
|
|
1164
|
-
follows the caret by the minimum needed, mirroring `TextArea#
|
|
1164
|
+
follows the caret by the minimum needed, mirroring `TextArea#scroll_top_row`.
|
|
1165
1165
|
This deletes the width-derived capacity rule rather than fixing its arithmetic:
|
|
1166
1166
|
the old `rect.width - 1` cap existed to reserve a column for the caret parked
|
|
1167
1167
|
past the last glyph, and that reservation now lives in the scroll clamp
|
|
@@ -1206,7 +1206,7 @@ exactly the width-vs-length confusion this note removes.
|
|
|
1206
1206
|
- **Cache the index↔column mapping.** A single line of text is short and
|
|
1207
1207
|
`Buffer.display_width` is memoized per grapheme, so each walk is a few hash
|
|
1208
1208
|
reads. A cache would need invalidating on every mutation — `TextArea`'s
|
|
1209
|
-
`@
|
|
1209
|
+
`@wrap` hazard — for no measured gain.
|
|
1210
1210
|
|
|
1211
1211
|
**Consequences.** `TextField` no longer has a maximum length by default;
|
|
1212
1212
|
an app that wants one sets `max_text_length`. `ComboBox` and `IntegerField`
|
|
@@ -1274,7 +1274,7 @@ not just a bug fix, and it matches every editor.
|
|
|
1274
1274
|
- **Iterate characters, summing per-character widths.** Gets the column totals
|
|
1275
1275
|
right (a mark measures 0, a wide glyph 2) and is a smaller diff, but it can
|
|
1276
1276
|
split a cluster across a row break — leaving a bare base letter on one row and
|
|
1277
|
-
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
|
|
1278
1278
|
also keeps termination accidental.
|
|
1279
1279
|
- **Wait for the cluster-caret redesign and do both at once.** The redesign is
|
|
1280
1280
|
parked, and this fix does not depend on it: the caret stays a character index
|
|
@@ -1316,7 +1316,7 @@ the slice end.
|
|
|
1316
1316
|
**(2) `Buffer` could not model a cluster wider than two columns.** `put_char`
|
|
1317
1317
|
special-cased `w == 2` and wrote exactly one continuation cell. A cluster
|
|
1318
1318
|
measuring 4 wrote its origin, no continuations, and left the next three cells
|
|
1319
|
-
holding whatever was there before — while `
|
|
1319
|
+
holding whatever was there before — while `set_text` advanced the column by 4.
|
|
1320
1320
|
Stale cells plus a cursor the flush positions from a wrong model.
|
|
1321
1321
|
|
|
1322
1322
|
**Decision — `emoji: :rgi`, in one named constant, at every call site.**
|
|
@@ -1946,7 +1946,7 @@ makes the orphan bug unreachable.
|
|
|
1946
1946
|
question about a loud rename migration purely to convert those silent breaks
|
|
1947
1947
|
into `NoMethodError`s. (2) `max_text_length` would silently change meaning,
|
|
1948
1948
|
characters → clusters. (3) It adds a second invalidated cache to a class that
|
|
1949
|
-
already carries one (`TextArea`'s `@
|
|
1949
|
+
already carries one (`TextArea`'s `@wrap`), for state a per-keystroke
|
|
1950
1950
|
walk recomputes in 62µs.
|
|
1951
1951
|
- **Store an `Array` of clusters instead of a `String`.** Insertion is where
|
|
1952
1952
|
cluster-native storage bites back: typing a combining mark after `e` would
|
|
@@ -2150,7 +2150,7 @@ PasswordField pane alone — renumbered by hand whenever a prompt gained a line)
|
|
|
2150
2150
|
plus hand-rolled expansion (`[inner.height - 8, 2].max`) and hand-rolled cross
|
|
2151
2151
|
clamps (`[inner.width, 30].min`). The cost was not that hand-rolling is
|
|
2152
2152
|
impossible but that the code newcomers read to *learn* Tuile demonstrated the
|
|
2153
|
-
tedious version. The port took the sampler to
|
|
2153
|
+
tedious version. The port took the sampler to a handful of `Rect.new` (5 today).
|
|
2154
2154
|
|
|
2155
2155
|
**Decision — the vocabulary is `Fixed` / `Percent` / `Expand`, and there is no
|
|
2156
2156
|
`Auto`.** Shrink-to-fit is the bottom-up `content_size` channel deleted in
|
|
@@ -2269,11 +2269,10 @@ are ~10-line concretes. That is the sanctioned cohesive base
|
|
|
2269
2269
|
is what a sidebar wants. Vaadin 8's ratio and CSS `flex-grow` both chose
|
|
2270
2270
|
weights.
|
|
2271
2271
|
- *`Min` / `Max` constraints:* deliberately not shipped, and the sampler shows
|
|
2272
|
-
what that costs — its
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
capped proportions turn out to be common.
|
|
2272
|
+
what that costs — its two sidebars (`min(16, width / 3)`) are caps on a
|
|
2273
|
+
*proportion*, unsayable in three constraints, so they keep a rect-callback
|
|
2274
|
+
`Absolute`. That is the intended division of labour: only the part needing
|
|
2275
|
+
arithmetic has any. Revisit only if capped proportions turn out to be common.
|
|
2277
2276
|
- *`BorderLayout` / `BorderPane` / Textual's `dock:`:* nesting
|
|
2278
2277
|
`Vertical(Fixed, Expand, Fixed)` covers it, and `ScreenPane` (content + status
|
|
2279
2278
|
bar) already *is* one, hard-coded.
|
|
@@ -2564,3 +2563,1960 @@ two callers that already know the answer.
|
|
|
2564
2563
|
non-focusable `Menu` really does give the same re-entrancy safety
|
|
2565
2564
|
`ComboBox#active=` leans on, and filtering / row rendering / the commit action
|
|
2566
2565
|
really do vary.
|
|
2566
|
+
|
|
2567
|
+
## D-list-items — `List` takes items + a renderer, rendered lazily (2026-08-14)
|
|
2568
|
+
|
|
2569
|
+
**Status:** Accepted; implemented 2026-08-14, with the five composers folded onto
|
|
2570
|
+
it in the same series. Builds on `D-has-value` (typed, not stringly),
|
|
2571
|
+
`D-combobox` (resolve an index, never store one), `D-float-field` (duplicate
|
|
2572
|
+
rather than fold a shallow commonality) and the top-down layout rule
|
|
2573
|
+
(`D-box-layouts`). Delivers the first half of the "typed items + data provider on
|
|
2574
|
+
`List`" item that gated List Box, Grid and Virtual List.
|
|
2575
|
+
|
|
2576
|
+
**Context.** `List` took pre-rendered rows: `lines=` stored `Array<StyledString>`
|
|
2577
|
+
and the callbacks handed one back. Two symptoms, both of them the same missing
|
|
2578
|
+
seam:
|
|
2579
|
+
|
|
2580
|
+
- Six internal call sites read `->(index, _line) { @items[index] }` — every
|
|
2581
|
+
composer obeying the resolve-an-index rule *by hand*, against its own array,
|
|
2582
|
+
because the framework handed back a string.
|
|
2583
|
+
- Four components (`ComboBox`, `Select`, `RadioGroup`, `CheckboxGroup`) kept a
|
|
2584
|
+
private copy of the `@items` / `@item_label` / `label_for` / `rebuild_rows`
|
|
2585
|
+
shell. `D-select` set the trigger for re-arguing a shared base at the *fourth*
|
|
2586
|
+
copy; this is it.
|
|
2587
|
+
|
|
2588
|
+
**Decision — externalize rendering on the generic component.** `List` holds
|
|
2589
|
+
`items` (any objects) plus a `renderer` (item → row); `on_item_chosen` and
|
|
2590
|
+
`on_cursor_changed` hand back the item. This is the `cop` rule the gem already
|
|
2591
|
+
follows elsewhere — a domain component takes data, a generic one takes strategies
|
|
2592
|
+
— arriving late at the one component that had grown up without it.
|
|
2593
|
+
|
|
2594
|
+
**Not a shared base class.** The alternative reading of four duplicated shells is
|
|
2595
|
+
"extract `AbstractItemsComponent`". That is exactly the `parse`/`format`-hook base
|
|
2596
|
+
`D-float-field` rejected, one level up: it would need a render hook, a
|
|
2597
|
+
commit-gesture hook and a where-do-rows-live hook to span a dropdown driver and a
|
|
2598
|
+
row-per-item group. The duplication was a symptom of a missing *seam*, not of a
|
|
2599
|
+
missing *ancestor*, and adding the seam deleted the duplication that actually
|
|
2600
|
+
mattered while leaving each widget's own gesture policy alone.
|
|
2601
|
+
|
|
2602
|
+
**Decision — render lazily, at paint, memoized per row.** Only the rows in the
|
|
2603
|
+
viewport are rendered; the cache is dropped by `items=`, `renderer=`, a width
|
|
2604
|
+
change or `scrollbar_visibility=`. Eager rendering (render everything in `items=`,
|
|
2605
|
+
keeping today's shape) was the smaller diff and was rejected on three counts:
|
|
2606
|
+
|
|
2607
|
+
- It made `renderer=` and every width change O(all items). That cost was already
|
|
2608
|
+
being paid — a 50k-row `LogWindow` re-ellipsized all 50k rows on *every*
|
|
2609
|
+
terminal resize — and the lazy version deletes `@padded_lines`,
|
|
2610
|
+
`rebuild_padded_lines` and the blank-row field along with it. The refactor came
|
|
2611
|
+
out net *smaller*.
|
|
2612
|
+
- It would have forced a redesign for a lazy data provider later. Rendering
|
|
2613
|
+
on demand is the half of "virtual list" that touches every method; sourcing on
|
|
2614
|
+
demand can then be added behind `items` without moving anything.
|
|
2615
|
+
- It makes `refresh_rows` (below) cheap enough to be the *normal* answer to
|
|
2616
|
+
"my rendering changed", which is what let the groups stop rebuilding rows.
|
|
2617
|
+
|
|
2618
|
+
Two prices, both accepted and both documented in the class rdoc: **a renderer runs
|
|
2619
|
+
at paint time**, so it must be pure and cheap (work that reaches a service belongs
|
|
2620
|
+
in the item), and **search must render without memoizing** — `select_next` scans
|
|
2621
|
+
with the uncached path, since one failed scan over a long list would otherwise
|
|
2622
|
+
grow the cache to one row per item. That asymmetry is invisible in the code and
|
|
2623
|
+
silent under test, so it is pinned by a spec that asserts the cache is still empty
|
|
2624
|
+
after a failed scan.
|
|
2625
|
+
|
|
2626
|
+
**Decision — `refresh_rows` for a renderer whose *inputs* moved.** A renderer
|
|
2627
|
+
closing over mutable state (`RadioGroup`'s selection, `CheckboxGroup`'s `Set`)
|
|
2628
|
+
produces different rows from the same items and the same proc, which no setter can
|
|
2629
|
+
detect. The alternatives were worse: re-assigning `content.renderer =
|
|
2630
|
+
content.renderer` is a ritual whose meaning isn't visible at the call site, and
|
|
2631
|
+
having `value=` rebuild every row is the O(n) pass this decision just deleted.
|
|
2632
|
+
|
|
2633
|
+
**Consequences.**
|
|
2634
|
+
|
|
2635
|
+
- **`lines=` stays, and is not deprecated.** It splits on `\n`, rstrips, and
|
|
2636
|
+
stores the resulting `StyledString`s *as the items* under the default renderer —
|
|
2637
|
+
so for a line-populated list "the item" is exactly what the callbacks handed
|
|
2638
|
+
back before, and all 2191 pre-existing examples passed unmodified. It is the
|
|
2639
|
+
honest API for a log or a static report, not a compatibility shim.
|
|
2640
|
+
Reconsidered right after implementation ("shouldn't `items=` be the only
|
|
2641
|
+
input?") and re-affirmed on a checkable difference: `items = ["a\nb"]` is one
|
|
2642
|
+
row, `lines = ["a\nb"]` is two, and the split-plus-style-preserving-rstrip a
|
|
2643
|
+
caller would have to repeat lives in two privates. Retiring it would need
|
|
2644
|
+
`StyledString.parse_lines(entries)` as a public class method so the coercion
|
|
2645
|
+
sits with the type — worth doing only if a second input flavor ever wants it.
|
|
2646
|
+
- **The appenders were removed, because they are the one thing a provider can't
|
|
2647
|
+
have.** `add_item` / `add_items` / `add_line` / `add_lines` are gone. This
|
|
2648
|
+
decision's second half is sourcing on demand, and the promise that it "can then
|
|
2649
|
+
be added behind `items` without moving anything" is only true while every input
|
|
2650
|
+
is a whole-collection assignment: `add_items` mutates `@items`, which a provider
|
|
2651
|
+
that computes a window on request has nothing to mutate, so the method would
|
|
2652
|
+
have had to either raise for provider-backed lists (a mode) or force the
|
|
2653
|
+
provider to materialize (defeating it). Removing four methods now is cheaper
|
|
2654
|
+
than either. No caller existed — in the gem, in the examples, or in the two
|
|
2655
|
+
downstream apps: every surviving `add_line` is `TextView`'s, including
|
|
2656
|
+
`LogWindow`'s, which is the coherent line to draw (**incremental append is a
|
|
2657
|
+
`TextView` feature; a `List` is a snapshot of a collection**). The price, paid
|
|
2658
|
+
knowingly: an app that tails re-assigns and so drops the row cache, re-rendering
|
|
2659
|
+
a viewport's worth of rows per incoming row where an append preserved every
|
|
2660
|
+
cached row. That is bounded by the viewport, not the list — the 50k-row case
|
|
2661
|
+
this decision was measured against is `TextView`'s now.
|
|
2662
|
+
- **The naming wart around them was deleted, not deprecated for long:** the
|
|
2663
|
+
`lines` **reader** and `ListDropdown#lines=` / `#lines` are gone. The reader
|
|
2664
|
+
returned `items` — it could have returned the *rendered* rows instead, which
|
|
2665
|
+
would have kept two specs asserting rendered text through it, but that forces a
|
|
2666
|
+
full render on a getter and lies about what a list of typed items contains
|
|
2667
|
+
(those specs moved to asserting what is painted, which is what they were really
|
|
2668
|
+
about). The dropdown's pass-throughs had exactly one caller in the wild —
|
|
2669
|
+
pikuri-tui's `SlashMenuPopup`, which pre-rendered its rows and kept `@matches`
|
|
2670
|
+
beside them, i.e. the parallel array this decision exists to delete. All three
|
|
2671
|
+
first shipped as a docs-only deprecation (`@deprecated` + a CHANGELOG line,
|
|
2672
|
+
since a runtime notice would have to go through `Tuile.logger` — `Kernel.warn`
|
|
2673
|
+
writes stderr into the frame a TUI is painting, and a logger defaulting to
|
|
2674
|
+
`IO::NULL` is a notice nobody reads), then were removed *inside the same
|
|
2675
|
+
unreleased 0.12.0* once both downstream apps had migrated: a deprecation
|
|
2676
|
+
nobody ever consumed is dead weight in the API, and virtui's surviving
|
|
2677
|
+
`build_lines` / `lines=` calls confirm the split was drawn in the right place.
|
|
2678
|
+
- **The block form moved to `build_lines`, keeping `lines` a plain reader.** The
|
|
2679
|
+
defect was the overload — `lines` meant "read the items" or "replace them all"
|
|
2680
|
+
depending on `block_given?`, which is half of why the reader read as a lie. A
|
|
2681
|
+
verb name splits the two with no semantic change (virtui's two `update` paths
|
|
2682
|
+
migrate by one word), and leaves `build_items` as the obvious sibling if a
|
|
2683
|
+
typed-items builder is ever wanted. Deleting it outright was the alternative —
|
|
2684
|
+
the body is three lines a caller can write — and was rejected because virtui
|
|
2685
|
+
reads `buffer.size` mid-build to record `Cursor::Limited` positions, so the
|
|
2686
|
+
buffer being a plain growing `Array` is part of the contract worth pinning with
|
|
2687
|
+
a spec rather than re-deriving per app.
|
|
2688
|
+
- **One item is one row.** A multi-line rendering keeps its first line: a `\n`
|
|
2689
|
+
reaching the buffer corrupts the frame, and any other rule (raise, split into
|
|
2690
|
+
several rows) breaks the index-is-the-item identity the whole change rests on.
|
|
2691
|
+
- **`items=` still leaves a stale cursor alone**, and the clamp stays in the
|
|
2692
|
+
caller (`RadioGroup#items=`), *before* the assignment so the single
|
|
2693
|
+
`on_cursor_changed` reports the final row. Moving the clamp into `List` was
|
|
2694
|
+
tempting and rejected: it would change behavior for tailing lists and would
|
|
2695
|
+
break that ordering guarantee for the one component that needs it.
|
|
2696
|
+
- **No measuring was added.** `Select` still measures its own labels caller-side
|
|
2697
|
+
and assigns the rect it computed; `List` gained no width reader. The top-down
|
|
2698
|
+
re-grow rule is unchanged.
|
|
2699
|
+
- **`file_commander`'s `descend` was broken** and this is what surfaced it: it
|
|
2700
|
+
called `Rainbow.uncolor` on the callback's second argument, which had been a
|
|
2701
|
+
`StyledString` (no `#gsub`) since long before this change, so Enter on a
|
|
2702
|
+
directory raised. Holding the entry hashes as items — the name separate from its
|
|
2703
|
+
rendering — is the shape that makes the bug unsayable, and the PTY test now
|
|
2704
|
+
presses Enter.
|
|
2705
|
+
|
|
2706
|
+
## D-scroll-nomenclature — `row` is the grid, `line` is `\n`, `items` are domain objects (2026-08-14)
|
|
2707
|
+
|
|
2708
|
+
**Status:** Accepted; implemented 2026-08-14. Builds on `D-list-items` (which
|
|
2709
|
+
made the item vocabulary real), `D-text-area-columns` and `D-text-field-axes`
|
|
2710
|
+
(which named the index-vs-column axes inside the inputs) and
|
|
2711
|
+
`D-ambiguous-width` (whose `display_width` is the column authority).
|
|
2712
|
+
|
|
2713
|
+
**Context.** Three scrolling components had grown three vocabularies for the
|
|
2714
|
+
same four concepts — a content unit, a wrapped unit, a viewport-relative row,
|
|
2715
|
+
and the offset between the last two. `TextView` said `hard_line` /
|
|
2716
|
+
`physical_line` / `row_in_viewport` / `top_line`; `List` said `item` / `item` /
|
|
2717
|
+
`row_in_viewport` / `top_line`; `TextArea`, the newest, invented "display row"
|
|
2718
|
+
and was the outlier on every axis. Worse, the *foundation* disagreed with
|
|
2719
|
+
itself: `Buffer#row_text` said row while `Buffer#set_line` said line, in one
|
|
2720
|
+
class; `line_count` meant screen rows in `VerticalScrollBar.new` and `\n` units
|
|
2721
|
+
in `TextView::Region`; and `List::Cursor#handle_key(key, line_count,
|
|
2722
|
+
viewport_lines)` carried an item count and a row count in one public signature,
|
|
2723
|
+
calling both "lines".
|
|
2724
|
+
|
|
2725
|
+
**Decision.** `row` is the terminal grid unit, everywhere, with no exceptions; a
|
|
2726
|
+
wrapped unit *is* a row, because wrapping is the operation that turns text into
|
|
2727
|
+
rows. `line` means exactly what `String#lines` returns and is never a
|
|
2728
|
+
coordinate. `items` are the domain objects a widget renders. The offset is
|
|
2729
|
+
`scroll_top_row`, the extent `viewport_rows`, the viewport-relative coordinate
|
|
2730
|
+
`row_in_viewport`. Two space rules carry the rest: an object with only one row
|
|
2731
|
+
space leaves `row` unqualified; a component holding both qualifies the viewport
|
|
2732
|
+
one. AGENTS.md's *Nomenclature* section holds the invariants, TERMINOLOGY.md the
|
|
2733
|
+
definitions.
|
|
2734
|
+
|
|
2735
|
+
**The survey that decided it — and it cuts against the conclusion.** The
|
|
2736
|
+
*official* word for a terminal row is `line`, not `row`: ECMA-48 addresses the
|
|
2737
|
+
presentation component by "line position", and its scroll primitives are named
|
|
2738
|
+
`IL` **INSERT LINE** / `DL` **DELETE LINE** operating on screen rows; terminfo's
|
|
2739
|
+
capabilities are `lines`/`cols`; POSIX's env vars are `LINES`/`COLUMNS`; VT100
|
|
2740
|
+
documented "24 lines by 80 columns"; and Textual's `Widget.render_line(y)`
|
|
2741
|
+
returns a `Strip` for screen row *y*. The kernel and the modern TUI world say
|
|
2742
|
+
row (`struct winsize.ws_row`, `stty rows`, `crossterm::terminal::size() ->
|
|
2743
|
+
(columns, rows)`, and decisively `TTY::Screen.rows`, which Tuile is built on).
|
|
2744
|
+
**`line` is unavailable to Tuile for exactly the reason ECMA-48 never hit the
|
|
2745
|
+
problem: ECMA-48 has no text buffer and no word wrap.** It had one meaning for
|
|
2746
|
+
"line", so it took the good word. Tuile has two and must give the free word to
|
|
2747
|
+
one of them — `row` is free, `line` is not, because Ruby owns it.
|
|
2748
|
+
|
|
2749
|
+
**The objection, and what actually answers it.** `row` and `line` are
|
|
2750
|
+
near-synonyms in English *and* in terminal usage, so a load-bearing distinction
|
|
2751
|
+
resting on them looked like a permanent confusion source — and the survey found
|
|
2752
|
+
that failure in the wild: prompt_toolkit's `WindowRenderInfo.displayed_lines` is
|
|
2753
|
+
documented as "List of all the visible rows" but holds **input buffer line
|
|
2754
|
+
numbers**. What defuses it is not picking better words but *removing the house
|
|
2755
|
+
convention*: `row` is the terminal's unit and `line` is Ruby's, verifiable by
|
|
2756
|
+
typing `"a\nb".lines` in irb. prompt_toolkit's bug was a coordinate-space mixup,
|
|
2757
|
+
which this scheme makes unwriteable — `line` is never a coordinate.
|
|
2758
|
+
|
|
2759
|
+
**Alternatives rejected.**
|
|
2760
|
+
|
|
2761
|
+
- **One noun `line`, unqualified meaning the wrapped unit** (TextView's scheme,
|
|
2762
|
+
extended to TextArea). The smallest possible break, and `line_count(width)`
|
|
2763
|
+
has direct ratatui precedent. Rejected: it contradicts `line` = the logical
|
|
2764
|
+
unit, and in `TextArea` — one String full of `\n` — an unqualified `line` is at
|
|
2765
|
+
its most ambiguous exactly where it is used most.
|
|
2766
|
+
- **`row` for coordinates, `line` for content, scoped to the components.** This
|
|
2767
|
+
is the decision's core, but as first scoped it left `Buffer#set_line`,
|
|
2768
|
+
`Component#draw_line` and `StyledString#wrap`'s "physical lines" alone — the
|
|
2769
|
+
synonym confusion preserved in the foundation — and it lacked the `String#lines`
|
|
2770
|
+
anchor that answers the objection above.
|
|
2771
|
+
- **`line` everywhere with the wrapped unit always qualified** (`physical_line_count`).
|
|
2772
|
+
Zero ambiguity by construction, but verbose, and "physical line" collides with a
|
|
2773
|
+
*famous opposite* usage: Python's language reference calls the raw `\n` lines
|
|
2774
|
+
*physical* and the joined ones *logical* — inverted from TextView's meaning.
|
|
2775
|
+
Borrowing a term with a well-known opposite reading is worse than inventing one.
|
|
2776
|
+
- **Drop the unit noun and name the space** (`virtual_height` / `viewport_height`
|
|
2777
|
+
/ `scroll_offset`, per CSS and Textual). Follows the survey's own lesson —
|
|
2778
|
+
nobody disambiguates via the noun, everybody qualifies the space — and has no
|
|
2779
|
+
Tuile collision. Rejected because it names *extents*, not *positions*, and a
|
|
2780
|
+
`Component`-level `virtual_height` seam edges toward the bottom-up sizing
|
|
2781
|
+
channel deleted in 0.9.0.
|
|
2782
|
+
- **`Buffer#set_row` / `Component#draw_row`,** for parallelism with the reader
|
|
2783
|
+
`row_text`. Rejected for `set_text` / `draw_text`: these write a
|
|
2784
|
+
{Tuile::StyledString} *starting at* `(x, y)` and do not fill the row, so
|
|
2785
|
+
`set_row` would be a new inaccuracy introduced by a cleanup whose point is to
|
|
2786
|
+
stop using row-words loosely. Naming no row is not an exception to "row
|
|
2787
|
+
everywhere".
|
|
2788
|
+
- **`List#items` → `List#rows`,** which a List item arguably is. Rejected:
|
|
2789
|
+
`items` is where `cop` wants the domain-object noun (`D-list-items` had just
|
|
2790
|
+
landed it), and it is the word the enum widgets above `List` already use.
|
|
2791
|
+
- **`scroll_top`** (CSS's `scrollTop`, shorter). Rejected for `scroll_top_row`:
|
|
2792
|
+
it names no unit, and `list.scroll_top` reads as an imperative — *scroll to
|
|
2793
|
+
top* — which a getter must not.
|
|
2794
|
+
- **A general `Component` scroll seam.** `scroll_top_row` stays per-component; a
|
|
2795
|
+
framework-consulted seam is the 0.9.0 re-grow rule's tripwire.
|
|
2796
|
+
|
|
2797
|
+
**Consequences.**
|
|
2798
|
+
|
|
2799
|
+
- **`item_count`, not `row_count`, on `List::Cursor`** — the two are numerically
|
|
2800
|
+
equal in a `List`, but a cursor's `position` indexes *items*
|
|
2801
|
+
(`on_item_chosen` resolves it against `items`, and a `Cursor::Limited`'s
|
|
2802
|
+
allowed positions are item indices). The one place the identity is legitimately
|
|
2803
|
+
used is the scrollbar call, which is screen-space and says
|
|
2804
|
+
`row_count: @items.size`. Same number, two names, each right in its own space.
|
|
2805
|
+
- **Every surviving `line` symbol takes or returns `\n`-delimited text** —
|
|
2806
|
+
`List#lines=`, `#build_lines`, `TextView#add_line`, `Region#line_count`,
|
|
2807
|
+
`StyledString#lines`, `InfoWindow.new(caption, lines)`. That is the property to
|
|
2808
|
+
check a future rename against, and it is why `Buffer#set_line` had to go.
|
|
2809
|
+
- **`spec/tuile/nomenclature_spec.rb` guards it with no allowlist.** A grep
|
|
2810
|
+
enforces words that are *always* wrong; `line_count` is deliberately absent,
|
|
2811
|
+
since `Region#line_count` is correct. A word that is right in one space and
|
|
2812
|
+
wrong in another is the glossary's job — that limit is accepted, not a gap to
|
|
2813
|
+
close later, and a rename needing an allowlist entry is evidence the rename is
|
|
2814
|
+
wrong.
|
|
2815
|
+
- **`row_count` was reserved here, then created separately.** Making it a public
|
|
2816
|
+
reader was held to be a behavioural addition needing its own argument; that
|
|
2817
|
+
argument is `D-text-area-rows`, which granted it on `TextArea` only. The point
|
|
2818
|
+
this entry settled — that the *name* is already taken, so the addition need not
|
|
2819
|
+
re-litigate its spelling — held.
|
|
2820
|
+
- **`CHANGELOG.md` was not swept.** Its 0.4.0 entry announcing the `set_line` /
|
|
2821
|
+
`fill` / `set_char` buffer API stays as written: the changelog is append-only
|
|
2822
|
+
and describes what shipped *then*, so retro-editing it would make a released
|
|
2823
|
+
migration note reference a method that release did not have.
|
|
2824
|
+
|
|
2825
|
+
## D-text-area-rows — `TextArea#caret_row` / `#row_count`, not a hook and not the wrap (2026-08-15)
|
|
2826
|
+
|
|
2827
|
+
**Status:** Accepted; implemented 2026-08-15. Grants the reader
|
|
2828
|
+
`D-scroll-nomenclature` reserved the name for. Answers
|
|
2829
|
+
[#3](https://github.com/mvysny/tuile/issues/3).
|
|
2830
|
+
|
|
2831
|
+
**Context.** Shell-style prompt-history recall in a `TextArea`: Up recalls the
|
|
2832
|
+
previous message, Down the next — but only once the caret has nowhere left to go
|
|
2833
|
+
that way, so Up/Down keep moving the caret inside wrapped text and only *leave*
|
|
2834
|
+
the buffer at its edge. That needs one question answered — **is the caret in the
|
|
2835
|
+
first / last row?** — and half of it was already public (`scroll_top_row` plus
|
|
2836
|
+
`cursor_position`), while the row *count* lived only on the private
|
|
2837
|
+
`WrappedText`. Meanwhile `move_caret_vertical` already computes exactly that
|
|
2838
|
+
condition (`new_row == cur_row` after a clamp) and already has an opinion about
|
|
2839
|
+
it: it snaps to the absolute start/end of the text.
|
|
2840
|
+
|
|
2841
|
+
**Decision — two public readers on `TextArea`, forwarding to the private wrap.**
|
|
2842
|
+
`caret_row` and `row_count`, one line each. The caller claims the key in a seam
|
|
2843
|
+
that already exists — `handle_text_input_key` in a subclass, or the `on_key`
|
|
2844
|
+
interceptor for app code that would rather not subclass — and delegates to
|
|
2845
|
+
`super` everywhere else, which leaves the edge snap intact for anyone who
|
|
2846
|
+
doesn't claim it. The recipe lives in the `TextArea` rdoc.
|
|
2847
|
+
|
|
2848
|
+
Both readers are needed and neither is redundant: history recall uses both, and
|
|
2849
|
+
the auto-growing prompt strip — the case the name was reserved for — uses
|
|
2850
|
+
`row_count` alone to size the strip top-down.
|
|
2851
|
+
|
|
2852
|
+
**Alternatives rejected.**
|
|
2853
|
+
|
|
2854
|
+
- **A protected `on_caret_vertical_overflow(delta)` hook**, consulted inside
|
|
2855
|
+
`move_caret_vertical` before the snap. This was the issue's own preferred
|
|
2856
|
+
shape, on the grounds that it avoids re-deriving a decision `TextArea` already
|
|
2857
|
+
makes. Rejected on five counts. It would be a *fourth* key-interception
|
|
2858
|
+
mechanism in a class that already has three (`on_key`,
|
|
2859
|
+
`handle_text_input_key`, the rung-3 ancestor bubble), where the house style is
|
|
2860
|
+
"claim the key, or decline it". It names an implementation *moment* rather than
|
|
2861
|
+
an event — one point inside a private method, after a clamp — so a later branch
|
|
2862
|
+
in the Up path (desired-column memory, say) would shift its firing condition
|
|
2863
|
+
silently under every subclass, where `caret_row == 0` cannot drift. It points
|
|
2864
|
+
the arrow the wrong way: a hook is the framework consulting the app, and the
|
|
2865
|
+
0.9.0 layout re-grow rule explicitly sanctions the opposite — capability
|
|
2866
|
+
returning as "an *optional, read-only, caller-side query* … never as an
|
|
2867
|
+
automatic channel the framework consults" — which is also why
|
|
2868
|
+
`D-scroll-nomenclature` rejected a general `Component` scroll seam. It serves
|
|
2869
|
+
one question, in one direction, at one moment, where the readers also serve the
|
|
2870
|
+
prompt strip, a "row 3/7" readout and a caller-drawn scrollbar. And it needs a
|
|
2871
|
+
subclass, where the readers serve `on_key` too. In COP terms it is neither a
|
|
2872
|
+
listener (nothing changed) nor a provider (no data pulled) — a template-method
|
|
2873
|
+
escape valve where two COP-shaped seams already exist. As for the
|
|
2874
|
+
re-derivation it was meant to avoid: the decision is literally
|
|
2875
|
+
`caret_row == 0` / `caret_row == row_count - 1`, so there is nothing to
|
|
2876
|
+
re-derive but a `- 1`.
|
|
2877
|
+
- **Publish `wrap` / `WrappedText` itself**, exposing the object that does the
|
|
2878
|
+
arithmetic rather than forwarding its methods one at a time. Tempting: it looks
|
|
2879
|
+
like it belongs in the published value-type family (`Point`, `Size`, `Rect`,
|
|
2880
|
+
`Color`, `StyledString`, `Fraction`), and it caps delegation at one method
|
|
2881
|
+
forever where readers grow one forwarder per question. Rejected on four counts.
|
|
2882
|
+
**(1) Value versus cache handle** — `Rect` is safe to publish because it is
|
|
2883
|
+
immutable *and* authoritative, with no truer copy that drifts; `@wrap` is a
|
|
2884
|
+
lazy cache nilled by `on_text_mutated` and `on_width_changed`, so a held
|
|
2885
|
+
reference goes *silently* stale, answering confidently about text the widget no
|
|
2886
|
+
longer holds, and never raising. The natural place for a subclass to hold it is
|
|
2887
|
+
an ivar — exactly the shape the "never cache a theme value in an ivar" and
|
|
2888
|
+
`effective_bg_color` rules already forbid. Documenting "always call it fresh"
|
|
2889
|
+
reduces the only safe usage to `area.wrap.row_at(area.caret)`, a longer
|
|
2890
|
+
spelling of `caret_row` with a foot-gun attached. **(2) It blesses the very
|
|
2891
|
+
coupling the issue objected to** — the stated complaint about reaching into
|
|
2892
|
+
privates was the coupling to the wrap's shape; publishing it makes that
|
|
2893
|
+
coupling permanent, putting `WrappedText` into `sig/tuile.rbs` and rubydoc and
|
|
2894
|
+
turning any future change to how `TextArea` wraps into a breaking one.
|
|
2895
|
+
**(3) Tell, don't ask** — `area.wrap.row_at(area.caret)` has the caller reading
|
|
2896
|
+
two public bits and doing the component's arithmetic with its borrowed engine,
|
|
2897
|
+
responsible for keeping them consistent. **(4) It flips a written invariant for
|
|
2898
|
+
no argued caller** — AGENTS.md holds the class private "until a second caller
|
|
2899
|
+
actually exists", and nobody has asked for `row_text` / `index_at` from
|
|
2900
|
+
outside. Forwarders grow on demand at one line each; `D-float-field`'s
|
|
2901
|
+
temperament ("a fourth copy is when to re-argue it") applies.
|
|
2902
|
+
- **A `wrapped_text` method documented "do not store".** Same staleness, renamed.
|
|
2903
|
+
- **A validity token on `WrappedText`,** so a holder can detect a stale snapshot.
|
|
2904
|
+
Cache-invalidation protocol in public API, to fix a problem created by
|
|
2905
|
+
publishing the cache.
|
|
2906
|
+
- **`caret_at_first_row?` / `caret_at_last_row?` predicates** instead of raw
|
|
2907
|
+
readers. Reads better at the call site and removes the `- 1`, but `row_count`
|
|
2908
|
+
is still needed for the prompt-strip case, making it three methods to the
|
|
2909
|
+
readers' two while covering less.
|
|
2910
|
+
|
|
2911
|
+
**Consequences.**
|
|
2912
|
+
|
|
2913
|
+
- **`TextArea` only.** `TextView` and `List` share the reserved name and have no
|
|
2914
|
+
argued caller; adding them now would be speculative. A future caller argues its
|
|
2915
|
+
own case, and the spelling is settled either way.
|
|
2916
|
+
- **The edge snap is now a documented default, not just behavior.** A subclass
|
|
2917
|
+
that claims one direction and delegates the other keeps the snap on the
|
|
2918
|
+
unclaimed side — pinned by a spec, since it is the part a reader of the recipe
|
|
2919
|
+
would assume rather than check.
|
|
2920
|
+
- **`caret_row` and `row_count` read the wrap live**, never a stored value —
|
|
2921
|
+
which is the whole reason the object stays private. Specs pin that both track a
|
|
2922
|
+
text change and a width change.
|
|
2923
|
+
|
|
2924
|
+
## D-text-view-scroll-verbs — `TextView#scroll_half_page_up` / `#scroll_half_page_down` (2026-08-15)
|
|
2925
|
+
|
|
2926
|
+
**Status:** Accepted; implemented 2026-08-15. Amended 2026-08-23: the
|
|
2927
|
+
`active?` guard this was originally argued *from* turned out to be dead code
|
|
2928
|
+
(see the correction at the end) — the decision stands on its other grounds.
|
|
2929
|
+
|
|
2930
|
+
**Context.** A chat TUI keeps focus in the input field beneath its transcript,
|
|
2931
|
+
so the transcript's own scroll keys never fire: dispatch delivers a key along
|
|
2932
|
+
the focus chain only, and the view is not on it. The host wants PageUp/PageDown
|
|
2933
|
+
at the *prompt* to page the *view*, half a screen at a time so the reader keeps
|
|
2934
|
+
an overlap while output streams in. `TextView` already knows how to do exactly
|
|
2935
|
+
that — `Ctrl+U` / `Ctrl+D` have scrolled by half a viewport since the scroll
|
|
2936
|
+
ladder landed — but every clamped primitive behind those bindings
|
|
2937
|
+
(`move_scroll_top_row_by`, `move_scroll_top_row_to`, `viewport_rows`,
|
|
2938
|
+
`scroll_top_row_max`) is private, and the one public setter is not a safe
|
|
2939
|
+
substitute (see the alternatives).
|
|
2940
|
+
|
|
2941
|
+
**Decision — two public verbs, and the key bindings route through them.**
|
|
2942
|
+
`scroll_half_page_up` and `scroll_half_page_down`, one line each, delegating to
|
|
2943
|
+
the private movers; the `Ctrl+U` / `Ctrl+D` cases in `handle_key` now call the
|
|
2944
|
+
verbs rather than repeating the arithmetic, so key and API cannot drift apart.
|
|
2945
|
+
Half a page is `viewport_rows / 2` floored at one row. The host's question is
|
|
2946
|
+
"scroll this view half a page", and that is exactly the granularity exposed —
|
|
2947
|
+
it never learns the row count, never clamps, and never touches focus.
|
|
2948
|
+
|
|
2949
|
+
**Alternatives rejected.**
|
|
2950
|
+
|
|
2951
|
+
- **Publish `move_scroll_top_row_by` + `viewport_rows` and let the app halve.**
|
|
2952
|
+
Moves the definition of "half a page" out of the widget and into every app
|
|
2953
|
+
that wants it, where the two spellings drift. `TERMINOLOGY.md` also pins
|
|
2954
|
+
`viewport_rows` private on purpose — `rect.height` is its public form.
|
|
2955
|
+
- **Let the host forward a synthetic key** (`view.handle_key(Keys::CTRL_U)`).
|
|
2956
|
+
A keystroke aimed at an unfocused widget is a lie about where focus is: the
|
|
2957
|
+
host's question is "scroll this view", and spelling it as a key makes the
|
|
2958
|
+
view's key bindings part of its API — rename `Ctrl+U` and the caller breaks.
|
|
2959
|
+
(At the time this was also *dead on arrival*, the guard rejecting it; that
|
|
2960
|
+
guard is gone and the forward would now work. It is still the wrong spelling.)
|
|
2961
|
+
- **App-side arithmetic on the existing public `scroll_top_row=`.** It raises
|
|
2962
|
+
below `0` and is deliberately *not* clamped above, so a caller who overshoots
|
|
2963
|
+
the last row leaves `at_bottom?` false and silently kills `auto_scroll`
|
|
2964
|
+
tailing — the exact bug a transcript pane cannot afford.
|
|
2965
|
+
- **Redefine PageUp/PageDown as half-page moves in `TextView`.** A key named
|
|
2966
|
+
"Page" should page, it would break `Ctrl+U`/`Ctrl+D`'s reason to exist, and it
|
|
2967
|
+
fixes nothing anyway: an unfocused view still sees no keys.
|
|
2968
|
+
- **Ship the whole ladder as verbs** (full page, top, bottom, by-row). No caller
|
|
2969
|
+
yet; `D-text-area-rows`'s temperament applies — a future caller argues its own
|
|
2970
|
+
case, and these two settle the spelling for the rest.
|
|
2971
|
+
|
|
2972
|
+
**Consequences.**
|
|
2973
|
+
|
|
2974
|
+
- **The floor at one row is a behavior change to `Ctrl+D` / `Ctrl+U`** in a
|
|
2975
|
+
one-row viewport, where `1 / 2 == 0` used to make both keys silent no-ops.
|
|
2976
|
+
A public verb that does nothing is worse than a key that does nothing, and the
|
|
2977
|
+
fix is the same line for both.
|
|
2978
|
+
- **Verbs return `void`, not "did it move?"** — consistent with the movers they
|
|
2979
|
+
wrap. A caller wanting the answer reads `scroll_top_row` or `following?`; one
|
|
2980
|
+
claiming a key should claim it unconditionally, since a clamped scroll at the
|
|
2981
|
+
edge is still a handled key (`handle_key` has always returned `true` there).
|
|
2982
|
+
- **`following?` still does the tailing bookkeeping**: paging up un-arms it,
|
|
2983
|
+
paging back to the last row re-arms it. The host gets read-while-streaming for
|
|
2984
|
+
free and has nothing to wire.
|
|
2985
|
+
|
|
2986
|
+
**The correction (2026-08-23).** `TextView#handle_key`'s opening
|
|
2987
|
+
`return false unless active?` was **vestigial**, and this entry took it for a
|
|
2988
|
+
live constraint. It was a leaf backstop for the one place the pre-0.8 framework
|
|
2989
|
+
over-delivered (`ScreenPane` forwarding to `content` unconditionally); e1777fe
|
|
2990
|
+
centralized dispatch and dropped the same guard from `TextInput`, `List` and
|
|
2991
|
+
`Button` — but `text_view.rb`, three weeks old at the time, was missed. It could
|
|
2992
|
+
never fire once removed from that context: `bubble_key` walks `Screen#focused`
|
|
2993
|
+
upward and `focused=` marks that chain `active`, and a `TextView` is a leaf, so
|
|
2994
|
+
the only chain position it can hold is `focused` itself. The stale
|
|
2995
|
+
`return true if super` above the `case` went with it — `Component#handle_key`
|
|
2996
|
+
has collapsed to `false` since the same commit. Both lines are deleted; the
|
|
2997
|
+
widget now obeys the framework-wide rule (AGENTS.md, book ch5) that a
|
|
2998
|
+
`handle_key` acts on the key alone. The *visible* change is that hand-feeding a
|
|
2999
|
+
key to an unfocused view now scrolls it, which is what every other widget in the
|
|
3000
|
+
gem already did (`examples/sampler.rb`'s unfocused `List` is the house idiom).
|
|
3001
|
+
|
|
3002
|
+
## D-notification — One corner toast, N messages, one ticker draining them (2026-08-17)
|
|
3003
|
+
|
|
3004
|
+
**Status:** Accepted and implemented, `Component::Notification`. Builds on
|
|
3005
|
+
`D-attach-hooks` (the synced-from-an-invariant ticker), `D-color-slots` (the
|
|
3006
|
+
per-message color), and Tier 1 of the component survey. Book ch7 "Notifications"
|
|
3007
|
+
is the user-facing half; the rdoc owns the per-symbol contract. What this entry
|
|
3008
|
+
owns is *why each choice*, and the alternatives that looked right first.
|
|
3009
|
+
|
|
3010
|
+
**Context.** Vaadin's `Notification`, on a TTY. The requirements that shape
|
|
3011
|
+
everything: it must not interrupt (no focus, no keys, no click blocking), it must
|
|
3012
|
+
be raisable from one line of app code, and *several* may be raised at once — a
|
|
3013
|
+
batch job reporting five results, a burst of failures.
|
|
3014
|
+
|
|
3015
|
+
### One box, N entries — not a stack of boxes
|
|
3016
|
+
|
|
3017
|
+
Two toasts would need placement arithmetic (each box's `top` depends on the
|
|
3018
|
+
heights of those above it) and every expiry would reflow the rest: a layout
|
|
3019
|
+
system for a widget nobody asked to lay out. One box with N entries costs a
|
|
3020
|
+
`"\n"`. So `Notification.show` **finds the live notification and appends to it**.
|
|
3021
|
+
|
|
3022
|
+
### Expiry: one repeating ticker over a deque, not a timer per message
|
|
3023
|
+
|
|
3024
|
+
The first formulation was "the second message's 3 s starts when the first
|
|
3025
|
+
disappears", which implies per-message deadline arithmetic (when does #4's clock
|
|
3026
|
+
start? what if #2 is dismissed early?). It collapses to something with no
|
|
3027
|
+
arithmetic at all: **one repeating `tick(3.0)`; each firing retires the oldest;
|
|
3028
|
+
the box closes when the last one goes.** Identical behavior, and it makes the
|
|
3029
|
+
non-obvious rule explicit:
|
|
3030
|
+
|
|
3031
|
+
- **The ticker is never restarted when a message arrives.** Restarting would
|
|
3032
|
+
extend the oldest message's life on every append, so a stream arriving every
|
|
3033
|
+
2.5 s would retire nothing and the box would live forever. The early return in
|
|
3034
|
+
`sync_ticker` is what enforces it, and `notification_spec` pins the ticker's
|
|
3035
|
+
*identity* across an append.
|
|
3036
|
+
- A message arriving 2.9 s into a cycle is not short-changed: it is retired only
|
|
3037
|
+
once it becomes the oldest *and* a full tick elapses, so its visible lifetime
|
|
3038
|
+
is ≥ 3 s and the bottom entry of a full box lives ~3·N seconds. That is the
|
|
3039
|
+
property the staggering was reaching for — the box lingers exactly as long as
|
|
3040
|
+
there is something left to read.
|
|
3041
|
+
|
|
3042
|
+
Independent timers were the rejected alternative and are worse in the case that
|
|
3043
|
+
motivated the widget: five raised in the same instant would appear *and vanish*
|
|
3044
|
+
together, a flash nobody can read.
|
|
3045
|
+
|
|
3046
|
+
### The cap is 5 messages, from reading time — and overflow goes to the log
|
|
3047
|
+
|
|
3048
|
+
The drain rate is fixed at one message per `DISPLAY_SECONDS`, so **the queue
|
|
3049
|
+
length is a duration**: 20 pending messages is a full minute of toast, and the
|
|
3050
|
+
failure mode a cap must prevent is an app bug (a loop notifying per iteration)
|
|
3051
|
+
turning the box into a permanent fixture. 5 × 3 s ≈ 15 s is about the longest a
|
|
3052
|
+
corner box should own the screen, and about as many short lines as anyone reads.
|
|
3053
|
+
The two numbers agreeing is the reason to trust the bound.
|
|
3054
|
+
|
|
3055
|
+
Consequence: **the pending queue is a short-terminal accommodation, not a
|
|
3056
|
+
feature.** With ≤3-row messages the 40 % height cap only binds below ~20 rows; on
|
|
3057
|
+
any normal terminal all five fit, nothing ever waits, and the concept is
|
|
3058
|
+
invisible. Overflow drops the **newest** (in an error storm the first messages are
|
|
3059
|
+
the diagnostic ones, the rest is cascade noise — and it never reorders) and warns
|
|
3060
|
+
via `Tuile.logger`, the gem's first internal log write.
|
|
3061
|
+
|
|
3062
|
+
- **Rejected: a `… and N more` tail**, first sketched as `Window#footer_text`
|
|
3063
|
+
(border chrome, so it costs no row and skips expiry — elegant machinery, which
|
|
3064
|
+
is a bad reason to put something on screen). It fails on *meaning*: the count is
|
|
3065
|
+
cumulative while the list shrinks, so it reads as a promise — "3 more are
|
|
3066
|
+
coming" — that is never kept, and one message beside `+3 more` is that promise
|
|
3067
|
+
at its most absurd. And when it fires the user is already looking at a full box
|
|
3068
|
+
with nothing to act on: no way to retrieve a dropped message, nothing to click.
|
|
3069
|
+
Information with no action. The party who *can* act is the app author, so the
|
|
3070
|
+
report goes to the log, where it says "use a `LogWindow`".
|
|
3071
|
+
- **If it is ever revived**, the fix is *not* "hide while fewer than `MAX` are
|
|
3072
|
+
showing": that resurrects the counter (8 arrive → 5 + `+3`; a tick hides it; one
|
|
3073
|
+
new message refills the box → `+3` reappears though nothing was just dropped).
|
|
3074
|
+
Zero the counter on every tick instead — self-clearing, no resurrection, and the
|
|
3075
|
+
claim becomes honest ("3 dropped in the last 3 seconds").
|
|
3076
|
+
- **Deferred, not rejected:** coalescing identical messages into `"Sync failed
|
|
3077
|
+
×47"`. `StyledString` has structural equality so it is cheap, and it handles a
|
|
3078
|
+
storm better than any cap — but it is a second mechanism against the same
|
|
3079
|
+
problem. Build it if the storm case proves real.
|
|
3080
|
+
|
|
3081
|
+
### `show` is the only door: `new` is private
|
|
3082
|
+
|
|
3083
|
+
The class has no correct standalone use — `reposition` derives its rect from the
|
|
3084
|
+
screen corner, so a second instance lands on *exactly* the same rect and the two
|
|
3085
|
+
overdraw each other with no error. `show`'s find-or-create is the only thing that
|
|
3086
|
+
makes "at most one" true.
|
|
3087
|
+
|
|
3088
|
+
- `TextView::Region` already establishes the idiom (`private_class_method :new`
|
|
3089
|
+
plus a "don't construct these directly" rdoc line), so this is its second use.
|
|
3090
|
+
- The usual objection — that a private constructor forces every knob through the
|
|
3091
|
+
factory — dissolves here: **`color:` is a property of the message, not of the
|
|
3092
|
+
box** (one box holds an error line and an info line), and duration / caps /
|
|
3093
|
+
corner are constants. The whole surface is `show(text, color: nil)`.
|
|
3094
|
+
- Corollary for a future factory: `self.show` calls bare `new`, never
|
|
3095
|
+
`Notification.new`, so a subclass's `show` builds the subclass.
|
|
3096
|
+
- This widget is what surfaced `Popup.self.open` as a subclass trap (it had to be
|
|
3097
|
+
privatized here too, until the factory was deleted outright — `D-popup-open`).
|
|
3098
|
+
|
|
3099
|
+
### The singleton lives in the popups stack, never in a class ivar
|
|
3100
|
+
|
|
3101
|
+
`show` finds it with `Screen.instance.pane.popups.find { _1.is_a?(Notification) }`.
|
|
3102
|
+
A `@@current` would be **process**-global while the notification is
|
|
3103
|
+
*screen*-global: it would survive `Screen.close` and leak a detached popup into
|
|
3104
|
+
the next `Screen.fake`. Clearing it would mean either `Screen#close` knowing about
|
|
3105
|
+
a component (dependencies point toward data, never toward UI) or a
|
|
3106
|
+
component-specific reset hook nothing else needs. The popups stack is already the
|
|
3107
|
+
single source of truth for "what overlays are up" and `ScreenPane#detach_all`
|
|
3108
|
+
empties it on close — the same "readers *over* the array, never a second copy"
|
|
3109
|
+
rule the tree API rests on. Cost is an `is_a?` scan of a 0–3 element array.
|
|
3110
|
+
|
|
3111
|
+
### Flush to the corner — both axes, one reason
|
|
3112
|
+
|
|
3113
|
+
`top = 0`, right edge at the last column, no margin and no knob. Against a
|
|
3114
|
+
full-screen framed app the toast's top and right borders land **coincident** with
|
|
3115
|
+
the window's, so its corner replaces the window's corner and nothing doubles;
|
|
3116
|
+
what you see is a box hanging off the top border, the toast's `┌` interrupting the
|
|
3117
|
+
window's `─`. Verified in the sampler at 100×30.
|
|
3118
|
+
|
|
3119
|
+
**A 1×1 margin is the disease, not the cure** — it is what puts two parallel rules
|
|
3120
|
+
one cell apart (toast right border at `W-2` beside the window's at `W-1`, toast
|
|
3121
|
+
top on row 1 below the window's on row 0). This also settles the vertical question
|
|
3122
|
+
("should `top` clear a content title bar?"), which was never independent: same
|
|
3123
|
+
argument, same answer. The one case wanting `top: 1` is an app whose row 0 is a
|
|
3124
|
+
*title bar* rather than a border — but then there is nothing to double, and the
|
|
3125
|
+
framework cannot see which it is. That is the `anchor:`/`margin:` knob, deferred
|
|
3126
|
+
until an app complains.
|
|
3127
|
+
|
|
3128
|
+
### Width is grow-only; a content floor is not needed
|
|
3129
|
+
|
|
3130
|
+
The box widens to fit a new message and never shrinks while it lives: **width is a
|
|
3131
|
+
property of the burst, not of the current message.** A high-water mark in
|
|
3132
|
+
*desired* columns, with the cap applied last.
|
|
3133
|
+
|
|
3134
|
+
- **Rejected: recompute freely.** On a 160-column terminal `"Saved"` is a
|
|
3135
|
+
7-column box at `x = 153`; a 31-column message jumps the left edge 24 columns
|
|
3136
|
+
left; three seconds later `"Saved"` retires and it jumps back. Every breath
|
|
3137
|
+
re-wraps and repaints every visible message *and* moves the rect, which makes
|
|
3138
|
+
`Popup#rect=` escalate to a full-scene repaint. Simultaneously the ugliest and
|
|
3139
|
+
the most expensive option.
|
|
3140
|
+
- **Rejected: fixed at the cap.** A 64×3 box holding `"Saved"` with 58 blank
|
|
3141
|
+
columns reads as a rendering bug. It works for macOS/GNOME toasts because
|
|
3142
|
+
padding, shadows and icons fill the space; a TTY box has nothing.
|
|
3143
|
+
- **The clamp must not be stored in the mark.** If `@high_water` held the clamped
|
|
3144
|
+
value, a SIGWINCH that narrows the terminal would ratchet the box permanently
|
|
3145
|
+
down to the narrow cap with nothing to restore it on widening.
|
|
3146
|
+
- `MIN_CAP_WIDTH = 34` floors the *cap* (40 % of an 80-column terminal is 32
|
|
3147
|
+
columns — about five words before the ellipsis). That is a different knob from a
|
|
3148
|
+
**content** floor, which was considered and dropped: the sampler shows a
|
|
3149
|
+
7-column `┌─────┐` / `│Saved│` reading as a proper small toast, not a glyph.
|
|
3150
|
+
|
|
3151
|
+
### A click dismisses the whole box
|
|
3152
|
+
|
|
3153
|
+
Not "one message per click". The box covers the corner where a
|
|
3154
|
+
`VerticalScrollBar` renders and header widgets sit, so **the stray click is the
|
|
3155
|
+
common click** — the user is aiming at something underneath. Whole-box dismissal
|
|
3156
|
+
clears the obstruction in one click; per-message would leave the widget covered
|
|
3157
|
+
and demand up to five. Gated on `:left`, because `MouseEvent` also carries
|
|
3158
|
+
`:scroll_up`/`:scroll_down` and a wheel spin must not nuke the box.
|
|
3159
|
+
|
|
3160
|
+
**Accepted wart:** a wheel spin over the toast is swallowed, so the list beneath
|
|
3161
|
+
does not scroll. No fix stays inside the widget — falling through would mean
|
|
3162
|
+
`ScreenPane#handle_mouse` re-running its search past the toast (a framework change
|
|
3163
|
+
for one widget), and having the toast re-route into `screen.pane.content` itself
|
|
3164
|
+
is a component reaching sideways across the tree. It lives ≤15 s.
|
|
3165
|
+
|
|
3166
|
+
### Content: a `TextView`, rebuilt wholesale — `Region` per message was dropped
|
|
3167
|
+
|
|
3168
|
+
The expiry unit is a **message**, not a row (eating a 3-row message one row per
|
|
3169
|
+
tick is not a thing any UI does), which rules out `Component::List` — one item is
|
|
3170
|
+
one row there, so a list cannot hold a wrapped message.
|
|
3171
|
+
|
|
3172
|
+
The design called for one `TextView::Region` per message, retired with
|
|
3173
|
+
`region.text = nil`. **Implementation dropped the regions** and rebuilds the
|
|
3174
|
+
view's text on every change instead, for two reasons found while writing it:
|
|
3175
|
+
|
|
3176
|
+
1. **Regions are unremovable.** Only `TextView#text=` clears them, so a
|
|
3177
|
+
long-lived box (a trickle of messages that never lets it empty) would
|
|
3178
|
+
accumulate one dead region per message forever — and `region_start_index` sums
|
|
3179
|
+
the line counts of every preceding region, so the per-append cost grows with
|
|
3180
|
+
the number of *retired* messages.
|
|
3181
|
+
2. **A rebuild is what a width change needs anyway.** Grow-only width and SIGWINCH
|
|
3182
|
+
both change the wrap width, so every message must be re-wrapped and
|
|
3183
|
+
re-ellipsized regardless. With ≤5 short messages that is trivially cheap, and
|
|
3184
|
+
it makes size, wrap, position and text one computation in `reposition` — which
|
|
3185
|
+
is why every mutation routes through there.
|
|
3186
|
+
|
|
3187
|
+
`TextView` still earns its place: pre-wrapped rows go in as hard lines (so its
|
|
3188
|
+
own wrap is a no-op over them), and it supplies the painting, the blank-row
|
|
3189
|
+
padding, the bg inheritance and the viewport clipping that makes an over-tall
|
|
3190
|
+
queue simply wait, unpainted, with no visible/pending bookkeeping at all.
|
|
3191
|
+
|
|
3192
|
+
### Two traps this widget is the first to hit
|
|
3193
|
+
|
|
3194
|
+
Both are framework-level and belong to *any* future non-modal popup; AGENTS.md
|
|
3195
|
+
carries them as invariants and the specs pin them.
|
|
3196
|
+
|
|
3197
|
+
1. **A click on a non-modal popup kills the keyboard.** `Popup#focusable?` is
|
|
3198
|
+
`true` and `ScreenPane#handle_mouse` routes an in-rect click to the popup,
|
|
3199
|
+
which reaches `Component#handle_mouse`'s `screen.focused = self`. Focus is then
|
|
3200
|
+
inside a subtree that is *not* the key scope (`modal_popup || content`), so
|
|
3201
|
+
`bubble_key` delivers to nobody and every keystroke goes dead until Tab
|
|
3202
|
+
recovers. `ListDropdown` dodges it by being `focusable? = false`; a
|
|
3203
|
+
notification must also override `handle_mouse`, since being unfocusable alone
|
|
3204
|
+
only makes the click a silent no-op.
|
|
3205
|
+
2. **`Popup#reposition` strands a derived position.** For a non-modal popup it
|
|
3206
|
+
re-resolves the size but keeps the caller-assigned `rect.left` — correct for an
|
|
3207
|
+
overlay someone placed by hand, wrong for a corner anchor, which is off-screen
|
|
3208
|
+
entirely after the terminal narrows.
|
|
3209
|
+
|
|
3210
|
+
**The `Popover` extraction still waits.** A screen-corner anchor is arguably the
|
|
3211
|
+
second *kind* of anchoring that would unlock it (per the component survey), but
|
|
3212
|
+
`Notification` ships its own `reposition` first so the extraction is judged with
|
|
3213
|
+
two real implementations rather than one and a guess.
|
|
3214
|
+
|
|
3215
|
+
## D-popup-open — No class-level `Popup.open` factory; `#open` returns `self` (2026-08-17)
|
|
3216
|
+
|
|
3217
|
+
**Status:** Accepted and implemented; `Component::Popup.open` **removed**, and
|
|
3218
|
+
`Popup#open` now returns `self`. Surfaced while building
|
|
3219
|
+
{Tuile::Component::Notification} (`D-notification`), which had to privatize the
|
|
3220
|
+
inherited factory to stop it undermining a private constructor.
|
|
3221
|
+
|
|
3222
|
+
**Context.** `Popup.open(content:, modal:, size:)` was one-line sugar for
|
|
3223
|
+
`Popup.new(...).tap(&:open)`. It hardcoded `Popup.new`, so **every subclass
|
|
3224
|
+
inherited a factory that silently built the wrong class**:
|
|
3225
|
+
`ListDropdown.open(...)` and `Notification.open(...)` each returned a bare
|
|
3226
|
+
`Popup` — no dropdown behavior, no message, no ticker, and no error to say so.
|
|
3227
|
+
|
|
3228
|
+
**Decision — delete it, and there is no fixed version to keep.** The obvious
|
|
3229
|
+
repair is late binding (`new(...)` instead of `Popup.new(...)`), and it does not
|
|
3230
|
+
work: a subclass's constructor takes different parameters — `ListDropdown.new`
|
|
3231
|
+
takes its list, `Notification.new` takes nothing and is *private* — so there is
|
|
3232
|
+
no argument list a base-class factory could forward. A factory that can be
|
|
3233
|
+
inherited neither correctly nor safely should not exist. (Privatizing it per
|
|
3234
|
+
subclass, which `Notification` did first, treats the symptom once per subclass
|
|
3235
|
+
and leaves the trap armed for the next one; and it barely works — a private
|
|
3236
|
+
method is still callable with an implicit receiver, so a *late-bound*
|
|
3237
|
+
`Popup.open` would have cheerfully built a second `Notification` from inside the
|
|
3238
|
+
inherited method.)
|
|
3239
|
+
|
|
3240
|
+
**Decision — `#open` returns `self`, which is what makes the deletion free.**
|
|
3241
|
+
The migration is `Popup.new(content: window).open`, one expression, no `.tap`:
|
|
3242
|
+
|
|
3243
|
+
```ruby
|
|
3244
|
+
popup = Component::Popup.new(content: window, size: Fraction::FULL).open
|
|
3245
|
+
```
|
|
3246
|
+
|
|
3247
|
+
The previous return value was undocumented junk (whatever `Screen#add_popup`
|
|
3248
|
+
happened to hand back), so nothing could depend on it. Both internal callers got
|
|
3249
|
+
*shorter*: `InfoWindow.open` is now a single line, and `PickerWindow.open` drops
|
|
3250
|
+
its trailing bare `popup` — and that method is the standing demonstration that
|
|
3251
|
+
the deleted factory could never have served the general case anyway, since it
|
|
3252
|
+
needs the popup *before* mounting it in order to wire `on_pick`.
|
|
3253
|
+
|
|
3254
|
+
**Not extended to the batteries-included windows.** `InfoWindow.open` and
|
|
3255
|
+
`PickerWindow.open` stay: each names its own class explicitly, takes that class's
|
|
3256
|
+
own arguments, and wraps the popup rather than *being* one — none of them is an
|
|
3257
|
+
inherited factory, so the trap does not apply. `popup_spec` asserts that neither
|
|
3258
|
+
`Popup` nor `ListDropdown` responds to `open` at the class level.
|
|
3259
|
+
|
|
3260
|
+
## D-bracketed-paste — A paste is its own event, not a burst of keys (2026-08-23)
|
|
3261
|
+
|
|
3262
|
+
**Status:** Accepted and implemented in `Keys` (`BRACKETED_PASTE_ON`,
|
|
3263
|
+
`PASTE_START`, `read_paste`, `normalize_paste`), `EventQueue::PasteEvent`,
|
|
3264
|
+
`Screen#run_event_loop(bracketed_paste:)`, `ScreenPane#handle_paste`,
|
|
3265
|
+
`Component#handle_paste`, `AbstractStringField#handle_paste`, and
|
|
3266
|
+
`FakeScreen#paste`. Reported as
|
|
3267
|
+
[issue #4](https://github.com/mvysny/tuile/issues/4).
|
|
3268
|
+
|
|
3269
|
+
**Context — the two bytes are the same byte.** Pressing Return in raw mode sends
|
|
3270
|
+
`\r`. Pasting into a terminal that has *not* been told the app can tell a paste
|
|
3271
|
+
apart also sends `\r` for every clipboard line break: xterm, VTE and tmux all
|
|
3272
|
+
rewrite the selection's `\n` on the way out, deliberately, so that a paste looks
|
|
3273
|
+
exactly like typing (tmux's `paste-buffer -r` exists to opt out of it). So a
|
|
3274
|
+
{Tuile::Component::TextArea} subclass that rebinds ENTER to submit — the
|
|
3275
|
+
chat-prompt shape — submitted **once per pasted line**, and the first line was
|
|
3276
|
+
gone before the second arrived.
|
|
3277
|
+
|
|
3278
|
+
Nothing downstream can repair that. By the time `handle_key("\r")` runs, "the
|
|
3279
|
+
user pressed Enter" and "the clipboard held a line break" are the same event.
|
|
3280
|
+
The only downstream lever is inter-keystroke timing, which `D-select` already
|
|
3281
|
+
rejected for type-ahead on exactly this ground: a terminal degrades that signal
|
|
3282
|
+
(bytes in one read burst merge into a single key) and a paste has no gaps at all.
|
|
3283
|
+
The information exists only at the layer that talks to the terminal, which is
|
|
3284
|
+
Tuile's.
|
|
3285
|
+
|
|
3286
|
+
**Decision — drive DEC private mode 2004, on by default.** `run_event_loop`
|
|
3287
|
+
prints `\e[?2004h` alongside the mode-2031 notify and `\e[?2004l` in the same
|
|
3288
|
+
`ensure`, and takes `bracketed_paste: false` to opt out, mirroring
|
|
3289
|
+
`capture_mouse:`. Terminals that don't know the mode ignore the sequence, so
|
|
3290
|
+
there is no capability probe and nothing to detect — which is what makes
|
|
3291
|
+
defaulting it *on* safe rather than a gamble. The off switch exists for the same
|
|
3292
|
+
reason `capture_mouse: false` does: a terminal that mishandles the mode, and a
|
|
3293
|
+
one-flag escape beats a fork of the loop.
|
|
3294
|
+
|
|
3295
|
+
**Decision — the payload is read raw, not through `Keys.getkey`.** `getkey`
|
|
3296
|
+
returns `\e[200~` cleanly (its 5-byte tail gulp fits the marker exactly), but the
|
|
3297
|
+
*content* must not go back through it: a pasted `\e` would send it gulping five
|
|
3298
|
+
bytes of clipboard as an escape tail and surfacing them as phantom keypresses —
|
|
3299
|
+
the failure the `\e[M` and `\e[?` drains already exist to prevent. So
|
|
3300
|
+
`Keys.read_paste` reads **one byte at a time** to the `\e[201~` terminator.
|
|
3301
|
+
One byte at a time, and not a chunked read, because a chunk would over-read past
|
|
3302
|
+
the terminator and swallow whatever the user typed behind the paste; there is
|
|
3303
|
+
deliberately no pushback buffer in `Keys` to make chunking safe. A paste is
|
|
3304
|
+
human-scale and arrives once, so the syscall count is not worth a second
|
|
3305
|
+
mechanism.
|
|
3306
|
+
|
|
3307
|
+
**Decision — a `PasteEvent`, and it never touches the key ladder.** The key
|
|
3308
|
+
thread posts one event carrying the whole payload; `Screen#event_loop` routes it
|
|
3309
|
+
to `handle_paste` down the focus chain, with the same modal scoping as a key and
|
|
3310
|
+
no other rung. *Rejected: reusing `KeyEvent` with a flag*, which would put a
|
|
3311
|
+
`pasted?` predicate on the ladder and re-create the runtime gate `D-key-dispatch`
|
|
3312
|
+
deleted — every `handle_key` would have to remember to check it, and the ones
|
|
3313
|
+
that forgot would be exactly today's bug. *Rejected: replaying an unhandled paste
|
|
3314
|
+
as individual keys.* It reads like graceful degradation and is the ambiguity
|
|
3315
|
+
walking back in through the fallback: a component that declines a paste would
|
|
3316
|
+
still get eight ENTERs. Unhandled text is dropped.
|
|
3317
|
+
|
|
3318
|
+
**Decision — the field inserts it as one mutation.**
|
|
3319
|
+
`AbstractStringField#handle_paste` inserts at the caret in a single `text=`, so
|
|
3320
|
+
`on_change` fires once for the paste rather than once per character. That is what
|
|
3321
|
+
lets a submit-on-Enter subclass need *no* paste code at all — it keeps
|
|
3322
|
+
`handle_key` for the typed ENTER and inherits paste-inserts-text — and it
|
|
3323
|
+
incidentally retires an O(n) re-render and, for a slash-command overlay, an O(n)
|
|
3324
|
+
re-filter.
|
|
3325
|
+
|
|
3326
|
+
**Where each layer sanitizes, and why the line is there.** `Keys.normalize_paste`
|
|
3327
|
+
fixes only what is a *terminal* artifact: `\r`/`\r\n` → `\n` (terminals disagree
|
|
3328
|
+
about which they send inside the brackets — readline carries its own `\r`→`\n`
|
|
3329
|
+
pass for precisely that reason, so this cannot be left to the caller), and an
|
|
3330
|
+
invalid-UTF-8 scrub so a pasted binary file cannot make a downstream
|
|
3331
|
+
grapheme-cluster walk raise. Control characters are *content* and survive that
|
|
3332
|
+
layer. What a **text buffer** may hold is the field's call:
|
|
3333
|
+
`AbstractStringField#preprocess_paste` drops the C0 controls (a raw `\e` or `\t`
|
|
3334
|
+
reaching {Tuile::Buffer} would move the real cursor mid-frame), keeps `\n`, and
|
|
3335
|
+
turns a tab into one space rather than inventing a tab width;
|
|
3336
|
+
`TextField#preprocess_paste` narrows further — newlines to spaces, since a
|
|
3337
|
+
one-row field holds no line break, and a trim to `max_text_length` rather than a
|
|
3338
|
+
rejection, because that is what typing the same characters would have done. An
|
|
3339
|
+
app wanting tab *expansion* or a `[Pasted 230 lines]` placeholder overrides
|
|
3340
|
+
`handle_paste`, which is the seam that exists for it.
|
|
3341
|
+
|
|
3342
|
+
**Testing is three layers, because no one of them covers the others.**
|
|
3343
|
+
`FakeScreen#paste` (normalize + dispatch) is the unit door and starts one layer
|
|
3344
|
+
above the terminal; the sampler's *Paste* pane is the visual demo; and one PTY
|
|
3345
|
+
example in `spec/examples/sampler_spec.rb` is the only place mode 2004, the
|
|
3346
|
+
marker recognition and `read_paste` run for real. That PTY test writes the whole
|
|
3347
|
+
`\e[200~…\e[201~` sequence as **one burst**, which is the one place AGENTS.md's
|
|
3348
|
+
pace-the-keys rule is deliberately inverted: a real paste *is* a gapless burst,
|
|
3349
|
+
and the payload is drained raw, so nothing in it can be mistaken for a key. Its
|
|
3350
|
+
assertions read newly painted log rows rather than the counter row, because the
|
|
3351
|
+
buffer flushes the minimal diff — `rows in draft: 1` becoming `…: 3` puts one
|
|
3352
|
+
character on the wire, not the phrase.
|
|
3353
|
+
|
|
3354
|
+
**Corrected while here.** `TextArea`'s rdoc claimed a pasted line break arrived
|
|
3355
|
+
as `\n` and a typed one as `\r`, which is backwards and read as though multi-line
|
|
3356
|
+
paste already worked. Accepting {Keys::CTRL_J} is still right, but its
|
|
3357
|
+
justification is now the honest one: that is the byte a *typed* Ctrl+J sends.
|
|
3358
|
+
|
|
3359
|
+
## D-repaint-cascade — the repaint cascade skips the clear, never the invalidate (2026-08-23)
|
|
3360
|
+
|
|
3361
|
+
**Status:** Accepted and implemented in {Tuile::Component#repaint}. Found while
|
|
3362
|
+
building {Tuile::Component::TabSheet}, but the bug predates it and was already
|
|
3363
|
+
visible in three shipped sampler panes.
|
|
3364
|
+
|
|
3365
|
+
**Context — the symptom.** Focus the sampler's *TabSheet* pane and press Tab to
|
|
3366
|
+
put focus on the strip: the pane below it vanishes. It is still there — Tab once
|
|
3367
|
+
more and it comes back — so nothing was detached; the cells were simply blanked
|
|
3368
|
+
and never repainted. The same fault, less dramatically, blanked four rows of the
|
|
3369
|
+
*Checkbox*, *CheckboxGroup* and *RadioGroup* panes whenever focus moved into
|
|
3370
|
+
them. A sweep comparing each pane's incremental repaint against a
|
|
3371
|
+
repaint-everything baseline is what found the other three.
|
|
3372
|
+
|
|
3373
|
+
**The mechanism, in one chain.** A focus change invalidates every component
|
|
3374
|
+
whose `active?` flipped — i.e. the whole new focus chain. One of those is a
|
|
3375
|
+
`Layout::Vertical(spacing: 1)`, whose children leave gaps, so the default
|
|
3376
|
+
{Tuile::Component#repaint} runs `clear_background` over **its whole rect** —
|
|
3377
|
+
which is every descendant's cells, not just the gaps — and then re-invalidates
|
|
3378
|
+
its *direct children*. That notice then has to travel the rest of the way down,
|
|
3379
|
+
and it didn't: the old default opened with
|
|
3380
|
+
|
|
3381
|
+
return if children.any? && children_tile_rect?
|
|
3382
|
+
|
|
3383
|
+
so a container whose children tile it perfectly painted nothing **and
|
|
3384
|
+
re-invalidated nothing**. A `TabSheet` (strip on row 0, pane below, exactly
|
|
3385
|
+
tiling) is such a container, and so is a `Layout` whose slot happens to fit its
|
|
3386
|
+
children. The cascade dead-ended there, the grandchildren never learned their
|
|
3387
|
+
cells had been wiped, and the blank stayed until some unrelated event invalidated
|
|
3388
|
+
them again. Nothing in the code says "this must forward", and no test went red —
|
|
3389
|
+
the invalidation set and the buffer were both self-consistent.
|
|
3390
|
+
|
|
3391
|
+
**Decision.** Make the *clear* conditional and the *invalidate* unconditional:
|
|
3392
|
+
|
|
3393
|
+
clear_background unless children.any? && children_tile_rect?
|
|
3394
|
+
children.each { |c| screen.invalidate(c) }
|
|
3395
|
+
|
|
3396
|
+
A container that paints nothing of its own can only redraw its area *through* its
|
|
3397
|
+
children, so being invalidated has to mean invalidating them. The tiling test
|
|
3398
|
+
keeps doing the one job it is good for — deciding whether there is a gap worth
|
|
3399
|
+
blanking, which is what `D-progress-bar`'s "never blank a cell you are about to
|
|
3400
|
+
paint over" cares about.
|
|
3401
|
+
|
|
3402
|
+
**Why the extra invalidation is not a cost.** It is a repaint of a subtree that
|
|
3403
|
+
was about to be wrong, and it reaches the terminal only if it changes something:
|
|
3404
|
+
`Buffer::Cell#set` flips the dirty flag on a real content change alone, so
|
|
3405
|
+
repainting identical glyphs emits nothing. The wire stays minimal; only CPU
|
|
3406
|
+
moves, and only on the frames where an ancestor cleared.
|
|
3407
|
+
|
|
3408
|
+
**Roads not taken.**
|
|
3409
|
+
|
|
3410
|
+
- **Clear only the gaps instead of the whole rect.** Strictly better in
|
|
3411
|
+
principle — no descendant's cells would be destroyed, so no cascade would be
|
|
3412
|
+
needed at all — but it means real rect-subtraction geometry (n children, holes,
|
|
3413
|
+
overlap) in the hottest path in the framework, to replace one `fill`. The
|
|
3414
|
+
cascade fix is three lines and needs no new geometry. Revisit only if clearing
|
|
3415
|
+
ever shows up in a profile.
|
|
3416
|
+
- **Fix it in `TabSheet` alone** (invalidate the strip and pane from its own
|
|
3417
|
+
`repaint`). Rejected on evidence: the sweep proves three other panes already
|
|
3418
|
+
had the bug, so the fault is the framework default, not the new component. A
|
|
3419
|
+
local fix would have left the trap armed for the next container that happens to
|
|
3420
|
+
tile.
|
|
3421
|
+
- **Make the clearing container invalidate the whole subtree** (`on_tree`) rather
|
|
3422
|
+
than its direct children. Same end state by a blunter route, and it moves the
|
|
3423
|
+
knowledge of "who might have been clobbered" into the clearing parent, where
|
|
3424
|
+
the tree below it is none of its business. Each container forwarding one hop is
|
|
3425
|
+
the local rule that composes.
|
|
3426
|
+
|
|
3427
|
+
## D-tabs — `Tabs` / `TabSheet`: a strip, and a strip that swaps panes (2026-08-23)
|
|
3428
|
+
|
|
3429
|
+
**Status:** Accepted; `Component::Tabs` (with `Tabs::Tab`) and
|
|
3430
|
+
`Component::TabSheet` implemented 2026-08-23, demoed in the sampler, taught in
|
|
3431
|
+
book ch7 ("Switching between views"). Brainstormed in `ideas/tabs.md`, now
|
|
3432
|
+
retired. Leans on `D-has-value` (the seam it declines), `D-progress-bar` (the
|
|
3433
|
+
precedent for a selection kept *out* of that seam), `D-list-items` (items vs.
|
|
3434
|
+
identities), `D-select` (claim the minimum), `D-ambiguous-width` (the separator
|
|
3435
|
+
glyph), `D-tree-api` (the slot-swap recipe) and `D-attach-hooks` (what
|
|
3436
|
+
detachment fires).
|
|
3437
|
+
|
|
3438
|
+
**Context.** Several views, one visible at a time, and a one-row strip of
|
|
3439
|
+
captions to pick between them. Two components, because the strip is useful
|
|
3440
|
+
alone — Vaadin documents that case explicitly ("content switching without Tab
|
|
3441
|
+
Sheet"), and an app whose strip lives structurally elsewhere on the screen
|
|
3442
|
+
needs it: `Tabs` is the selector, `TabSheet` is the selector plus the pane that
|
|
3443
|
+
goes with it.
|
|
3444
|
+
|
|
3445
|
+
**Decision — neither is `HasValue`, because a selection is not a value.** The
|
|
3446
|
+
test that decides it, and it generalizes: **would a form save it?** A
|
|
3447
|
+
`RadioGroup`'s selection *is* the datum being edited, so it is a value; a tab's
|
|
3448
|
+
selection is where the user is looking — nothing saves it, nothing validates it,
|
|
3449
|
+
and a forms layer iterating fields must never find it. `D-progress-bar` made the
|
|
3450
|
+
same call one step further out (a `value` that is a read-only report), and
|
|
3451
|
+
`List` has held a cursor and an `on_item_chosen` without being a field since it
|
|
3452
|
+
existed. External corroboration: **Vaadin's `Tabs` is not a field either** — it
|
|
3453
|
+
fires `SelectedChangeEvent`, exposes `setSelectedTab`/`setSelectedIndex`, and is
|
|
3454
|
+
grouped with Accordion and Details rather than with the fields. The cost is that
|
|
3455
|
+
`Tabs` gets no `empty?` / `clear` / `on_value_change` and no free `focusable?`,
|
|
3456
|
+
so it declares `focusable?` and `tab_stop?` itself, the way `Checkbox` and
|
|
3457
|
+
`Select` do. Someone will eventually ask for `tabs.value`; the answer is
|
|
3458
|
+
`selected` / `selected_index`, and this paragraph is why.
|
|
3459
|
+
|
|
3460
|
+
**Decision — `on_tab_selected` reports that the selection *changed*, not that
|
|
3461
|
+
the user pressed something.** Arrows, a click, `selected=` / `selected_index=`,
|
|
3462
|
+
the autoselect of the first `add_tab`, and the re-selection that follows removing
|
|
3463
|
+
the selected tab all fire it; re-selecting the tab already selected fires
|
|
3464
|
+
nothing. Removing the *last* tab fires `(nil, nil)`, both arguments nil. The
|
|
3465
|
+
alternative — notify only on user gestures — would make `Tabs` the one component
|
|
3466
|
+
where an app must re-derive the selection after a removal, and the empty case is
|
|
3467
|
+
exactly where a listener most needs to hear from the strip: an app that renders
|
|
3468
|
+
from the callback has to be told to render *nothing*, or the departed tab's
|
|
3469
|
+
content sits on screen with no tab pointing at it. One implementation
|
|
3470
|
+
consequence worth keeping: the notification decision cannot be made by comparing
|
|
3471
|
+
indices, because removing the selected middle tab of three leaves the index at 1
|
|
3472
|
+
with a *different* tab under it. `apply_selection` therefore takes the
|
|
3473
|
+
previously-selected tab as an argument.
|
|
3474
|
+
|
|
3475
|
+
**Decision — hiding a pane means *detaching* it; Tuile grows no visibility
|
|
3476
|
+
flag.** `TabSheet` keeps only the selected tab's pane in the tree. The
|
|
3477
|
+
alternative — n+1 children, unselected panes hidden by an empty rect — looks
|
|
3478
|
+
cheaper and is not, because the empty rect is a *paint* convention that gates
|
|
3479
|
+
nothing else. Five leaks, all silent: `cycle_focus` collects tab stops by tree
|
|
3480
|
+
walk, so every field in every hidden pane stays in the Tab cycle;
|
|
3481
|
+
`first_tab_stop_or_root` and `Layout#on_focus` cascade focus *into* hidden
|
|
3482
|
+
subtrees; `Screen` parks the hardware cursor at `focused.cursor_position`, so a
|
|
3483
|
+
hidden `TextField` puts the terminal cursor in the middle of the visible pane;
|
|
3484
|
+
`keyboard_hint` advertises the hidden widget in the status bar; and key delivery
|
|
3485
|
+
bubbles through it because it is on the focus chain. Only mouse hit-testing is
|
|
3486
|
+
safe. So option B needs a real seam gating at least four places plus a ruling on
|
|
3487
|
+
whether `Box` / `Absolute` skip invisible children when dividing space — a
|
|
3488
|
+
framework-wide change in the focus system, to buy one component what detachment
|
|
3489
|
+
already gives. Note the prior art: every framework that keeps hidden panes
|
|
3490
|
+
mounted (Textual, FTXUI) has a display/visibility flag in its *core* — Textual's
|
|
3491
|
+
`ContentSwitcher` is one `display` toggle. Tuile's honest options were detach or
|
|
3492
|
+
invent that flag. **Re-grow rule:** `Component#visible?` comes back only when a
|
|
3493
|
+
*second* consumer appears (a pane that must stay live while hidden, an app
|
|
3494
|
+
wanting hidden-but-laid-out widgets), and only argued as a focus-and-paint gate
|
|
3495
|
+
with an explicit ruling on layout arithmetic — never as a paint-time flag
|
|
3496
|
+
smuggled in under one component. AGENTS.md carries the one-line invariant.
|
|
3497
|
+
|
|
3498
|
+
**Decision — one tab stop for the whole strip, and arrows activate
|
|
3499
|
+
immediately.** Three arguments against a component per tab, in order of force:
|
|
3500
|
+
"exactly one stop per widget" (`D-has-value`), and n tabs would mean n Tab
|
|
3501
|
+
presses before the content is reachable; making the *Tab key* walk between
|
|
3502
|
+
*tabs* is the one thing the key ladder forbids by construction (Tab is claimed
|
|
3503
|
+
above everything and means "leave this widget"), so it would read as a feature
|
|
3504
|
+
and be a semantic inversion; and no prior art does it, including the frameworks
|
|
3505
|
+
where individual tabs are widgets (Textual's `Tab`s are children of a focusable
|
|
3506
|
+
`Tabs` and are not focus stops).
|
|
3507
|
+
|
|
3508
|
+
Activation is immediate — Textual, Terminal.Gui, FTXUI, Windows tab controls and
|
|
3509
|
+
the ARIA "automatic activation" pattern all agree, Vaadin being the lone
|
|
3510
|
+
counterexample with a manual variant motivated by expensive panels and
|
|
3511
|
+
screen-reader semantics a TTY doesn't have. The deciding reason is narrower than
|
|
3512
|
+
the prior art, though: **auto-activation means only one thing is ever
|
|
3513
|
+
highlighted.** Manual activation needs two states on one row — the selection and
|
|
3514
|
+
the roamed-to tab — and therefore two visual channels to separate them, on a
|
|
3515
|
+
strip that spends both on the selection alone (below). `RadioGroup` could afford
|
|
3516
|
+
that split vertically because each row has a glyph column of its own
|
|
3517
|
+
(`D-radio-group`); a one-row strip cannot, and two highlights side by side read
|
|
3518
|
+
as noise rather than as two kinds of state. Auto-activation deletes the
|
|
3519
|
+
distinction instead of styling it, and every code path — paint, hit test,
|
|
3520
|
+
callback — has one index to consult. Consequence, and it runs the opposite way
|
|
3521
|
+
to the brainstorm's guess: **lazy panes inherit this rather than reopening it.**
|
|
3522
|
+
Arrowing across five lazy tabs builds five panes; a sheet that can't afford
|
|
3523
|
+
that owes its own answer (a cheap placeholder, or building on a settle delay),
|
|
3524
|
+
not a return to Enter-to-activate.
|
|
3525
|
+
|
|
3526
|
+
**Decision — the strip claims LEFT / RIGHT and the mouse, and nothing else.**
|
|
3527
|
+
`D-select`'s contract restated: Enter and Space have nothing to do once arrows
|
|
3528
|
+
activate, and declining them keeps a form's default button and the app's keys
|
|
3529
|
+
alive. UP / DOWN are declined so a future arrow-navigating layout can move focus
|
|
3530
|
+
*out* of the strip on the axis the strip doesn't use. HOME / END are declined
|
|
3531
|
+
too — Terminal.Gui binds them on its tab row, but a key no widget claims stays
|
|
3532
|
+
available app-wide, which is worth more than a shortcut for a jump that is two
|
|
3533
|
+
Left presses away in the 3–5 tab normal case; an app that wants it assigns
|
|
3534
|
+
`selected_index`. Edges clamp and consume, no wrap (as `List` does): the
|
|
3535
|
+
arrow-nav rule about declining at the edge is about *focus motion*, and this is
|
|
3536
|
+
selection, with the vertical axis already the way out.
|
|
3537
|
+
|
|
3538
|
+
**Decision — bold marks the selected tab; it is not strip chrome.** The selected
|
|
3539
|
+
caption is bold *always*, and additionally sits on `Theme#active_bg_color` while
|
|
3540
|
+
the strip is on the focus chain; unselected captions are regular weight. Two
|
|
3541
|
+
channels, no new theme token. Bold is the one that survives an unfocused strip,
|
|
3542
|
+
which matters because the strip is the map of where you are in the app — unlike
|
|
3543
|
+
a `List` cursor, which is a transient pointer and has
|
|
3544
|
+
`show_cursor_when_inactive` for exactly this reason. **Bolding every caption is
|
|
3545
|
+
the tempting "fix" and it is wrong**: it spends the only unfocused-visible
|
|
3546
|
+
channel, leaving selection to the focus-gated background alone, so an unfocused
|
|
3547
|
+
strip would show no selection at all. Neither escape works — `input_bg_color` is
|
|
3548
|
+
the only other bg token and it means "resting input well" (`Select` uses it for
|
|
3549
|
+
exactly that), and dimming the *unselected* captions instead collides with the
|
|
3550
|
+
dim a *disabled* tab wants (see Deferred below), which would leave unselected
|
|
3551
|
+
and disabled indistinguishable.
|
|
3552
|
+
Rejected alternatives: bracketing the label (`[Payment]`) shifts every later
|
|
3553
|
+
segment by two columns whenever the selection moves, making hit-test geometry
|
|
3554
|
+
depend on the selection; an underline is `▁`, a fresh Ambiguous glyph. This
|
|
3555
|
+
ruling also needed one new primitive — `StyledString#with_bold`, since nothing
|
|
3556
|
+
in the gem had used bold and a caption is a `StyledString` that may carry its own
|
|
3557
|
+
colors.
|
|
3558
|
+
|
|
3559
|
+
**Decision — the separator is `│`, the glyph `Window` paints its borders with,
|
|
3560
|
+
not ASCII `|`.** This inverts `D-ambiguous-width`'s "a new component defaults to
|
|
3561
|
+
ASCII when the pretty glyph is Ambiguous", and the inversion is the point: that
|
|
3562
|
+
rule exists to keep the Ambiguous inventory small and enumerable, and `│` is
|
|
3563
|
+
already *in* the inventory — `window.rb` paints it on every window, and nothing
|
|
3564
|
+
in the gem is designed to survive it measuring 2. Reusing a glyph the framework
|
|
3565
|
+
has already bet on adds nothing to the audit list, and a strip inside a window
|
|
3566
|
+
lines up with the border around it. `separator=` remains, now as the opt-in for
|
|
3567
|
+
ASCII. A *fresh* Ambiguous glyph still defaults to ASCII.
|
|
3568
|
+
|
|
3569
|
+
**Decision — segment geometry: the padding is part of the segment, the separator
|
|
3570
|
+
column is chrome.** A segment is `" " + caption + " "`, segments joined by one
|
|
3571
|
+
separator column. Every segment has the same shape including the first and last
|
|
3572
|
+
(no trimmed outer padding, so no edge case in the arithmetic); the highlight
|
|
3573
|
+
covers the padding, because one that stopped at the glyphs would read as a
|
|
3574
|
+
ragged smear; and a click on a padding column selects that tab, while the
|
|
3575
|
+
separator column selects nothing — same rule as the blank tail past `extent`,
|
|
3576
|
+
which focuses without selecting (`D-boolean-fields`). One private `segments`
|
|
3577
|
+
method is the sole source of that arithmetic, read by *both* the paint and the
|
|
3578
|
+
hit test, and derived from the captions on each call rather than recorded during
|
|
3579
|
+
the last paint — so a hit test is correct before the first paint and after a
|
|
3580
|
+
caption change.
|
|
3581
|
+
|
|
3582
|
+
**Decision — a narrow strip scrolls to keep the selection whole in view**
|
|
3583
|
+
(2026-08-24; v1 clipped, and this replaces that ruling before either strip
|
|
3584
|
+
shipped). One private `left_column` — the strip column painted in the rect's
|
|
3585
|
+
leftmost cell, the name `TextField` uses — read by the paint, the hit test,
|
|
3586
|
+
`extent` and (on `MenuBar`) the segment rect a panel anchors to, so there is
|
|
3587
|
+
still exactly **one** source of segment arithmetic; two would let a click land on
|
|
3588
|
+
the tab beside the one drawn under it. One idempotent `adjust_left_column` is its
|
|
3589
|
+
sole writer, called from every mutation site (`ProgressBar#sync_ticker`'s shape,
|
|
3590
|
+
not a nudge per site), which is what makes the offset `0` in every situation the
|
|
3591
|
+
clipping version handled, scroll back on its own when the rect grows or the
|
|
3592
|
+
captions shrink, and never need a scroll-back branch in any mutator. `MenuBar`
|
|
3593
|
+
funnels its three highlight writers through one private `highlight=` for the same
|
|
3594
|
+
reason, and gets a guarantee out of it: the highlighted segment is on screen
|
|
3595
|
+
*before* `Cascade` anchors a panel to it.
|
|
3596
|
+
|
|
3597
|
+
Rejected — *segment-aligned scrolling* (the offset always a segment start): it
|
|
3598
|
+
buys clean edges and needs no glyph snapping, but wastes up to a segment of width
|
|
3599
|
+
at the right edge, and a strip this narrow is exactly where columns are scarce.
|
|
3600
|
+
Rejected — *reserved cue columns*: reserving two columns makes the window width a
|
|
3601
|
+
function of the scroll state that is computed from it, which is `D-select`'s
|
|
3602
|
+
`:auto`-scrollbar circularity, and shifts the whole strip sideways when a caption
|
|
3603
|
+
is edited. The cues are **overlaid** on the edge columns instead, keeping the
|
|
3604
|
+
style of the cell they cover so one landing on the selected segment doesn't punch
|
|
3605
|
+
a hole in its highlight, and they are ASCII `<` / `>` — `‹ ›` are Ambiguous-width
|
|
3606
|
+
(`D-ambiguous-width`), and a `cue_glyphs=` knob with no caller is a knob to argue
|
|
3607
|
+
about later. They stay chrome, not buttons: a click on a cue falls through to the
|
|
3608
|
+
half-visible segment under it, which selects it and reveals it — the direction the
|
|
3609
|
+
cue pointed anyway — where a clickable cue would need the column to hit-test
|
|
3610
|
+
differently from what it paints. And *free scrolling* (a wheel moving the window
|
|
3611
|
+
without moving the selection) is deliberately absent: the next sync would yank the
|
|
3612
|
+
view back to the selection, so supporting it means a second "user scrolled, stop
|
|
3613
|
+
following" state with a resume rule — `List#auto_scroll`'s machinery, for a
|
|
3614
|
+
one-row widget.
|
|
3615
|
+
|
|
3616
|
+
**The trap this design steps over.** `StyledString#slice` *drops* a grapheme
|
|
3617
|
+
cluster straddling the window edge rather than half-painting it, so an offset
|
|
3618
|
+
landing mid-cluster returns a row one column short and shifts everything past the
|
|
3619
|
+
hole one column left — paint and hit test then disagree, silently, only for wide
|
|
3620
|
+
glyphs. So the offset is snapped *forward* to a cluster boundary, as
|
|
3621
|
+
`TextField#snap_to_glyph_start` does; forward is the safe direction, giving up at
|
|
3622
|
+
most one column of the segment left of the window and never of the one being
|
|
3623
|
+
revealed. A caption wider than the whole rect can't be shown whole at all: its
|
|
3624
|
+
head wins, being the half that identifies it.
|
|
3625
|
+
|
|
3626
|
+
**Decision — `Tabs` owns mutable `Tab` handles; it does *not* get the
|
|
3627
|
+
`items=` / `item_label=` / `label_for` shell.** `add_tab("First")` mints and
|
|
3628
|
+
returns a `Tabs::Tab`, Vaadin-style. The test that separates the two is sharper
|
|
3629
|
+
than "items feel wrong": **an item is an element of a collection someone else
|
|
3630
|
+
owns** — assignment is whole-collection, and an item carries no per-element
|
|
3631
|
+
state, the renderer deriving everything from the object each paint (which is why
|
|
3632
|
+
`D-list-items` *removed* the appenders). **A tab is identity plus per-element
|
|
3633
|
+
mutable state**, minted by the widget and living as long as it, and re-assigning
|
|
3634
|
+
the whole set — the operation an items API is built around — is precisely what a
|
|
3635
|
+
strip must never offer: it would destroy tab identity and with it `TabSheet`'s
|
|
3636
|
+
pane mapping. Two corollaries make the ruling durable: the unbuilt half of
|
|
3637
|
+
`D-list-items` is a *data provider* behind `items`, and a provider cannot own
|
|
3638
|
+
per-tab state, so `HasItems` would arrive carrying a promise Tabs must refuse
|
|
3639
|
+
(paging tabs is meaningless — a million tabs is not a UI); and the growth path
|
|
3640
|
+
here is per-element *attributes* (hidden, disabled, closeable), which items have
|
|
3641
|
+
no notion of. So the `HasItems` question is closed for `Tabs`; it survives only
|
|
3642
|
+
for `ComboBox` / `Select` / `RadioGroup`, where the shell genuinely is three
|
|
3643
|
+
copies of one thing.
|
|
3644
|
+
|
|
3645
|
+
The synthesis worth keeping: **the `Tab` object is what keeps those attributes
|
|
3646
|
+
from becoming framework seams.** A hidden tab is a skipped segment, not
|
|
3647
|
+
`Component#visible?`; a disabled tab is painted dim and skipped when arrowing,
|
|
3648
|
+
not a framework enabled/disabled seam; a closeable tab is an `x` in the segment.
|
|
3649
|
+
None touches `Component`.
|
|
3650
|
+
|
|
3651
|
+
`Tab` is a small mutable object owned by the strip — not a frozen value type (it
|
|
3652
|
+
has settable attributes) and not a `Component` (it never paints itself; a
|
|
3653
|
+
component that never paints is a confusing new category). The contract is copied
|
|
3654
|
+
wholesale from `TextView::Region`: `private_class_method :new`, handed out by the
|
|
3655
|
+
owner, mutators invalidating the owner through a back-pointer, and **a removed
|
|
3656
|
+
handle raising on every mutator and on every reader that consults the strip** — a
|
|
3657
|
+
stale `Tab` is the same footgun as a stale `Region`. As there, the locally-held
|
|
3658
|
+
`caption` stays readable (so an error message can name it) and `remove` is an
|
|
3659
|
+
idempotent no-op. The back-pointer also closes the caption-refresh question:
|
|
3660
|
+
`Tab#caption=` invalidates the strip, so there is no `refresh_rows`-style
|
|
3661
|
+
question to answer.
|
|
3662
|
+
|
|
3663
|
+
**Decision — no `Tab#data`.** A tab carries a caption and its own display
|
|
3664
|
+
attributes, nothing of the app's. The rejected slot would have let
|
|
3665
|
+
`on_tab_selected` hand back a domain object, and it isn't needed: the pane
|
|
3666
|
+
component owns its data (COP's "a component does everything its one purpose
|
|
3667
|
+
needs", so the pane *is* the handle), or a future binder does — the tab is on
|
|
3668
|
+
neither path. Anything genuinely per-tab and app-owned lives in the `TabSheet`
|
|
3669
|
+
or the app component that built it, keyed the way `TabSheet` keys its panes.
|
|
3670
|
+
This is what keeps `Tab` from becoming the items API this entry just refused.
|
|
3671
|
+
|
|
3672
|
+
**Decision — `Tab` hand-rolls `caption` / `caption=` rather than including
|
|
3673
|
+
`HasCaption`.** The six duplicated lines look like exactly what a mixin is for,
|
|
3674
|
+
and the reason they aren't is the mixin's actual payoff: `HasCaption` earns its
|
|
3675
|
+
place as a **test-locator seam** — a locator walks the component tree matching
|
|
3676
|
+
`is_a?(HasCaption)` plus a caption compare, with no hardcoded class list. A
|
|
3677
|
+
`Tab` is not a `Component`, so it appears in no tree walk and that payoff is
|
|
3678
|
+
structurally unreachable; and there is exactly one `Tab` class, forever, so the
|
|
3679
|
+
"no hardcoded class list" benefit has nothing to range over either. Including it
|
|
3680
|
+
would be DRY-only, which is the bar the seam argument sets. The lookup debt is
|
|
3681
|
+
paid on the strip instead: **`Tabs#tabs`** returns the tab array (read-only by
|
|
3682
|
+
convention, like `Component#children`), so a test finds a tab through the widget
|
|
3683
|
+
that owns it — `tabs.find { |t| t.caption.to_s == "Payment" }`. That
|
|
3684
|
+
reader was needed anyway, since `TabSheet` keys panes by identity and nothing
|
|
3685
|
+
else can enumerate.
|
|
3686
|
+
|
|
3687
|
+
**Decision — `TabSheet` holds two children, not n+1, and is not
|
|
3688
|
+
`HasContent`.** `children == [strip, pane]` with the strip pinned at index 0, so
|
|
3689
|
+
pre-order traversal yields the browser's strip-then-pane Tab order for free.
|
|
3690
|
+
`HasContent` stays out even though the swap looks like a content slot, for three
|
|
3691
|
+
concrete reasons: `content=` would become public API meaning "the visible pane",
|
|
3692
|
+
which is misleading (the pane is *derived* from the selection, not assignable);
|
|
3693
|
+
`HasContent#handle_mouse` forwards only into `content`, so the strip would never
|
|
3694
|
+
see a click; and `HasContent#on_focus` forwards focus into the content, which is
|
|
3695
|
+
the behavior this design rejects (switching a tab must not move focus into the
|
|
3696
|
+
new pane — browser and Vaadin behavior). What *is* reused is the slot-swap
|
|
3697
|
+
recipe `D-tree-api` specifies for `Window`: detach without notifying, rewire,
|
|
3698
|
+
then `on_child_removed` last, so the focus repair cascades into the *new*
|
|
3699
|
+
occupant. `TabSheet` overrides that hook to land focus on **the strip** rather
|
|
3700
|
+
than on itself, which is not focusable; the other candidate (the new pane's
|
|
3701
|
+
first tab stop) loses because the user's last action was a tab switch.
|
|
3702
|
+
|
|
3703
|
+
Panes live in an identity-keyed `Tab => Component` map on the sheet. Licence:
|
|
3704
|
+
`Box`'s per-child constraint map, which AGENTS.md permits because it is "a
|
|
3705
|
+
per-child *attribute* map, not a second copy of ordering" — the strip's tab array
|
|
3706
|
+
stays the sole ordering authority. Rejected: a `component` slot on `Tabs::Tab`
|
|
3707
|
+
(the strip would then know about panes, which is the split this whole design
|
|
3708
|
+
rests on), and `TabSheet::Tab < Tabs::Tab` behind a protected factory hook (a
|
|
3709
|
+
framework hook existing for exactly one subclass, handing the minting decision
|
|
3710
|
+
to the subclass while `Tabs` still owns the array).
|
|
3711
|
+
|
|
3712
|
+
Two implementation rulings the map earned. **One idempotent `sync_pane` is the
|
|
3713
|
+
sole writer of the visible pane**, deriving it from `strip.selected` on every
|
|
3714
|
+
call — which is what lets `add_tab` register a pane *after* the strip has already
|
|
3715
|
+
autoselected its tab, with no suspend-the-listener dance; the event-driven
|
|
3716
|
+
alternative has an ordering problem on the very first tab. And the map's keys are
|
|
3717
|
+
kept honest by an invariant rather than by one code path:
|
|
3718
|
+
`forget_removed_tabs` drops every entry whose tab is detached, because
|
|
3719
|
+
`Tabs::Tab#remove` reaches the strip without passing through
|
|
3720
|
+
`TabSheet#remove_tab` — which stranded the entry, pinned the pane against GC, and
|
|
3721
|
+
made `add_tab` reject that pane as still in use. Rejected there: an
|
|
3722
|
+
`on_tab_removed` listener on `Tabs` for the sheet to subscribe to — the tidier
|
|
3723
|
+
data flow, but new app-facing API whose only consumer is internal.
|
|
3724
|
+
|
|
3725
|
+
Named `add_tab(caption, pane)`, not `add`: `Layout#add(component)` is the house
|
|
3726
|
+
`add`, and the explicit verb stops the two reading alike — the same reason
|
|
3727
|
+
`Tabs#add_tab` isn't `add`. (The brainstorm sketched `sheet.add`; its own
|
|
3728
|
+
argument overruled it.)
|
|
3729
|
+
|
|
3730
|
+
**Decision — no framework key switches tabs from *inside* a pane, and no
|
|
3731
|
+
`Keys::CTRL_PAGE_UP` / `CTRL_PAGE_DOWN` constants are added.** Not v1, not
|
|
3732
|
+
later. Four reasons, the first decisive: it is **a global shortcut in disguise**
|
|
3733
|
+
— "one key, anywhere in the app, meaning switch tab" is app policy, and Tuile
|
|
3734
|
+
already has two homes for app policy (the rung-2 registry and an ancestor's
|
|
3735
|
+
`handle_key`), so shipping it as component behavior smuggles an app-level binding
|
|
3736
|
+
into a widget. Nested sheets make it ambiguous *and* the failure is silent: the
|
|
3737
|
+
bubble delivers to the innermost `TabSheet` first, so an inner sheet swallows the
|
|
3738
|
+
key and the outer one becomes unreachable by keyboard with nothing on screen
|
|
3739
|
+
explaining why. Vaadin apps have never needed it — the strip plus Tab is enough.
|
|
3740
|
+
And the editors that do have it use their own scheme, which is the argument for
|
|
3741
|
+
leaving the binding to the app: no choice Tuile made here would match the app's
|
|
3742
|
+
other keys. What Tuile owes instead is the *verbs*: `select_next` /
|
|
3743
|
+
`select_previous` are public (they exist for Left/Right anyway), so an app that
|
|
3744
|
+
wants the habit writes two lines and owns both the key and the "which sheet"
|
|
3745
|
+
question that sank the framework version.
|
|
3746
|
+
|
|
3747
|
+
**Deferred, and why each lands additively.** v1 is captions, selection, mouse,
|
|
3748
|
+
keys and the pane swap. Nothing below is blocked, which is the payoff of the
|
|
3749
|
+
`Tab`-object ruling — each is a `Tab` attribute plus a branch in paint and in
|
|
3750
|
+
arrowing, needing no framework seam:
|
|
3751
|
+
|
|
3752
|
+
- **Hidden tabs** — skip the segment when painting and when arrowing. Pane
|
|
3753
|
+
hiding is already detachment, so this needs no `Component#visible?`.
|
|
3754
|
+
- **Disabled tabs** — paint dim, skip when arrowing, never select. A disabled
|
|
3755
|
+
*tab* is not a component, so no enabled/disabled seam is needed. (A disabled
|
|
3756
|
+
*pane* would be; still out of scope.)
|
|
3757
|
+
- **Closeable tabs** — an `x` in the segment, hit-tested, removing the tab.
|
|
3758
|
+
Nobody else in the gem needs it and the glyph is ASCII-cheap.
|
|
3759
|
+
- **Lazy panes** — `add_tab("Reports") { build_reports }`, built on first
|
|
3760
|
+
selection (Vaadin does it with an attach listener). Free to add: the swap has
|
|
3761
|
+
one call site. Inherits auto-activation, per the activation ruling above.
|
|
3762
|
+
- **Clickable cues, and free scrolling** — see the scrolling decision.
|
|
3763
|
+
|
|
3764
|
+
**Alternatives rejected** (beyond those argued inline). *Vertical orientation:*
|
|
3765
|
+
out of scope — Vaadin doesn't allow it in a TabSheet either, and a vertical strip
|
|
3766
|
+
is a `List` with a renderer (the Side Nav shape). *A border around the strip:*
|
|
3767
|
+
compose with `Window`; the Turbo Vision / Terminal.Gui look, with tabs notched
|
|
3768
|
+
into the top border, would couple `Tabs` to `Window` chrome. *Prefix/suffix
|
|
3769
|
+
slots* for icons and badges: unnecessary — a caption is a `StyledString`, so
|
|
3770
|
+
`Open [24]` is just text.
|
|
3771
|
+
|
|
3772
|
+
**Consequences.** Selection is view state, so nothing in a forms layer will ever
|
|
3773
|
+
enumerate a strip. Hiding a component means detaching it, framework-wide, and
|
|
3774
|
+
`TabSheet` is the worked example — which also means a pane's `on_attached` /
|
|
3775
|
+
`on_detached` fire on every switch, and a pane cannot own a resource that must
|
|
3776
|
+
outlive its visibility. A tab is a handle an app holds, so tab identity is stable
|
|
3777
|
+
across caption edits and reorderings of nothing else. And a starved strip stays
|
|
3778
|
+
wholly reachable, at the cost of a scroll offset that every future paint-time or
|
|
3779
|
+
hit-test change has to keep threading through one place.
|
|
3780
|
+
|
|
3781
|
+
## D-menu-bar — `MenuBar`: a focused strip driving a cascade of `ListDropdown`s (2026-08-24)
|
|
3782
|
+
|
|
3783
|
+
**Status:** Accepted; v1 (`Component::MenuBar` with `MenuBar::Item` and the
|
|
3784
|
+
private `MenuBar::Cascade`) implemented 2026-08-24, demoed in the sampler, taught
|
|
3785
|
+
in book ch7 ("Menus"); v2 (mnemonics) the same day. Designed in a since-retired
|
|
3786
|
+
`ideas/menu-bar.md`, whose prior-art survey (Vaadin 25.2, Turbo Vision,
|
|
3787
|
+
Terminal.Gui, notcurses, MC, and the frameworks that have no menu) this entry
|
|
3788
|
+
only summarizes.
|
|
3789
|
+
|
|
3790
|
+
**Update 2026-08-24: a narrow bar scrolls**, on `D-tabs`' scrolling decision,
|
|
3791
|
+
which both strips implement identically (one private `left_column`, one
|
|
3792
|
+
`adjust_left_column` as its sole writer, ASCII cues overlaid on the edge
|
|
3793
|
+
columns). `MenuBar`'s share of it: one private `highlight=` funnels the arrow,
|
|
3794
|
+
mnemonic and click paths, so a segment is on screen before `Cascade` anchors to
|
|
3795
|
+
it, and `rect=` still *closes* the cascade rather than re-anchoring it.
|
|
3796
|
+
|
|
3797
|
+
**Context.** `ideas/new-components.md` listed Menu Bar as blocked on extracting a
|
|
3798
|
+
`Popover` from `ListDropdown#anchor_to`. It isn't: the widget needs a *second
|
|
3799
|
+
placement*, not a second kind of overlay.
|
|
3800
|
+
|
|
3801
|
+
**Decision.** Focus never leaves the bar. The strip is the single tab stop, and
|
|
3802
|
+
the open menus are non-modal `ListDropdown`s mounted on the `ScreenPane` — owned
|
|
3803
|
+
by the bar, parented by nobody — so every key arrives at `MenuBar#handle_key`,
|
|
3804
|
+
which offers it to a `Cascade` first. That is `Select`'s architecture
|
|
3805
|
+
(`D-select`) extended to N levels, which is why **nothing in the key-dispatch
|
|
3806
|
+
ladder changes** and why the whole widget is additive: two new placement helpers
|
|
3807
|
+
on `ListDropdown`, one callback pass-through, and no change to `Popup`,
|
|
3808
|
+
`ScreenPane` or `Component`.
|
|
3809
|
+
|
|
3810
|
+
The keyboard map is copied from Vaadin's, which is also the ARIA menubar pattern
|
|
3811
|
+
and what every TUI lineage surveyed does — Left/Right along the strip,
|
|
3812
|
+
Down/Enter/Space to open, Up/Down inside, Right/Enter to drill, Left to go back,
|
|
3813
|
+
ESC to close one level, and Left-at-the-top / Right-on-a-leaf stepping to the
|
|
3814
|
+
neighbouring menu. There was nothing to invent, and inventing would have been the
|
|
3815
|
+
error.
|
|
3816
|
+
|
|
3817
|
+
**Alternatives rejected.**
|
|
3818
|
+
|
|
3819
|
+
- **A single drill-down frame** — one panel that re-renders as you descend
|
|
3820
|
+
(Terminal.Gui ships this as `UseSubMenusSingleFrame`). It needs no
|
|
3821
|
+
`anchor_beside` and no stack at all, and was rejected because it loses the
|
|
3822
|
+
"where am I in the hierarchy" readout that is the cascade's entire point — and
|
|
3823
|
+
because it cannot be the default with a cascade bolted on later: the cascade is
|
|
3824
|
+
the harder mechanism, and building it second means building it against a shape
|
|
3825
|
+
that assumed one panel.
|
|
3826
|
+
- **A modal level-0 popup**, which would give real modality — keys scoped, clicks
|
|
3827
|
+
outside blocked. Rejected on a mechanical fact, not a preference:
|
|
3828
|
+
`ScreenPane#add_popup` **centers** every modal popup and focuses it, so an
|
|
3829
|
+
anchored modal is impossible without changing `ScreenPane`. It would also
|
|
3830
|
+
invert ownership, moving key handling off the bar and into the popup.
|
|
3831
|
+
- **Focusable panels**, focus descending as you drill. Rejected: AGENTS.md's
|
|
3832
|
+
non-modal-overlay traps say a focus-taking non-modal overlay lands focus
|
|
3833
|
+
outside the key scope and kills *every* keystroke until Tab recovers. This
|
|
3834
|
+
would be that bug once per level.
|
|
3835
|
+
- **Extracting `Popover` now.** The roadmap's own trigger ("the second *kind* of
|
|
3836
|
+
anchoring") arguably fires here, but both callers still wrap a `List`, so a
|
|
3837
|
+
`Popover < Popup` would move code without a second kind of *content*. The
|
|
3838
|
+
trigger is the first non-`List` content wanting anchoring (Tooltip, a
|
|
3839
|
+
date-picker grid). It originally had a second half — a third placement method
|
|
3840
|
+
on `ListDropdown`, from `ContextMenu`'s `anchor_at(point)` — which went dormant
|
|
3841
|
+
when that widget was iced (`D-no-context-menu`).
|
|
3842
|
+
- **A command-code bus** (Turbo Vision's `cmOpen` + `handleEvent`) instead of
|
|
3843
|
+
per-item callables. Rejected: Ruby has closures, and Vaadin, Terminal.Gui and
|
|
3844
|
+
ratatui's `tui-menu` all landed on per-item listeners.
|
|
3845
|
+
- **`item.submenu` as a separate object** (Vaadin's `getSubMenu()`). It exists
|
|
3846
|
+
because a Vaadin `MenuItem` is a DOM component; a Tuile item is a handle, so
|
|
3847
|
+
`item.add_item` is one hop shorter and makes depth fall out for free.
|
|
3848
|
+
- **`Component::MenuItem` as a top-level constant.** Considered and reverted the
|
|
3849
|
+
same day: promoting it was priced against a breaking rename once `ContextMenu`
|
|
3850
|
+
names the type, and that price is zero, since no release ships in between. With
|
|
3851
|
+
the cost gone the house default (`Tabs::Tab`, `List::Cursor`) wins, and
|
|
3852
|
+
`MenuBar` gets to settle as one coherent component before unification is argued
|
|
3853
|
+
against a second implementation rather than a guess about one. **Now settled
|
|
3854
|
+
rather than deferred:** icing `ContextMenu` removed the counterparty, so
|
|
3855
|
+
`MenuBar::Item` is simply the name. A revival after 0.13.0 ships pays a
|
|
3856
|
+
**Breaking:** changelog line for the rename, or keeps `MenuBar::Item` as an
|
|
3857
|
+
alias — cheap, and only paid if it happens, which beats paying it now for a
|
|
3858
|
+
widget that may never exist.
|
|
3859
|
+
- **A `HasMenuItems` mixin** (Vaadin's shared `MenuBar` / `ContextMenu` /
|
|
3860
|
+
`SubMenu` interface). Not needed yet, and the shape keeps it cheap: `MenuBar`
|
|
3861
|
+
delegates `add_item` / `items` to a captionless root `Item`, so the method
|
|
3862
|
+
exists exactly *once* and a future sharing exercise starts from one
|
|
3863
|
+
implementation rather than two that drifted.
|
|
3864
|
+
- **Separators (`add_separator`).** Looks free, isn't: a `List` has no
|
|
3865
|
+
unselectable row, so the cursor would land on a separator and Enter would
|
|
3866
|
+
activate nothing. It needs a `Cursor` that hops non-selectable positions, which
|
|
3867
|
+
is a `List` decision, not this one.
|
|
3868
|
+
|
|
3869
|
+
**Deliberately not like `Tabs`.** The strip reuses `Tabs`' *hit testing* — an
|
|
3870
|
+
`extent`, one private `segments` method feeding both paint and click — and
|
|
3871
|
+
deliberately not its *look*: no separator column, no bold, and no highlight while
|
|
3872
|
+
unfocused. Both are one-row caption strips with one highlighted segment, so
|
|
3873
|
+
looking alike would leave a reader working out which control they are seeing. Two
|
|
3874
|
+
of the three divergences are forced anyway: bold is `Tabs`' *persistence* channel
|
|
3875
|
+
(the selection must survive focus moving on) and a menu bar has nothing to
|
|
3876
|
+
persist. The segments arithmetic is the second copy of that trio; per AGENTS.md's
|
|
3877
|
+
duplicate-a-shallow-shell rule a third caption strip is when to argue for
|
|
3878
|
+
extraction.
|
|
3879
|
+
|
|
3880
|
+
**Consequences a contributor would trip over.**
|
|
3881
|
+
|
|
3882
|
+
- **Activation is uniform.** Children win over a listener; a leaf closes the
|
|
3883
|
+
cascade *before* firing (so an action that opens a dialog doesn't paint it under
|
|
3884
|
+
a menu, as in `Select#commit`); and an item with **neither** children nor a
|
|
3885
|
+
listener is legal and inert. An item that looks live but does nothing is the
|
|
3886
|
+
app's error to fix, not the framework's to raise on.
|
|
3887
|
+
- **Stepping highlights; only Enter, Space or a click presses.** Left/Right
|
|
3888
|
+
moving to a neighbouring *top-level button* (a listener, no menu) closes the
|
|
3889
|
+
cascade and highlights it — it does not fire it, or walking the strip would
|
|
3890
|
+
trigger every button on the bar, each one behind a menu still standing over its
|
|
3891
|
+
output. Every path that *does* fire a top-level listener closes the cascade
|
|
3892
|
+
first, matching `Cascade`'s own leaf activation.
|
|
3893
|
+
- **A resize closes the menu**, from `MenuBar#rect=` — see the AGENTS.md
|
|
3894
|
+
non-modal-overlay trap for why that is the legal answer here rather than a
|
|
3895
|
+
`reposition` override. Only a *changed* rect closes it, since `Layout::Box`
|
|
3896
|
+
re-assigns an equal rect on any child mutation.
|
|
3897
|
+
- **So does detaching**, from `on_detached`: the panels are the pane's children,
|
|
3898
|
+
not the bar's, so nothing else would take them down.
|
|
3899
|
+
- **An open menu swallows keys; a closed strip does not.** The one deliberate
|
|
3900
|
+
divergence from `D-select`'s claim-the-minimum rule, and the honest reading of
|
|
3901
|
+
what a menu is — an app key firing behind a visible panel is worse than a dead
|
|
3902
|
+
keystroke.
|
|
3903
|
+
- **A click outside an open cascade is not blocked**, because non-modal overlays
|
|
3904
|
+
block nothing — but it does *dismiss*. The framework-level fix this entry
|
|
3905
|
+
called for (and declined to invent here) shipped as `D-outside-click`: the
|
|
3906
|
+
pane closes every popup a left click missed, and `Cascade` reconciles its level
|
|
3907
|
+
stack from each panel's `Popup#on_close`. The click itself still reaches
|
|
3908
|
+
whatever is beneath.
|
|
3909
|
+
- **`Cascade` is provisional.** It is split from the strip on cohesion, not reuse
|
|
3910
|
+
— otherwise `MenuBar` would both paint captions and manage an overlay stack —
|
|
3911
|
+
and the test for keeping it is *the size of the interface `MenuBar` needs*: at
|
|
3912
|
+
`open_below` / `handle_key` / `close` / `open?` it is a boundary; if it grows
|
|
3913
|
+
accessors that expose the level stack, the "class" was only ever a seam and it
|
|
3914
|
+
folds back in.
|
|
3915
|
+
- **Widths are measured per level, caller-side**, third repeat of the `D-select`
|
|
3916
|
+
pattern (`anchor_to` and `anchor_beside` measure nothing). The submenu arrows
|
|
3917
|
+
right-align against the level's *widest label*, a number the cascade already
|
|
3918
|
+
has, so they line up without asking the `List` how wide it ended up.
|
|
3919
|
+
- **The `▸` is Neutral, not Ambiguous** — verified, like `Select`'s `▾`. The
|
|
3920
|
+
obvious `▶` / `▼` are Ambiguous and would have needed an ASCII opt-in under
|
|
3921
|
+
`D-ambiguous-width`.
|
|
3922
|
+
|
|
3923
|
+
**Mnemonics (v2), and why they are legal.** `add_item(caption, mnemonic: "f")`
|
|
3924
|
+
at *every* depth. AGENTS.md deleted `Component#key_shortcut` and the capture
|
|
3925
|
+
phase that scanned a scope subtree, and forbids reintroducing them — but its
|
|
3926
|
+
re-grow rule sanctions exactly this: *sugar over an ancestor's `handle_key`,
|
|
3927
|
+
never a dispatch phase and never a gate*. A focused `MenuBar` consulting its own
|
|
3928
|
+
item tree inside its own rung-3 `handle_key` is unregistered, unscanned and
|
|
3929
|
+
invisible to every other component. This is the first thing a reviewer will
|
|
3930
|
+
(correctly) flag, hence the paragraph.
|
|
3931
|
+
|
|
3932
|
+
The rule is **one live set, no fallback**: the top-level items while the cascade
|
|
3933
|
+
is closed, the deepest open panel's items while it is open, nothing else ever
|
|
3934
|
+
consulted. Cross-level collision is therefore *structurally impossible* rather
|
|
3935
|
+
than tie-broken — `File > Export` and top-level `Edit` may both bind `e`, and
|
|
3936
|
+
with File open there is nothing to arbitrate — and `f`,`q` for File > Quit falls
|
|
3937
|
+
out with no chord, buffer or timeout. A duplicate *within one sibling set* raises
|
|
3938
|
+
at `add_item`, which is the only scope where two mnemonics can race. A miss
|
|
3939
|
+
swallows rather than falling back to a shallower level: a mistyped letter must
|
|
3940
|
+
not tear down the open menu and open another, and Left/Right and ESC are the
|
|
3941
|
+
routes to a different menu. All of this is what Windows/GTK/Qt do; macOS is the
|
|
3942
|
+
only lineage without menu mnemonics, for the historical reason that it never had
|
|
3943
|
+
an Alt-activates-the-menubar model.
|
|
3944
|
+
|
|
3945
|
+
Four consequences worth recording, each a road that looked open:
|
|
3946
|
+
|
|
3947
|
+
- **The match is hoisted above the cascade delegation.** v1's cascade swallows
|
|
3948
|
+
every unrecognized key while open, so a letter would never reach the strip
|
|
3949
|
+
otherwise. It is guarded to a single printable non-space character so Enter,
|
|
3950
|
+
Space, the arrows, ESC and `MOVE_KEYS` keep their v1 path.
|
|
3951
|
+
- **The cue is `Item#cued_caption`, computed once at construction.** There are
|
|
3952
|
+
two paint sites (the strip's segments, the cascade's row renderer) and
|
|
3953
|
+
`StyledString#slice` counts **columns** while a caption search yields a
|
|
3954
|
+
**character** index, so the conversion lives in exactly one place. Safe to
|
|
3955
|
+
precompute — unlike a theme value, it has no live input. Cues are **always
|
|
3956
|
+
drawn**, focused or not: Tuile has no Alt to reveal them with, so the choice is
|
|
3957
|
+
binary and discoverability wins.
|
|
3958
|
+
- **The bell is tied strictly to the swallow**, and guarded by `Keys.printable?`
|
|
3959
|
+
at the swallow site. Unguarded it would ring at HOME, function keys and the
|
|
3960
|
+
five-byte junk `Keys.getkey` returns for an unknown escape sequence. No bell
|
|
3961
|
+
while the strip is *closed* — a bubbled key is not a miss — and none for a
|
|
3962
|
+
matched-but-inert item or a clamped arrow, or "beep when nothing happened"
|
|
3963
|
+
would grow into an audit of every no-op path.
|
|
3964
|
+
- **`List#select(index)` was the one real gap.** The cascade must move a panel's
|
|
3965
|
+
highlight to the matched row *before* drilling, or a submenu anchors beside
|
|
3966
|
+
whatever row the cursor was on — and a row scrolled out of view has no rect to
|
|
3967
|
+
anchor against at all. `List` could move its cursor by key, by mouse and by
|
|
3968
|
+
search, but not by index; that hole is independent of menus.
|
|
3969
|
+
|
|
3970
|
+
**Type-ahead search is deliberately not built.** "Type `s` in an open menu to
|
|
3971
|
+
jump to the first item containing s" is nearly free — `List#select_next` already
|
|
3972
|
+
does substring, case-insensitive, cursor-ordered-with-wrap search — and that is
|
|
3973
|
+
the trap: it competes with explicit mnemonics for the same keystroke, so it owes
|
|
3974
|
+
a precedence rule *and* a ruling on whether a unique match fires or merely
|
|
3975
|
+
highlights. A separate feature, for a later session.
|
|
3976
|
+
|
|
3977
|
+
**Deferred, each additive:** checkable and disabled items, global-shortcut
|
|
3978
|
+
activation (which needs `Keys` to grow function keys first — and this is the
|
|
3979
|
+
deferral that costs something, since with no Alt the only way to *reach* the bar
|
|
3980
|
+
is Tab, which is what separates `Alt+F, X` from a Tab-hunt), removal and
|
|
3981
|
+
reordering, dynamically computed items, open-on-hover
|
|
3982
|
+
(needs mouse motion — Tuile runs X10 mode 1000, press-only), and Vaadin's
|
|
3983
|
+
collapse-into-an-overflow-menu.
|
|
3984
|
+
|
|
3985
|
+
**Update 2026-08-24: `ContextMenu` is iced indefinitely** — designed, priced and
|
|
3986
|
+
declined the same day, in `D-no-context-menu`. It would have reused `Cascade` and
|
|
3987
|
+
`Item` verbatim, which is why the two consequences above are worded the way they
|
|
3988
|
+
are: the nested `Item` name is *settled* rather than deferred, and the `Popover`
|
|
3989
|
+
extraction trigger keeps only its "first non-`List` content" half.
|
|
3990
|
+
|
|
3991
|
+
## D-outside-click — An outside click dismisses a popup, by flag not by notice (2026-08-24)
|
|
3992
|
+
|
|
3993
|
+
**Status:** Decided and implemented 2026-08-24. Designed in a since-retired
|
|
3994
|
+
`ideas/outside-click-dismiss.md`, itself split out of the declined `ContextMenu`
|
|
3995
|
+
(`D-no-context-menu`), so this entry is the whole record. Supersedes the wart
|
|
3996
|
+
`D-menu-bar` recorded without fixing.
|
|
3997
|
+
|
|
3998
|
+
**Context.** Whether an open overlay closed when you clicked elsewhere depended
|
|
3999
|
+
on what you happened to click *on*. A click on a focusable widget moved focus,
|
|
4000
|
+
and losing focus is what closed `Select`'s dropdown and `MenuBar`'s cascade — so
|
|
4001
|
+
it worked, by accident. A click on decoration (a `Label`, a `Window` border, a
|
|
4002
|
+
gap between fields, the status bar row) did nothing at all, and the overlay
|
|
4003
|
+
stayed open over content it no longer belonged to. Three customers felt it:
|
|
4004
|
+
`Select`, `MenuBar`'s whole cascade, and the sampler's slash-menu demo.
|
|
4005
|
+
|
|
4006
|
+
It could not be fixed inside the widgets. `ScreenPane#handle_mouse` routes a
|
|
4007
|
+
click to the topmost popup containing it, else the tiled content, else (with a
|
|
4008
|
+
modal open) nobody — so a click that misses every popup is never reported to the
|
|
4009
|
+
open overlay, no driver can poll for it, and nothing below can forward it. A
|
|
4010
|
+
`ScreenPane` change or nothing.
|
|
4011
|
+
|
|
4012
|
+
**Decision.** `Component::Popup#close_on_outside_click?` (default `true`, modal
|
|
4013
|
+
or not), read by `ScreenPane#handle_mouse`: a left click that misses an open
|
|
4014
|
+
popup closes it. Beside it, `Popup#on_close`, a driver-facing callback fired from
|
|
4015
|
+
`on_detached`.
|
|
4016
|
+
|
|
4017
|
+
**Why a flag and not `on_outside_click(event)`.** The rejected alternative was
|
|
4018
|
+
notice-shaped: every missed popup gets the event, default no-op, with a
|
|
4019
|
+
driver-facing proc beside it. It works, and it is more expressive. It was
|
|
4020
|
+
rejected because it hands a `MouseEvent` to a component that is *not* on the
|
|
4021
|
+
chain the event was delivered to — structurally the same second delivery this
|
|
4022
|
+
project already rejects for a `Screen`-level click broadcast, just with a shorter
|
|
4023
|
+
subscriber list. Under the flag, `ScreenPane` never delivers anything twice: it
|
|
4024
|
+
closes popups that asked in advance to be closed. **The popup receives a fate,
|
|
4025
|
+
not an event**, and "a click is delivered exactly once, down one chain" stays
|
|
4026
|
+
literally true.
|
|
4027
|
+
|
|
4028
|
+
The price is expressiveness: the popup answers with a stored `true`/`false`, not
|
|
4029
|
+
with an opinion about the click. Paid once, by `ComboBox` — its field is tiled,
|
|
4030
|
+
so clicking your own input to reposition the caret closes the list you are
|
|
4031
|
+
filtering. Transient, because `TextField#on_change` is wired to `refill`, which
|
|
4032
|
+
reopens it on the next keystroke, and Vaadin's ComboBox behaves the same way. If
|
|
4033
|
+
per-click nuance is ever genuinely needed, widen the reader to
|
|
4034
|
+
`close_on_outside_click?(event)` — a pure widening, no migration — rather than
|
|
4035
|
+
reaching for a notice or a veto.
|
|
4036
|
+
|
|
4037
|
+
**The ordering rule, both halves load-bearing.** Snapshot the open popups
|
|
4038
|
+
*before* routing, close the opted-in misses *after*.
|
|
4039
|
+
|
|
4040
|
+
- *Snapshot before*, or a popup the delivered click **opened** is in the set and
|
|
4041
|
+
dismisses itself instantly — every `Select` would be unopenable by mouse.
|
|
4042
|
+
- *Close after*, or a widget toggling its own overlay from a click on its face
|
|
4043
|
+
sees a shut overlay and **reopens** it — a `Select`'s dropdown could then never
|
|
4044
|
+
be dismissed by clicking the Select.
|
|
4045
|
+
|
|
4046
|
+
Both are specced, and both mutations also break *pre-existing* `Select` specs.
|
|
4047
|
+
The snapshot is a fresh array for a third reason: a handler may close further
|
|
4048
|
+
popups, and `@popups` must not be mutated mid-iteration.
|
|
4049
|
+
|
|
4050
|
+
**"Outside" spans the owner chain, and stacking order plays no part.**
|
|
4051
|
+
`Popup#owner` names the component an overlay is *part of* (`nil` = an overlay in
|
|
4052
|
+
its own right). A click keeps the popup it hit *and* every popup that one
|
|
4053
|
+
belongs to, transitively; everything else dismissable closes. The owner is any
|
|
4054
|
+
`Component` — a driver hands its dropdown `self` — and the pane resolves it to
|
|
4055
|
+
the enclosing popup at click time, a `Popup` resolving to itself.
|
|
4056
|
+
|
|
4057
|
+
Two bugs forced this, both found by clicking rather than by reasoning, and both
|
|
4058
|
+
after the naive "closed if it missed my rect" rule had shipped:
|
|
4059
|
+
|
|
4060
|
+
- **A cascade panel is beside its parent, not inside it.** Drilling by mouse
|
|
4061
|
+
(File → Open Recent → Archive) dismissed every shallower panel, so the File
|
|
4062
|
+
menu vanished the moment you clicked into its own submenu.
|
|
4063
|
+
- **A dropdown routinely hangs past its dialog's border.** A `ComboBox` or
|
|
4064
|
+
`Select` on a dialog's lower rows drops a panel outside the dialog's rect, so
|
|
4065
|
+
clicking a row dismissed the dialog. The most common form layout there is.
|
|
4066
|
+
|
|
4067
|
+
Neither is reachable by a widget-local fix: the panels and the dialog are
|
|
4068
|
+
different popups with no way to speak for each other.
|
|
4069
|
+
|
|
4070
|
+
**Two rejected rules, and why order is the wrong axis.** *Dismiss the popups
|
|
4071
|
+
stacked above the one you clicked* (standard light-dismiss layering) fixes both
|
|
4072
|
+
bugs with no new API, and was rejected because `@popups` is insertion order and
|
|
4073
|
+
Tuile has no click-to-raise: the same click would produce different outcomes
|
|
4074
|
+
depending on which overlay opened first. *A click on any overlay dismisses
|
|
4075
|
+
nothing* also fixes both, needs no API at all, and was rejected because it
|
|
4076
|
+
declares unrelated overlays related — it leaves a dropdown open when you click
|
|
4077
|
+
the dialog beneath it, and stops two window-like overlays from dismissing each
|
|
4078
|
+
other, which is exactly what they should do.
|
|
4079
|
+
|
|
4080
|
+
Order is only ever the *shadow* of ownership: a child overlay cannot exist
|
|
4081
|
+
before its host, so it is always later in the stack. Reading the shadow works
|
|
4082
|
+
for related popups and is meaningless for unrelated ones, which is why the
|
|
4083
|
+
relationship is declared instead.
|
|
4084
|
+
|
|
4085
|
+
Consequences kept: every dismissable popup closes, not just the topmost — a
|
|
4086
|
+
cascade must vanish whole on one background click, not peel one panel per click
|
|
4087
|
+
— and two *unrelated* stacked modals both close on one outside click, where
|
|
4088
|
+
Vaadin's curtain would close only the top. Arguably Vaadin-consistent anyway:
|
|
4089
|
+
the Flow Dialog docs say closing a modal Dialog also closes the dialogs opened
|
|
4090
|
+
after it.
|
|
4091
|
+
|
|
4092
|
+
**The cost, stated plainly.** `owner` is a declaration you can forget, and
|
|
4093
|
+
forgetting it silently reproduces the two bugs above. It is the third entry in
|
|
4094
|
+
AGENTS.md's non-modal-overlay traps for that reason. Three sites wire it today:
|
|
4095
|
+
`ComboBox` and `Select` hand their dropdown `self` at construction (not per
|
|
4096
|
+
open, so there is nothing to forget on reopen), and `Cascade#push` chains each
|
|
4097
|
+
panel to the one it dropped out of. Level 0 owns nothing on purpose — a click on
|
|
4098
|
+
a dialog hosting the bar *should* close the whole menu and keep the dialog. A
|
|
4099
|
+
mis-wired cycle terminates rather than hanging, guarded by the walk.
|
|
4100
|
+
|
|
4101
|
+
**Why `on_close` hangs off `on_detached`, never `#close`.** A popup leaves the
|
|
4102
|
+
screen three ways — `Popup#close`, a direct `Screen#remove_popup`, and
|
|
4103
|
+
`Screen#close` → `detach_all`. Hang the proc off `#close` and two of those vanish
|
|
4104
|
+
silently, which is the desync the mechanism exists to kill, reintroduced one
|
|
4105
|
+
level up. `parent=` is already the sole firing site for the lifecycle hooks, so a
|
|
4106
|
+
proc over `on_detached` keeps that true and makes the notice unconditional. The
|
|
4107
|
+
subclass trap that follows: `Notification#on_detached` already existed and now
|
|
4108
|
+
calls `super`.
|
|
4109
|
+
|
|
4110
|
+
`MenuBar::Cascade` is the worked example and the reason the callback exists. It
|
|
4111
|
+
keeps `@levels` as the sole authority on depth, so a panel closing behind its
|
|
4112
|
+
back would leave `depth` / `deepest` / `highlighted` all lying. It wires an
|
|
4113
|
+
identity-keyed, idempotent delete — idempotent because the same notice also
|
|
4114
|
+
arrives from its own `truncate` (which has already popped the entry) and from
|
|
4115
|
+
teardown, in no guaranteed order. That is the shape the house rules ask for:
|
|
4116
|
+
`@levels` is a `D-tree-api`-style second copy of a list slot, and hook-owned
|
|
4117
|
+
state is *synced from an invariant*, not toggled by the hooks (`D-progress-bar`'s
|
|
4118
|
+
`sync_ticker`). Per-level truncate closures wired at `push` are the toggle
|
|
4119
|
+
version.
|
|
4120
|
+
|
|
4121
|
+
**Left button only.** `MouseEvent` is X10 press-only (no release, no motion), so
|
|
4122
|
+
there is no drag case. Excluding scroll is `D-notification`'s stray-spin lesson;
|
|
4123
|
+
excluding `:right` keeps a future context action from nuking an open dropdown.
|
|
4124
|
+
|
|
4125
|
+
**Vaadin, verified against the 24 docs.** "Modal dialogs are closable in three
|
|
4126
|
+
ways: by pressing Esc; clicking outside the Dialog; or programmatically", and
|
|
4127
|
+
"Dialogs are modal by default" — so default-`true`-even-for-modals is the Vaadin
|
|
4128
|
+
behavior, and that is why the default is what it is. What does *not* port: in
|
|
4129
|
+
Vaadin the thing catching the outside click is the modality curtain, part of the
|
|
4130
|
+
overlay, so light dismiss is nearly free because modality is a DOM element.
|
|
4131
|
+
Tuile's modality is a routing rule with nothing to click on, so the notice must
|
|
4132
|
+
be manufactured. Which also means Vaadin's *non-modal* behavior is no precedent
|
|
4133
|
+
here — a non-modal Vaadin Dialog does not light-dismiss; its ComboBox overlay
|
|
4134
|
+
does.
|
|
4135
|
+
|
|
4136
|
+
**The modal/non-modal split dissolves.** The design was framed as two halves,
|
|
4137
|
+
only one with a customer: non-modal overlays needing a notice, and modals unable
|
|
4138
|
+
to hear a click at all. The flag applies identically to both, and no
|
|
4139
|
+
`clicked ||= modal_popup` routing change is needed, because nothing is
|
|
4140
|
+
*delivered* to the modal — it is just closed. An outside click on a modal both
|
|
4141
|
+
dismisses it and is swallowed (click once to dismiss, again to act), same as
|
|
4142
|
+
Vaadin's curtain.
|
|
4143
|
+
|
|
4144
|
+
**Roads not taken.** A veto — `on_close` (or a new hook) returning false to
|
|
4145
|
+
refuse the close: rejected mechanically, since `on_close` fires from
|
|
4146
|
+
`on_detached`, after the popup is off the screen, and you cannot un-detach. Any
|
|
4147
|
+
veto therefore needs a *new*, earlier hook, which is the notice again with a
|
|
4148
|
+
return channel, and it makes every grouped overlay re-implement the geometry
|
|
4149
|
+
test the pane just did. A `Screen`-level "a click landed at P" broadcast any
|
|
4150
|
+
component can subscribe to: rejected on sight, a second mouse-dispatch path
|
|
4151
|
+
beside the one-chain rule. Making `ListDropdown` modal so it hears every click:
|
|
4152
|
+
`ComboBox` and `Select` would lose the events their own faces need. A generation
|
|
4153
|
+
counter to make close-and-reopen-within-one-click safe: over-engineering for a
|
|
4154
|
+
case nothing hits — reopening the *same* popup object during delivery of one
|
|
4155
|
+
click is out of contract (the snapshot holds it), and the answer is a fresh popup
|
|
4156
|
+
or a cleared flag.
|
|
4157
|
+
|
|
4158
|
+
**Per-widget settings.** `Popup` defaults `true`; `ListDropdown` inherits it, so
|
|
4159
|
+
`Select`, `ComboBox` and every cascade panel are fixed with zero wiring;
|
|
4160
|
+
`Notification` sets `false`, since a toast is timed and an unrelated click is not
|
|
4161
|
+
about it; app modals keep `true` and opt out per dialog. The one accepted risk is
|
|
4162
|
+
a stray click discarding a half-filled form dialog.
|
|
4163
|
+
|
|
4164
|
+
## D-no-context-menu — No `ContextMenu`: designed, priced and declined (2026-08-24)
|
|
4165
|
+
|
|
4166
|
+
**Status:** Decided 2026-08-24 — **not building it**, indefinitely. Designed in a
|
|
4167
|
+
since-retired `ideas/context-menu.md` (opened and graduated the same day), so
|
|
4168
|
+
this entry is the whole record. `Context Menu` was *dropped* from
|
|
4169
|
+
`ideas/new-components.md` rather than demoted to its Tier 3, and nothing else
|
|
4170
|
+
tracks it.
|
|
4171
|
+
|
|
4172
|
+
**Context.** The roadmap listed it as a Tier 1 near-freebie — "same as Menu Bar;
|
|
4173
|
+
`:right` already parses" — and after `MenuBar` shipped that looked right: the item
|
|
4174
|
+
tree, the mnemonics, the cascading submenus and the per-level width measurement
|
|
4175
|
+
all exist and would have been reused as they stand. The design confirmed it. The
|
|
4176
|
+
widget then failed on its *inputs*, not on its machinery, which is why this entry
|
|
4177
|
+
is a rejection rather than a deferral.
|
|
4178
|
+
|
|
4179
|
+
**Decision, and the three reasons in order of weight.**
|
|
4180
|
+
|
|
4181
|
+
1. **The gesture that defines the widget is the least reliable input Tuile has.**
|
|
4182
|
+
A context menu *is* right-click, and terminal emulators routinely keep that
|
|
4183
|
+
button for their own menu (some pass it through only with Shift) — on top of
|
|
4184
|
+
mouse reporting being optional in the first place. The keyboard route then has
|
|
4185
|
+
to be invented from nothing: no terminal sends a context-menu event, where a
|
|
4186
|
+
browser hands Vaadin `contextmenu` from Shift+F10 *and* the Menu key, so
|
|
4187
|
+
Vaadin's `ContextMenu` needs no keyboard code at all. **And Shift+F10 is not
|
|
4188
|
+
readable today:** `Keys.getkey` gulps at most 5 bytes after `\e` — deliberately,
|
|
4189
|
+
since 6 would over-read the next event on a mouse burst — while xterm sends
|
|
4190
|
+
`\e[21;2~`, 6 tail bytes, so the `~` would surface as a printable keypress.
|
|
4191
|
+
`\e[29~` (Menu/Apps) and plain F1–F12 *do* fit. That constraint binds anything
|
|
4192
|
+
wanting an exotic key, not just menus.
|
|
4193
|
+
2. **No host wants one.** Not the sampler, not `file_commander`, and the TUI
|
|
4194
|
+
lineages are thin: mc spends F9 on a menu bar instead, Turbo Vision and LazyGit
|
|
4195
|
+
have none. LazyVim is the counterexample — it does ship one — which is an
|
|
4196
|
+
argument for revisiting when a host asks, not for building on spec.
|
|
4197
|
+
3. **It would cost two new framework concepts to serve nobody** — an invisible
|
|
4198
|
+
modal popup as a focus grab, and a `ScreenPane` notice for modality-blocked
|
|
4199
|
+
clicks (the second outlived it; see below).
|
|
4200
|
+
|
|
4201
|
+
**The design that would have been built,** recorded so a revival starts here. One
|
|
4202
|
+
structural fact drives all of it: **a popup can only hold focus if it is modal.**
|
|
4203
|
+
`ScreenPane#handle_key` scopes delivery to `modal_popup || content`, so a *focused
|
|
4204
|
+
non-modal* popup sits outside the key scope and every keystroke goes dead —
|
|
4205
|
+
AGENTS.md's non-modal-overlay trap. There is no third option, and unlike a menu
|
|
4206
|
+
bar a context menu has no strip to park focus on.
|
|
4207
|
+
|
|
4208
|
+
So: `ContextMenu < Popup(modal: true)` with a **zero-size rect that paints
|
|
4209
|
+
nothing** — not a picture but a *grab*, playing exactly the role `MenuBar`'s strip
|
|
4210
|
+
plays (focus holder, key scope, lifecycle owner, outside-click sink). Every
|
|
4211
|
+
visible panel, level 0 included, is a `Cascade` level, so `Cascade` and `Item` are
|
|
4212
|
+
reused verbatim and mnemonics work with no new code at all. Modality then hands
|
|
4213
|
+
over focus save/restore (`@popup_prior_focus`), an inert Tab (`cycle_focus` scopes
|
|
4214
|
+
stops to `modal_popup`, and a grab has none) and click-blocking for free. Two
|
|
4215
|
+
openers, because the desktop lineages agree these are different placements:
|
|
4216
|
+
`open_at(point)` for the mouse, `open_below(rect)` for the keyboard — pointer
|
|
4217
|
+
versus selection. Framework growth: `ListDropdown#anchor_at(point)`, a second
|
|
4218
|
+
level-0 entry point on `Cascade`, the blocked-click notice, and overrides for
|
|
4219
|
+
`reposition` (or close-on-resize, as `MenuBar#rect=` does), for `q`/ESC — `q` has
|
|
4220
|
+
to stay available as a mnemonic — and for `keyboard_hint`.
|
|
4221
|
+
|
|
4222
|
+
**Alternatives rejected.**
|
|
4223
|
+
|
|
4224
|
+
- **Host-driven, no new machinery** — a plain object the host wires from its own
|
|
4225
|
+
`handle_mouse` / `handle_key`, i.e. `MenuBar`'s architecture minus the
|
|
4226
|
+
component. It costs the framework nothing, and that is the trap: `MenuBar`
|
|
4227
|
+
encodes five invariants *once* because it is a component — close on focus loss,
|
|
4228
|
+
on detach, on resize, swallow keys while open, forward the mouse — and every
|
|
4229
|
+
host would re-encode all five. Forgetting `on_detached` strands panels on the
|
|
4230
|
+
pane with nothing to take them down, the exact bug class AGENTS.md's
|
|
4231
|
+
non-modal-overlay section exists to prevent.
|
|
4232
|
+
- **The level-0 panel *as* the modal popup**, which deletes the invisible
|
|
4233
|
+
component. Rejected because level 0 then becomes structurally unlike every
|
|
4234
|
+
deeper level, so the panel-driving logic — `MOVE_KEYS` to the highlight, Enter
|
|
4235
|
+
to drill-or-fire, mnemonic match, truncate-on-cursor-move — exists twice for
|
|
4236
|
+
panels that are identical on screen. It buys only the deletion of a zero-size
|
|
4237
|
+
rect.
|
|
4238
|
+
- **Recursive modal popups, one per level, no `Cascade`** — each level an ordinary
|
|
4239
|
+
modal `Popup` over a *focusable* `List`, with `Popup`'s own ESC/`q` closing a
|
|
4240
|
+
level. Genuinely tiny and free of every non-modal trap, and rejected on the
|
|
4241
|
+
smell: it is a *second* menu mechanism, so item trees, mnemonics, submenu
|
|
4242
|
+
arrows, width measurement and the key map would all get a second
|
|
4243
|
+
implementation. If it is right, `MenuBar` is wrong — a much larger argument
|
|
4244
|
+
than this widget.
|
|
4245
|
+
- **A `Component#context_menu=` slot** checked inside `Component#handle_key`, so
|
|
4246
|
+
any component gets one by assignment. Half a feature: almost no widget calls
|
|
4247
|
+
`super` from its own `handle_key` (`List` doesn't), so it would work for
|
|
4248
|
+
ancestors that don't override and silently not for focused leaves.
|
|
4249
|
+
- **Vaadin's `setTarget(component)`** — attach the menu to a target and let the
|
|
4250
|
+
framework route the right-click to it. Tuile has nothing to build that on:
|
|
4251
|
+
`handle_mouse` returns `void`, and a right-click already reaches *every*
|
|
4252
|
+
component along the rect chain, ancestor first and deepest last, so "which
|
|
4253
|
+
target owns this click" has no answer. (What that ordering *would* give free is
|
|
4254
|
+
deepest-wins, if a revival adds "opening one closes any other open context
|
|
4255
|
+
menu" — the `D-notification` shape, found by scanning the popups stack rather
|
|
4256
|
+
than a class ivar.)
|
|
4257
|
+
- **Type-ahead search inside an open menu**, which `List#select_next` makes nearly
|
|
4258
|
+
free. Same rejection as in `D-menu-bar`: it competes with explicit mnemonics for
|
|
4259
|
+
the same keystroke and owes a precedence rule.
|
|
4260
|
+
|
|
4261
|
+
**Two gaps it surfaced that outlive it.**
|
|
4262
|
+
|
|
4263
|
+
- **An outside click on an open overlay notified nobody.** `Select`, `MenuBar`
|
|
4264
|
+
and the sampler's slash menu all lingered on a click that landed on decoration,
|
|
4265
|
+
and a modal popup could not dismiss on an outside click at all. **Closed**
|
|
4266
|
+
2026-08-24 by `D-outside-click`, which also dissolved the modal/non-modal split
|
|
4267
|
+
the gap was framed around.
|
|
4268
|
+
- **A right-click does not move a `List` cursor.** `List::Cursor#handle_mouse`
|
|
4269
|
+
acts on `:left` only (specced), and there is no public `item_index_at(point)`,
|
|
4270
|
+
so "act on the row I clicked" is unsayable unless the app computes
|
|
4271
|
+
`event.y - rect.top + scroll_top_row` itself. Nothing needs it today; it is the
|
|
4272
|
+
same shape of hole as the `List#select(index)` gap `D-menu-bar` had to fill.
|
|
4273
|
+
|
|
4274
|
+
---
|
|
4275
|
+
|
|
4276
|
+
## D-status-bar — Delete the framework status bar; the app owns its bottom row (2026-08-25)
|
|
4277
|
+
|
|
4278
|
+
**Status:** Accepted 2026-08-25; unimplemented. Supersedes the shipped
|
|
4279
|
+
`ScreenPane#status_bar` slot and the `Component#keyboard_hint` channel that fed
|
|
4280
|
+
it — see *the scar* at the end. Retires `ideas/status-bar-ownership.md`.
|
|
4281
|
+
|
|
4282
|
+
**Context.** `ScreenPane` has always reserved the bottom terminal row for a
|
|
4283
|
+
framework-owned `Label`, and `Screen#refresh_status_bar` filled it on every
|
|
4284
|
+
focus change from three sources: a hardcoded `"q quit"`, the `hint:` strings on
|
|
4285
|
+
registered global shortcuts, and one component's `keyboard_hint` — the innermost
|
|
4286
|
+
active `Window` (found by an `is_a?` scan) when tiled, the top popup's *direct*
|
|
4287
|
+
content when not.
|
|
4288
|
+
|
|
4289
|
+
That last source barely worked. Of the seven `keyboard_hint` implementations,
|
|
4290
|
+
only `Window`, `Popup` and `PickerWindow` (a `Window`) were reachable in any
|
|
4291
|
+
configuration; `MenuBar`, `Tabs`, `Select` and `ComboBox` were dead
|
|
4292
|
+
**everywhere**, tiled and popup alike, because nothing walked down to the
|
|
4293
|
+
focused component and the popup path forwarded only to its direct child. The
|
|
4294
|
+
obvious fix — ask `screen.focused` and walk up, matching the delivery bubble —
|
|
4295
|
+
was drafted, and a survey of the two real consumers was run to choose between
|
|
4296
|
+
it and two variants. The survey concluded the channel should be **deleted**.
|
|
4297
|
+
|
|
4298
|
+
**Decision.** Delete the status bar and the hint channel. `ScreenPane` no longer
|
|
4299
|
+
owns a `Label`, no longer reserves `height - 1`, and `Component#keyboard_hint`
|
|
4300
|
+
ceases to exist. In its place `Screen` gains one notification —
|
|
4301
|
+
`on_focus_changed=`, a plain proc fired from `focused=`, matching the
|
|
4302
|
+
`on_theme_changed=` style stock assemblies already use. An app that wants a
|
|
4303
|
+
status bar builds one:
|
|
4304
|
+
|
|
4305
|
+
```ruby
|
|
4306
|
+
bar = Tuile::Component::Label.new
|
|
4307
|
+
root = Tuile::Component::Layout::Vertical.new
|
|
4308
|
+
root.add(main, Expand)
|
|
4309
|
+
root.add(bar, Fixed[1])
|
|
4310
|
+
screen.on_focus_changed = -> { bar.text = hint_for(screen.focused) }
|
|
4311
|
+
```
|
|
4312
|
+
|
|
4313
|
+
**Why deletion beat a better hint source.**
|
|
4314
|
+
|
|
4315
|
+
- **No app has ever wanted a *widget's* hint.** Across four apps, virtui
|
|
4316
|
+
advertises window-level app keys (`"p Power v run Viewer m Memory d toggle
|
|
4317
|
+
Disk stat / Search"`) and pikuri-tui advertises global app keys (`"^K menu"`,
|
|
4318
|
+
`"^C cancel"`). Neither has ever advertised a `Select`'s or `ComboBox`'s keys.
|
|
4319
|
+
The channel was not merely unused by four of its seven implementors — the
|
|
4320
|
+
thing it was designed to carry is something nobody wants carried.
|
|
4321
|
+
- **An app was routing presentation through dispatch.** pikuri re-registers a
|
|
4322
|
+
global keybinding to change a status-bar string, and documents the technique
|
|
4323
|
+
in rdoc: "`Screen` replaces the binding in place on re-register, so this is
|
|
4324
|
+
also how the hint stays in sync with the counter." The bar was write-only from
|
|
4325
|
+
the app's side, so a *text* change had to be expressed as a *binding* change.
|
|
4326
|
+
That is the design inverted, not a missing feature — and it is the single
|
|
4327
|
+
finding that settled this.
|
|
4328
|
+
- **The one reachable widget hint was also stale.** `MenuBar#keyboard_hint`
|
|
4329
|
+
switched to `"↑↓ move ⏎ select"` with the cascade open, but the cascade is a
|
|
4330
|
+
non-focusable `ListDropdown`, so focus never changed and `refresh_status_bar`
|
|
4331
|
+
never ran (it fired from `focused=`, `theme=` and the two registry mutators —
|
|
4332
|
+
never from `add_popup`). Opening a menu did not update the bar; *closing* it
|
|
4333
|
+
did, via focus repair. Dead twice over.
|
|
4334
|
+
- **The bar is a layout special case that `Box` layouts obsoleted.** The
|
|
4335
|
+
`height - 1` reservation is v0.1-era, from before `Vertical`/`Fixed` existed.
|
|
4336
|
+
An app-owned bar is now three lines, and buys what the framework can never
|
|
4337
|
+
offer: two rows, a bar at the top, its own styling, a file-commander
|
|
4338
|
+
function-key strip, or nothing at all.
|
|
4339
|
+
- **It is the shape the top-down re-grow rule already governs.** That rule says
|
|
4340
|
+
a deleted bottom-up channel may return only as an *optional, read-only,
|
|
4341
|
+
caller-side query*, never as an automatic channel the framework consults.
|
|
4342
|
+
`keyboard_hint` was an automatic channel; deleting it applies the rule Tuile
|
|
4343
|
+
already lives by.
|
|
4344
|
+
- **The framework baked an app policy.** The `"q quit"` prefix was
|
|
4345
|
+
unconditional: pikuri's three apps quit via `^K → q`, and their bar read
|
|
4346
|
+
`q quit ^K menu` while `q` typed into the focused input just typed a `q`.
|
|
4347
|
+
|
|
4348
|
+
**Alternatives rejected.**
|
|
4349
|
+
|
|
4350
|
+
- *Walk the focus chain and concatenate (the drafted fix).* Correct as far as it
|
|
4351
|
+
went — it matched the delivery bubble, subsumed the popup special case, and
|
|
4352
|
+
would have deleted `active_window`. Rejected because it fixes *reachability*
|
|
4353
|
+
while leaving ownership where it hurts: pikuri's re-registration hack survives
|
|
4354
|
+
it untouched, and MenuBar's flickering, redundant `←→ menu ⏎ open` becomes
|
|
4355
|
+
*visible* rather than merely dead. It also forced a ruling on hint ordering
|
|
4356
|
+
that is really a truncation policy, since `Label` ellipsizes and the rightmost
|
|
4357
|
+
hint silently vanishes on a narrow terminal.
|
|
4358
|
+
- *Ask `active_window` and forward down the active chain.* Keeps `Window` as the
|
|
4359
|
+
unit of "what am I looking at" but re-implements the focus walk, and preserves
|
|
4360
|
+
the framework's only place where a *class* is special-cased for behavior.
|
|
4361
|
+
- *Keep the bar, make it optional.* A `status_bar: false` flag leaves every
|
|
4362
|
+
defect in place for whoever leaves it on, and adds framework surface in the
|
|
4363
|
+
middle of an argument for less of it.
|
|
4364
|
+
- *Drop only `MenuBar#keyboard_hint`.* Treats the symptom. Three other widget
|
|
4365
|
+
hints stay dead, and the ownership inversion is untouched.
|
|
4366
|
+
- *Keep `Component#keyboard_hint` as a documented seam, delete only the
|
|
4367
|
+
renderer.* Tempting — it preserves a common vocabulary for a future component
|
|
4368
|
+
ecosystem. Rejected for now because a seam with no framework consumer is
|
|
4369
|
+
precisely the automatic-channel-with-no-caller the re-grow rule exists to
|
|
4370
|
+
prevent, and because the built-in hints it would preserve are the four nobody
|
|
4371
|
+
wants. See the re-grow shape below.
|
|
4372
|
+
|
|
4373
|
+
**Consequences — what was given up, honestly.**
|
|
4374
|
+
|
|
4375
|
+
- **Zero-config batteries are gone.** `book/01-first-app.md` said "you never
|
|
4376
|
+
created a status bar, yet the app has one", and `hello_world_spec` asserted on
|
|
4377
|
+
`q quit`. A first app now shows an empty bottom row until it builds one. Ruled
|
|
4378
|
+
acceptable: a bar the app cannot drive is not a battery, and ch1 gains a
|
|
4379
|
+
better story once the bar is three lines of `Vertical`.
|
|
4380
|
+
- **A widget's keys are no longer self-describing.** An app that *does* want to
|
|
4381
|
+
advertise a `ComboBox`'s keys must hardcode `"↑↓ select ⏎ accept"` itself,
|
|
4382
|
+
duplicating knowledge that lived in the widget. No app has ever done this, but
|
|
4383
|
+
the duplication is real if one starts.
|
|
4384
|
+
- **`book/05-focus.md`'s "The status bar writes itself" section goes.** It
|
|
4385
|
+
claimed the bar was "driven by focus" and showed "the focused context's own
|
|
4386
|
+
advertised hint" — behavior that never existed; focus only triggered the
|
|
4387
|
+
rebuild. Deleting it removes a documented promise the code never kept.
|
|
4388
|
+
- **`D-boolean-fields`' aside is retired**, not overruled: "hints are a
|
|
4389
|
+
window/popup-level affordance; per-field hints would drown the status bar" was
|
|
4390
|
+
an argument about where a hint belongs, and there is no longer a framework
|
|
4391
|
+
hint to place.
|
|
4392
|
+
- **A modal {Component::Popup} no longer shows how to close itself.**
|
|
4393
|
+
`Popup#keyboard_hint`'s `q Close` was the only affordance, and — unlike
|
|
4394
|
+
`PickerWindow`'s hint, which merely repeated the option keys its own `List`
|
|
4395
|
+
rows already paint — nothing else on screen carries it. `popup.rb`'s `q`/ESC
|
|
4396
|
+
handler is untouched, so the behavior remains; only the advertisement is gone.
|
|
4397
|
+
**Ruled acceptable 2026-08-25 on the Vaadin precedent:** a Vaadin `Dialog`
|
|
4398
|
+
closes on ESC and no Vaadin *app* documents that anywhere — it lives in the
|
|
4399
|
+
framework's own docs and javadoc, which end users never read. ESC-dismisses-an
|
|
4400
|
+
-overlay is a convention the user brings with them, not something each app has
|
|
4401
|
+
to teach. An app that wants it spelled out writes it into its own row.
|
|
4402
|
+
|
|
4403
|
+
**The `q`/ESC quit fallback stays** (`Screen#event_loop`:
|
|
4404
|
+
`@event_queue.stop if !handled && ["q", Keys::ESC].include?(key)`). It is the
|
|
4405
|
+
same baked app policy as the `"q quit"` string, but it is *dispatch*, not
|
|
4406
|
+
presentation, and it is separable — deleting it would make every example and
|
|
4407
|
+
both downstream apps grow a quit handler in the same breath as an unrelated
|
|
4408
|
+
change. **Ruled 2026-08-25 by `D-quit-key`: it stays, unadvertised**, on the
|
|
4409
|
+
same convention argument as the popup's lost `q Close` above.
|
|
4410
|
+
|
|
4411
|
+
**There is no app-facing `keyboard_hint` convention, and the book must not
|
|
4412
|
+
teach one.** The first cut of `examples/file_commander.rb` kept a
|
|
4413
|
+
`PaneWindow#keyboard_hint` and walked up the focus chain via
|
|
4414
|
+
`respond_to?(:keyboard_hint)` to find it — which re-created the deleted seam by
|
|
4415
|
+
convention, in three places at once (the example, the book, virtui), with a
|
|
4416
|
+
duck-type where a declared method used to be. It was also *dead*: both panes
|
|
4417
|
+
were `PaneWindow`s returning the same constant, so the focus hook, the walk and
|
|
4418
|
+
the duck-type together computed a value that never changed. The example is now
|
|
4419
|
+
a static `Label` and `PaneWindow` is gone; book ch5 leads with "a status line is
|
|
4420
|
+
a `Label` in your layout", and treats {Screen#on_focus_changed=} as the
|
|
4421
|
+
*exception* for a row that genuinely varies. The walk survives only in virtui,
|
|
4422
|
+
where three windows really do advertise different keys — as one app's design
|
|
4423
|
+
decision, named as such.
|
|
4424
|
+
|
|
4425
|
+
**Re-grow rule.** A hint channel may come back only as **a query the app pulls,
|
|
4426
|
+
never a channel the framework pushes** — and specifically not as a
|
|
4427
|
+
framework-owned row. Textual is the shape to copy if it does: its `Footer` is a
|
|
4428
|
+
widget the app mounts in `compose()`, reading from the `BINDINGS` table the
|
|
4429
|
+
framework owns ⚠. That splits ownership at the right seam — the app decides
|
|
4430
|
+
whether a bar exists and where, the widget declares its keys — and it is already
|
|
4431
|
+
on record as steal-candidate #1 in `D-key-dispatch`. Bringing back a bar the
|
|
4432
|
+
framework *places* reopens this entry.
|
|
4433
|
+
|
|
4434
|
+
**Prior art** (surveyed 2026-08-25; ⚠ marks memory-based claims worth checking
|
|
4435
|
+
before acting). The honest reading is that a framework-owned status *row* is a
|
|
4436
|
+
minority position, and the one framework that does it well does not own the row:
|
|
4437
|
+
|
|
4438
|
+
| | Owns a status row? | Where the text comes from |
|
|
4439
|
+
|---|---|---|
|
|
4440
|
+
| **Turbo Vision** | yes — `TStatusLine`, always present | declarative `TStatusDef` tables keyed by help context ⚠ |
|
|
4441
|
+
| **Textual** | no — `Footer` is a widget you mount | the framework's `BINDINGS` tables ⚠ |
|
|
4442
|
+
| **Swing** | no | app-written `JLabel` in `BorderLayout.SOUTH` |
|
|
4443
|
+
| **ncurses / Bubbletea / Ratatui** | no | app draws every cell |
|
|
4444
|
+
| **Tuile (before)** | yes — `ScreenPane#status_bar` | `active_window&.keyboard_hint` + registry hints + `"q quit"` |
|
|
4445
|
+
| **Tuile (after)** | no | app-drawn, from `on_focus_changed` |
|
|
4446
|
+
|
|
4447
|
+
Turbo Vision is the only real precedent for the shipped design, and it paired
|
|
4448
|
+
the row with a declarative binding table — the half Tuile never had, which is
|
|
4449
|
+
why its bar could only be fed by an inverted registration hack.
|
|
4450
|
+
|
|
4451
|
+
**The scar.** The status bar was never designed for Tuile. It arrived whole in
|
|
4452
|
+
`4491a77`, the 0.1.0 commit that ported virtui's `lib/ttyui/` under the `Tuile`
|
|
4453
|
+
namespace — it was *virtui's* status bar, generalized by accident of extraction,
|
|
4454
|
+
and virtui is to this day the only app using the `keyboard_hint` half. It then
|
|
4455
|
+
survived every later overhaul (the top-down layout rewrite, the key-ladder
|
|
4456
|
+
deletion, the tree-first split) without anyone asking who it was for, while each
|
|
4457
|
+
new widget dutifully grew a hint nobody could see. The tell sat in the code the
|
|
4458
|
+
whole time: `Screen#active_window` was public API with exactly one caller —
|
|
4459
|
+
this one — and no app ever invoked it.
|
|
4460
|
+
|
|
4461
|
+
---
|
|
4462
|
+
|
|
4463
|
+
## D-quit-key — `q` / ESC quit the loop, unadvertised, as a Tuile quirk (2026-08-25)
|
|
4464
|
+
|
|
4465
|
+
**Status:** Accepted 2026-08-25; no code change — this records a decision to
|
|
4466
|
+
*keep* what ships. Closes the question `D-status-bar` deferred.
|
|
4467
|
+
|
|
4468
|
+
**Context.** `Screen#event_loop` ends with
|
|
4469
|
+
`@event_queue.stop if !handled && ["q", Keys::ESC].include?(key)` — after the
|
|
4470
|
+
three-rung ladder has declined a key, bare `q` or ESC stops the loop and the
|
|
4471
|
+
app exits. It is app policy the framework enforces, and no app opted into it.
|
|
4472
|
+
|
|
4473
|
+
`D-status-bar` deleted the framework status bar and with it the hardcoded
|
|
4474
|
+
`"q quit"` prefix that was this fallback's only advertisement, deliberately
|
|
4475
|
+
leaving the behavior alone as a separate question. That left the least coherent
|
|
4476
|
+
state of the three: a hardcoded quit key with nothing anywhere surfacing it.
|
|
4477
|
+
|
|
4478
|
+
**Decision.** Keep it exactly as it is, unadvertised, and stop treating it as an
|
|
4479
|
+
open question.
|
|
4480
|
+
|
|
4481
|
+
- **It is a convention, not an invention.** `q` quits `less`, `man`, `top`,
|
|
4482
|
+
`htop` and every pager git shells out to; ESC dismisses. A user arriving at a
|
|
4483
|
+
full-screen terminal app already tries both. That is the same argument that
|
|
4484
|
+
settled the popup's lost `q Close` hint in `D-status-bar` — a convention the
|
|
4485
|
+
user brings is not something each app must teach.
|
|
4486
|
+
- **The escape hatch already exists and needs no new surface.** A component
|
|
4487
|
+
keeps `q` by consuming it, which is the whole of `D-key-dispatch`'s
|
|
4488
|
+
delivery rung: a focused {Component::TextField} does it for free (`q` is
|
|
4489
|
+
printable — this is why pikuri-tui's shells never quit on a typed `q`), and an
|
|
4490
|
+
app wanting `q` as a command binds it in the scope root's `handle_key`. ESC
|
|
4491
|
+
likewise never reaches the loop while a {Component::Popup} is open, because
|
|
4492
|
+
the popup consumes it first.
|
|
4493
|
+
- **It is genuinely useful for the small app.** `examples/hello_world.rb` is
|
|
4494
|
+
eleven lines and needs no quit handler. Deleting the fallback would make every
|
|
4495
|
+
example and both downstream apps grow one, buying nothing.
|
|
4496
|
+
|
|
4497
|
+
**Alternatives rejected.**
|
|
4498
|
+
|
|
4499
|
+
- *Delete it; apps handle their own quit.* The clean-architecture answer, and
|
|
4500
|
+
the one consistent with deleting the status bar. Rejected because the two are
|
|
4501
|
+
not the same shape: the status bar was a *row the app could not write to* —
|
|
4502
|
+
it actively blocked apps (pikuri had to re-register a keybinding to change
|
|
4503
|
+
text) — whereas this fallback blocks nothing. Any component can take the key.
|
|
4504
|
+
A rule the app can override on the spot is a default, not a policy.
|
|
4505
|
+
- *Make it opt-in (`Screen#quit_on_q=`).* Adds framework surface for a knob
|
|
4506
|
+
nobody has asked for, in the middle of an argument for less of it, and the
|
|
4507
|
+
override it provides is one the key ladder already gives for free.
|
|
4508
|
+
- *Re-advertise it somehow.* That is the framework-owned status row again.
|
|
4509
|
+
|
|
4510
|
+
**Consequences.**
|
|
4511
|
+
|
|
4512
|
+
- **It is undiscoverable from inside the app**, and that is accepted. An app
|
|
4513
|
+
that wants it spelled out writes `q quit` into its own status line —
|
|
4514
|
+
`examples/hello_world.rb`, `examples/file_commander.rb` and virtui all do;
|
|
4515
|
+
pikuri-tui deliberately does not, because its focused input eats `q` and the
|
|
4516
|
+
hint would be a lie.
|
|
4517
|
+
- **`q` is reserved-ish for a scope root.** An app binding bare `q` in
|
|
4518
|
+
`handle_key` must return `true`, or the key falls through and quits the app —
|
|
4519
|
+
a surprising bug the book calls out (ch5) and this entry pins.
|
|
4520
|
+
- **What would reopen it:** a real app that needs bare `q` at the scope root and
|
|
4521
|
+
finds consuming it awkward, or a second key wanting the same treatment (which
|
|
4522
|
+
would make this a *list*, and a list wants a knob).
|