tuile 0.11.0 → 0.12.0

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