tuile 0.12.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/DECISIONS.md +1297 -13
  4. data/README.md +136 -490
  5. data/TERMINOLOGY.md +11 -2
  6. data/book/01-first-app.md +22 -17
  7. data/book/02-repaint.md +18 -5
  8. data/book/03-layout.md +11 -10
  9. data/book/05-focus.md +133 -18
  10. data/book/06-theming.md +5 -2
  11. data/book/07-components.md +402 -12
  12. data/book/08-testing.md +18 -4
  13. data/book/README.md +7 -5
  14. data/examples/file_commander.rb +22 -16
  15. data/examples/hello_world.rb +17 -5
  16. data/examples/sampler.rb +385 -66
  17. data/ideas/arrow-key-navigation.md +16 -0
  18. data/ideas/new-components.md +7 -6
  19. data/lib/tuile/ansi.rb +10 -0
  20. data/lib/tuile/component/abstract_string_field.rb +36 -0
  21. data/lib/tuile/component/combo_box.rb +3 -1
  22. data/lib/tuile/component/list.rb +22 -0
  23. data/lib/tuile/component/list_dropdown.rb +86 -3
  24. data/lib/tuile/component/menu_bar/cascade.rb +255 -0
  25. data/lib/tuile/component/menu_bar.rb +582 -0
  26. data/lib/tuile/component/notification.rb +14 -11
  27. data/lib/tuile/component/picker_window.rb +0 -5
  28. data/lib/tuile/component/popup.rb +75 -9
  29. data/lib/tuile/component/select.rb +3 -1
  30. data/lib/tuile/component/tab_sheet.rb +242 -0
  31. data/lib/tuile/component/tabs.rb +528 -0
  32. data/lib/tuile/component/text_area.rb +5 -4
  33. data/lib/tuile/component/text_field.rb +23 -6
  34. data/lib/tuile/component/text_view.rb +8 -5
  35. data/lib/tuile/component.rb +38 -13
  36. data/lib/tuile/event_queue.rb +25 -1
  37. data/lib/tuile/fake_screen.rb +14 -0
  38. data/lib/tuile/keys.rb +65 -0
  39. data/lib/tuile/screen.rb +94 -77
  40. data/lib/tuile/screen_pane.rb +109 -27
  41. data/lib/tuile/styled_string.rb +40 -0
  42. data/lib/tuile/version.rb +1 -1
  43. data/sig/tuile.rbs +1473 -93
  44. metadata +6 -3
  45. data/mise.toml +0 -2
data/DECISIONS.md CHANGED
@@ -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.
@@ -2924,13 +2923,15 @@ the auto-growing prompt strip — the case the name was reserved for — uses
2924
2923
 
2925
2924
  ## D-text-view-scroll-verbs — `TextView#scroll_half_page_up` / `#scroll_half_page_down` (2026-08-15)
2926
2925
 
2927
- **Status:** Accepted; implemented 2026-08-15.
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.
2928
2929
 
2929
2930
  **Context.** A chat TUI keeps focus in the input field beneath its transcript,
2930
- so the transcript's own scroll keys never fire: {Component::TextView#handle_key}
2931
- opens with `return false unless active?`. The host wants PageUp/PageDown at the
2932
- *prompt* to page the *view*, half a screen at a time so the reader keeps an
2933
- overlap while output streams in. `TextView` already knows how to do exactly
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
2934
2935
  that — `Ctrl+U` / `Ctrl+D` have scrolled by half a viewport since the scroll
2935
2936
  ladder landed — but every clamped primitive behind those bindings
2936
2937
  (`move_scroll_top_row_by`, `move_scroll_top_row_to`, `viewport_rows`,
@@ -2952,8 +2953,11 @@ it never learns the row count, never clamps, and never touches focus.
2952
2953
  that wants it, where the two spellings drift. `TERMINOLOGY.md` also pins
2953
2954
  `viewport_rows` private on purpose — `rect.height` is its public form.
2954
2955
  - **Let the host forward a synthetic key** (`view.handle_key(Keys::CTRL_U)`).
2955
- Dead on arrival the `active?` guard rejects it, which is the whole problem
2956
- and a keystroke aimed at an unfocused widget is a lie about where focus is.
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.)
2957
2961
  - **App-side arithmetic on the existing public `scroll_top_row=`.** It raises
2958
2962
  below `0` and is deliberately *not* clamped above, so a caller who overshoots
2959
2963
  the last row leaves `at_bottom?` false and silently kills `auto_scroll`
@@ -2979,6 +2983,22 @@ it never learns the row count, never clamps, and never touches focus.
2979
2983
  paging back to the last row re-arms it. The host gets read-while-streaming for
2980
2984
  free and has nothing to wire.
2981
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
+
2982
3002
  ## D-notification — One corner toast, N messages, one ticker draining them (2026-08-17)
2983
3003
 
2984
3004
  **Status:** Accepted and implemented, `Component::Notification`. Builds on
@@ -3236,3 +3256,1267 @@ needs the popup *before* mounting it in order to wire `on_pick`.
3236
3256
  own arguments, and wraps the popup rather than *being* one — none of them is an
3237
3257
  inherited factory, so the trap does not apply. `popup_spec` asserts that neither
3238
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).