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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -0
  3. data/DECISIONS.md +1970 -14
  4. data/README.md +136 -491
  5. data/TERMINOLOGY.md +70 -0
  6. data/book/01-first-app.md +22 -17
  7. data/book/02-repaint.md +19 -6
  8. data/book/03-layout.md +12 -11
  9. data/book/05-focus.md +133 -18
  10. data/book/06-theming.md +6 -3
  11. data/book/07-components.md +498 -38
  12. data/book/08-testing.md +18 -4
  13. data/book/README.md +7 -5
  14. data/examples/file_commander.rb +27 -20
  15. data/examples/hello_world.rb +17 -5
  16. data/examples/sampler.rb +422 -66
  17. data/ideas/arrow-key-navigation.md +16 -0
  18. data/ideas/new-components.md +16 -10
  19. data/lib/tuile/ansi.rb +10 -0
  20. data/lib/tuile/buffer.rb +7 -7
  21. data/lib/tuile/component/abstract_string_field.rb +36 -0
  22. data/lib/tuile/component/button.rb +1 -1
  23. data/lib/tuile/component/checkbox.rb +1 -1
  24. data/lib/tuile/component/checkbox_group.rb +31 -26
  25. data/lib/tuile/component/combo_box.rb +13 -8
  26. data/lib/tuile/component/info_window.rb +1 -1
  27. data/lib/tuile/component/label.rb +14 -14
  28. data/lib/tuile/component/list.rb +313 -216
  29. data/lib/tuile/component/list_dropdown.rb +100 -10
  30. data/lib/tuile/component/menu_bar/cascade.rb +255 -0
  31. data/lib/tuile/component/menu_bar.rb +582 -0
  32. data/lib/tuile/component/notification.rb +320 -0
  33. data/lib/tuile/component/picker_window.rb +3 -8
  34. data/lib/tuile/component/popup.rb +83 -19
  35. data/lib/tuile/component/progress_bar.rb +1 -1
  36. data/lib/tuile/component/radio_group.rb +32 -30
  37. data/lib/tuile/component/select.rb +10 -8
  38. data/lib/tuile/component/tab_sheet.rb +242 -0
  39. data/lib/tuile/component/tabs.rb +528 -0
  40. data/lib/tuile/component/text_area/wrapped_text.rb +320 -0
  41. data/lib/tuile/component/text_area.rb +84 -277
  42. data/lib/tuile/component/text_field.rb +24 -7
  43. data/lib/tuile/component/text_view.rb +197 -180
  44. data/lib/tuile/component/window.rb +8 -8
  45. data/lib/tuile/component.rb +43 -18
  46. data/lib/tuile/event_queue.rb +25 -1
  47. data/lib/tuile/fake_screen.rb +14 -0
  48. data/lib/tuile/keys.rb +65 -0
  49. data/lib/tuile/screen.rb +95 -78
  50. data/lib/tuile/screen_pane.rb +109 -27
  51. data/lib/tuile/styled_string.rb +52 -12
  52. data/lib/tuile/version.rb +1 -1
  53. data/lib/tuile/vertical_scroll_bar.rb +6 -6
  54. data/sig/tuile.rbs +2307 -516
  55. metadata +9 -3
  56. 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#draw_line` / `#draw_char`.
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`, `draw_line` per row):* wrong here.
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` + `draw_line`), the fallback the idea
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#top_display_row`.
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
- `@display_rows` hazard — for no measured gain.
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#set_line` drops entirely. It
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 `set_line` advanced the column by 4.
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 `@display_rows`), for state a per-keystroke
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 7 `Rect.new`.
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 main split (`(width / 3).clamp(20, 40)`) and its two
2273
- sidebars (`min(16, width / 3)`) are caps on a *proportion*, unsayable in three
2274
- constraints, so they keep a rect-callback `Absolute`. That is the intended
2275
- division of labour: only the part needing arithmetic has any. Revisit only if
2276
- capped proportions turn out to be common.
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).