tuile 0.9.0 → 0.11.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +81 -36
  3. data/DECISIONS.md +2566 -0
  4. data/README.md +37 -24
  5. data/book/03-layout.md +153 -8
  6. data/book/04-event-loop.md +86 -0
  7. data/book/05-focus.md +84 -51
  8. data/book/06-theming.md +53 -1
  9. data/book/07-components.md +458 -9
  10. data/book/08-testing.md +21 -8
  11. data/book/09-styled-text.md +132 -0
  12. data/book/README.md +22 -14
  13. data/examples/sampler.rb +632 -67
  14. data/ideas/arrow-key-navigation.md +205 -0
  15. data/ideas/new-components.md +118 -0
  16. data/ideas/per-component-buffers.md +55 -0
  17. data/lib/tuile/buffer.rb +52 -51
  18. data/lib/tuile/color.rb +4 -10
  19. data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
  20. data/lib/tuile/component/big_decimal_field.rb +199 -0
  21. data/lib/tuile/component/button.rb +25 -21
  22. data/lib/tuile/component/checkbox.rb +134 -0
  23. data/lib/tuile/component/checkbox_group.rb +188 -0
  24. data/lib/tuile/component/combo_box.rb +263 -0
  25. data/lib/tuile/component/float_field.rb +161 -0
  26. data/lib/tuile/component/has_caption.rb +44 -0
  27. data/lib/tuile/component/has_content.rb +5 -5
  28. data/lib/tuile/component/has_value.rb +64 -0
  29. data/lib/tuile/component/integer_field.rb +135 -0
  30. data/lib/tuile/component/label.rb +13 -10
  31. data/lib/tuile/component/layout/box.rb +316 -0
  32. data/lib/tuile/component/layout/horizontal.rb +40 -0
  33. data/lib/tuile/component/layout/vertical.rb +41 -0
  34. data/lib/tuile/component/layout.rb +149 -15
  35. data/lib/tuile/component/list.rb +8 -7
  36. data/lib/tuile/component/list_dropdown.rb +157 -0
  37. data/lib/tuile/component/password_field.rb +105 -0
  38. data/lib/tuile/component/popup.rb +10 -14
  39. data/lib/tuile/component/progress_bar.rb +278 -0
  40. data/lib/tuile/component/radio_group.rb +188 -0
  41. data/lib/tuile/component/select.rb +251 -0
  42. data/lib/tuile/component/text_area.rb +189 -65
  43. data/lib/tuile/component/text_field.rb +170 -32
  44. data/lib/tuile/component/text_view.rb +57 -114
  45. data/lib/tuile/component/window.rb +32 -47
  46. data/lib/tuile/component.rb +250 -87
  47. data/lib/tuile/event_queue.rb +14 -17
  48. data/lib/tuile/fake_event_queue.rb +11 -2
  49. data/lib/tuile/fake_screen.rb +4 -5
  50. data/lib/tuile/fraction.rb +6 -10
  51. data/lib/tuile/screen.rb +202 -104
  52. data/lib/tuile/screen_pane.rb +51 -41
  53. data/lib/tuile/styled_string.rb +125 -86
  54. data/lib/tuile/theme.rb +78 -41
  55. data/lib/tuile/version.rb +1 -1
  56. data/lib/tuile.rb +4 -0
  57. data/sig/tuile.rbs +2962 -680
  58. metadata +25 -7
@@ -0,0 +1,205 @@
1
+ # Arrow keys move focus between fields — as a *behavior*, not a layout class
2
+
3
+ **Status:** design sketch, 2026-08-12. Nothing built. Started from the
4
+ sampler's PasswordField pane ("Up/Down between the three fields would be
5
+ friendlier"), but that pane is a *demo* of the feature, not an argument for
6
+ it — the argument is a ten-field form. Open questions at the bottom are the
7
+ point of this file.
8
+
9
+ ## What is being proposed
10
+
11
+ In a form, Up/Down moves focus between fields, in addition to Tab/Shift+Tab.
12
+ Tab keeps its current job (cycle every tab stop in the scope, wrapping);
13
+ arrows do *local* motion within one container and stop at its edges.
14
+
15
+ ## Why it's plausible at all: Tuile already has the whole mechanism
16
+
17
+ Two findings from the survey, both load-bearing:
18
+
19
+ 1. **`TextField` already declines Up/Down by design.** `text_field.rb:134-140`:
20
+ `on_key_up` / `on_key_down` are nil by default and the nil branch is
21
+ `return false`, with rdoc reading "when nil, UP falls through to the parent
22
+ (default behavior)". The clash we feared was already resolved in the
23
+ direction that enables this.
24
+ 2. **Rung 3 of the key ladder is exactly the right hook.** `bubble_key` asks
25
+ the focused widget, then each ancestor. AGENTS.md already names this as the
26
+ sanctioned home for scope-wide keys ("a layout's one-key jumps to its
27
+ panes"). So this is a `handle_key` on a container — no dispatch phase, no
28
+ gate in `Screen#handle_key`, no framework change.
29
+
30
+ Worth stating explicitly for a future reader: the key ladder's "no gate, no
31
+ predicate, no mode flag" rule constrains `Screen#handle_key`, **not** a
32
+ component's own `handle_key`. Adding behavior at rung 3 is sanctioned; a
33
+ per-instance switch on a *component* is not the thing that rule forbids.
34
+
35
+ Everything that must keep the arrows already claims them and wins for free:
36
+ `TextArea`, `TextView`, `List`, the three numeric fields, `ComboBox`.
37
+
38
+ ## Prior art
39
+
40
+ Splits by lineage, not by age:
41
+
42
+ - **FTXUI** — closest to Tuile architecturally, and does exactly this.
43
+ `Container::Vertical` is *defined* as "navigated vertically using up/down
44
+ arrow keys"; `Container::Horizontal` gets left/right. Dispatch is our shape:
45
+ active child asked first, container only sees what the child declined
46
+ (`container.cpp`, `OnEvent`). Arrows do **not** wrap (`MoveSelector`); Tab
47
+ wraps (`MoveSelectorWrap`).
48
+ - **Midnight Commander** — "to move between the widgets use the arrow keys or
49
+ the Tab key". The ncurses form-dialog lineage generally (`dialog`, newt)
50
+ behaves this way; only MC was verified.
51
+ - **Bubble Tea** — the canonical `examples/textinputs` cycles focus on
52
+ up/down/tab/shift+tab, but app-side; the framework has no focus model.
53
+ - **Textual, ratatui, Ink, Vaadin** — no. Textual is the explicit web/ARIA
54
+ position: Tab between widgets, arrows only *within* a composite widget.
55
+
56
+ ## The shape: a behavior on a layout, not a `Layout::Form`
57
+
58
+ `Layout::Form < Layout::Vertical` was the first sketch and is **rejected**.
59
+ Reasons, in order of force:
60
+
61
+ - **Vaadin's FormGroup precedent.** It coupled `Binder` to layouting and was
62
+ abandoned for it. `Form` here would couple a layout algorithm to a key
63
+ behavior — a smaller version of the same mistake.
64
+ - **It breaks the moment the layout is insufficient.** A real form needs a
65
+ nested `Horizontal` row, or an `Absolute` for a capped-proportion split. Now
66
+ the behavior is attached to the *outer* class and the nested layouts are
67
+ arbitrary, so "does this container navigate?" stops being answerable from
68
+ the class. Policy carried by a class is inherited by every subclass and
69
+ unavailable to every non-subclass; policy carried by a setter is per
70
+ instance and never inherited.
71
+ - **COP says so.** `Layout::Vertical` is a *generic, domain-agnostic*
72
+ component, and the skill's rule for those is to externalize policy via
73
+ injected strategies — not to subclass per policy. A `Form` whose only
74
+ divergence is one `handle_key` is precisely the "shallow divergent
75
+ scaffolding — duplicate or inject, don't fold into a base" case.
76
+
77
+ So: **any layout can be given the behavior; none has it by default.** virtui
78
+ gets nothing and stays exactly as it is; a form opts in. The sampler's `form`
79
+ helper (`sampler.rb:832`) becomes the one place the demo opts in.
80
+
81
+ Concretely, the nesting story this buys — and it is the whole reason for the
82
+ shape: a layout without the behavior **declines** the arrow key, so it bubbles
83
+ to the next ancestor that *does* have it. An inner `Absolute` inside a
84
+ navigating `Vertical` is therefore one opaque slot: focus anywhere inside it,
85
+ Down moves to the next slot of the outer box. No inheritance, no surprise, and
86
+ the answer is the same at any nesting depth.
87
+
88
+ ## Semantics that already look settled
89
+
90
+ Recorded here so the open questions below stay narrow.
91
+
92
+ - **Walk direct children, not `on_tree`.** A `Horizontal` row nested in a
93
+ navigating `Vertical`: flattened pre-order would make Down from the row's
94
+ left field jump to the row's *right* field, which is geometrically wrong.
95
+ Direct children makes Down go to the next row. (FTXUI indexes `children()`
96
+ for the same reason.) "Which direct child holds focus" is a parent-chain
97
+ walk from `screen.focused`, so depth doesn't matter.
98
+ - **Skip children with no focusable descendant** (a `Label`, a spacer).
99
+ - **Don't wrap; decline at the edge.** Two payoffs: nesting composes (an inner
100
+ box at its edge declines and the outer box moves to the next sibling group),
101
+ and wrapping stays Tab's distinguishing job. Same split FTXUI landed on.
102
+ - **Descend via the existing focus cascade** — set `screen.focused` to the
103
+ sibling and let `Layout#on_focus` (`layout.rb:203`) forward to its first tab
104
+ stop. See open question on backwards entry.
105
+ - **Mouse is untouched.** Popups are untouched — the bubble is already scoped
106
+ to the topmost modal popup.
107
+
108
+ ## The honest argument against
109
+
110
+ A *partially* live feature is worse than an absent one: if arrows navigate in
111
+ 80% of positions the user can't build a model. Inside a form the exception set
112
+ is mostly coherent — `TextArea` / `TextView` / `List` swallow arrows, and
113
+ they're the *tall* widgets, where a user already expects arrows to move
114
+ *inside* the box. That reads as "arrows move within a tall widget, between
115
+ short ones", which is learnable and is the story MC tells.
116
+
117
+ The three numeric fields break that story and are the real problem (see Q6).
118
+
119
+ ## Open questions
120
+
121
+ **Q1 — What exactly is the knob?** Candidates, roughly in order of how much
122
+ API they add:
123
+
124
+ a. keyword + accessor on `Layout`: `navigation: :vertical` / `:horizontal` /
125
+ `:both` / `nil` (default `nil`).
126
+ b. a strategy *object*: `layout.navigation = Layout::ArrowNavigation.new(...)`,
127
+ leaving room for per-instance config and app subclassing.
128
+ c. a module the app mixes in: `Vertical.new.extend(Layout::ArrowNavigable)`.
129
+ Composable with any layout without touching `Layout`, but `extend` on a
130
+ singleton class is obscure and hard to document.
131
+ d. a general `Component#on_key` interceptor hook (the shape
132
+ `AbstractStringField#on_key` already has), with the framework shipping a
133
+ ready-made callable to assign. Most decoupled — touches `Layout` not at
134
+ all — but adds a general hook whose merits should be argued on their own,
135
+ not smuggled in under this feature.
136
+
137
+ (a) is the smallest thing that works; (d) is the most COP-pure. Not decided.
138
+
139
+ **Q2 — Where does the axis come from?** If the behavior is layout-agnostic it
140
+ can't be derived from the class. `Vertical` → up/down and `Horizontal` →
141
+ left/right are natural defaults, but `Absolute` has none. Does the knob always
142
+ carry an explicit axis, or default per class and require it on `Absolute`?
143
+
144
+ **Q3 — Should `Horizontal` / left-right navigation exist at all?**
145
+ `AbstractStringField` *always* consumes Left/Right for the caret, so a row of
146
+ text fields will never arrow-navigate while a row of Buttons/Checkboxes will.
147
+ The rule stays uniform (widget wins); the outcome looks selective. Ship both
148
+ axes, or vertical-only until someone asks?
149
+
150
+ **Q4 — Ordering inside an `Absolute`.** Declaration order is all that's
151
+ available and may not match visual order — the original worry that killed the
152
+ idea of putting this on every layout. Options: document "declaration order is
153
+ yours to get right"; or sort direct children geometrically per keypress (by
154
+ `rect.top`, then `rect.left`), which is cheap and actually correct, and would
155
+ make `Absolute` a first-class citizen here. Is geometric ordering worth it?
156
+
157
+ **Q5 — Backwards entry into a multi-widget sibling.** `Layout#on_focus` always
158
+ forwards to the *first* tab stop, so arrowing **Up** into a previous group
159
+ lands on its first widget rather than its last. FTXUI has the same wart. Fix
160
+ with a `last:` variant of the cascade, or accept it?
161
+
162
+ **Q6 — The numeric fields.** `IntegerField` / `FloatField` / `BigDecimalField`
163
+ consume Up/Down to step by ±1, and they are *one row tall* — so they break the
164
+ "arrows move within tall widgets" story silently, with nothing on screen
165
+ explaining why. This is the sharpest concrete collision. Options:
166
+
167
+ a. leave it; document the exception,
168
+ b. move stepping to `Ctrl+Up/Down` or `PgUp/PgDn` (breaking, but the fields
169
+ are young),
170
+ c. make stepping opt-in per field (`step = 1` / `nil`) — a knob, but on the
171
+ widget that actually has the ambiguity, and a numeric field in a form
172
+ usually doesn't want spinner behavior anyway.
173
+
174
+ **Q7 — `List` at its edges.** It clamps and returns true, so arrows can never
175
+ escape a focused list; only Tab does. Keep clamping (a list is a list, and MC
176
+ agrees), or have it decline at its edges so arrows escape? Note this is
177
+ exactly virtui's shape, so the answer matters more there than in a form.
178
+
179
+ **Q8 — `ComboBox` is asymmetric.** Closed, it eats Down to open the menu
180
+ (`combo_box.rb:181`) but declines Up. So Up would jump out of a closed combo
181
+ while Down opens it. Browsers eat both. Deliberate choice or accident to fix?
182
+
183
+ **Q9 — Naming.** "Form" is the wrong word for the behavior — it's *focus
184
+ navigation*. `navigation` / `arrow_nav` / `key_navigation` /
185
+ `focus_navigation`? Whatever it is, it must not imply validation or submit,
186
+ which Tuile has no notion of.
187
+
188
+ **Q10 — Does Enter participate?** `dialog(1)` moves to the next field on
189
+ Enter. Almost certainly out of scope — `Checkbox`, `Button` and `TextArea` all
190
+ claim Enter already, and book ch5 has the per-widget Enter table — but worth
191
+ rejecting explicitly rather than by omission.
192
+
193
+ **Q11 — Where does the code live?** Zeitwerk wants one top-level constant per
194
+ file. If Q1 lands on (b) or (c) it needs its own file under
195
+ `lib/tuile/component/layout/`; if (a), it's a few lines on `Layout` itself.
196
+ Also: any public signature change means `rake sig` in the same commit.
197
+
198
+ ## Graduation
199
+
200
+ If built: the user-facing half goes to book ch5 (the key/Enter tables live
201
+ there), the invariants half to AGENTS.md's key-dispatch section, and the
202
+ choice-plus-rejected-roads half to `DECISIONS.md` as `D-arrow-navigation` —
203
+ which must record the `Layout::Form` rejection and the Vaadin FormGroup
204
+ precedent behind it, since that's the reasoning most likely to be
205
+ re-litigated. Then retire this file.
@@ -0,0 +1,118 @@
1
+ # Components Vaadin has and Tuile doesn't — the survey
2
+
3
+ **Status:** survey done 2026-07-25 against Vaadin **25.2** (54 free/OSS
4
+ components, via the Vaadin docs MCP). This file is the roadmap; each
5
+ component we actually decide to build gets its own `ideas/<name>.md`.
6
+ Retire this file once the interesting part of the list is either built or
7
+ explicitly rejected — the tiering below is the only nugget worth keeping,
8
+ and it belongs here, not in a durable doc, because it goes stale as we
9
+ build.
10
+
11
+ Batch 1 ("field components only") is **done** — every idea filed under it has
12
+ graduated: `checkbox` (`DECISIONS.md` `D-boolean-fields`) and `checkbox-group`
13
+ (`D-checkbox-group`), both built 2026-07-30; `radio-group` (`D-radio-group`),
14
+ built 2026-07-31; `progress-bar` (`D-color-slots`, book ch7 "Reporting
15
+ progress") and `password-field` (`D-integer-field`'s taxonomy, book ch7
16
+ "Editing text"), both built 2026-08-02.
17
+
18
+ The **box layouts** that headed the gating list below are done too
19
+ (`D-box-layouts`, 2026-08-07) — `Layout::Vertical` / `::Horizontal`, plus the
20
+ sampler ported onto them.
21
+
22
+ ## What Tuile already has
23
+
24
+ Seven of the 54 have a counterpart: Button, Text Field, Text Area,
25
+ Integer Field, Combo Box (1:1), Dialog ({Tuile::Component::Popup} plus
26
+ `Window`/`InfoWindow`/`PickerWindow`), Themable Mixin
27
+ ({Tuile::Theme}/{Tuile::ThemeDef}). **List Box** is half-there:
28
+ {Tuile::Component::List} is line-based, with no typed items and no
29
+ multi-select.
30
+
31
+ Tuile also has pieces Vaadin doesn't name as components — `ListDropdown`,
32
+ `TextView`, `LogWindow`, `VerticalScrollBar` — so the gap is not
33
+ symmetric.
34
+
35
+ That leaves ~46 gaps.
36
+
37
+ ## Tier 1 — reachable from what exists (S–M each)
38
+
39
+ | Component | Builds on | Note |
40
+ |---|---|---|
41
+ | ~~Box layouts (H/V)~~ | `Layout` | **built** 2026-08-07 (`D-box-layouts`, book ch3); `Vertical`/`Horizontal` over `Box`, additive sugar on top of `Absolute` — no foundation change |
42
+ | ~~Checkbox~~ | `HasValue` | **built** 2026-07-30 (`D-boolean-fields`); tri-state still deferred |
43
+ | ~~Radio Group~~ | `List` + `HasValue` | **built** 2026-07-31 (`D-radio-group`); composes a `List`, cursor roams and Space selects |
44
+ | ~~Checkbox Group~~ | `List` + `HasValue` | **built** 2026-07-30 (`D-checkbox-group`); composes a `List`, frozen `Set` value |
45
+ | ~~Select~~ | `ListDropdown` + `HasValue` | **built** 2026-08-12 (`D-select`, book ch7); a *second driver* of `ListDropdown`, not "ComboBox − filter" — it paints its own one-row face and needs no read-only axis. Claims no printable but Space |
46
+ | ~~Password Field~~ | `TextField` | **built** 2026-08-02 (`D-integer-field`'s taxonomy — subclass, since a password's value *is* its text; mask default in `D-ambiguous-width`); a `display_text` seam, one mask glyph per character |
47
+ | ~~Number Field~~ | `IntegerField` twin | **built** 2026-08-07 as `FloatField` (`D-float-field`) and `BigDecimalField` (`D-bigdecimal-field`, on Tuile's first optional dep); each named for its Ruby value type, deliberate copies of `IntegerField` |
48
+ | ~~Progress Bar~~ | `draw_line` + `EventQueue#tick_fps` | **built** 2026-08-02 (`D-progress-bar`, book ch7); a `value` that stays out of `HasValue`, ticker synced from `attached? && indeterminate?` |
49
+ | Notification | `Popup` + `Ticker` | needs corner-anchored (non-centered) popup placement |
50
+ | Confirm Dialog | `Popup`+`Window`+`Button` | fold `PickerWindow` in |
51
+ | Details → Accordion | `HasContent` | Details is the atom, Accordion the group |
52
+ | Tabs → Tabsheet | `HasValue` (index) + `HasContent` | strip, then strip + content swap |
53
+ | Popover | `ListDropdown#anchor_to` (extracted 2026-08-12) | generalize the anchored non-modal overlay: `anchor_to` moves down to it and `ListDropdown` inherits it. Build it when the second *kind* of anchoring appears (a point; a right edge that flips) — not the second caller of the same kind. Gates the next two |
54
+ | Menu Bar | `ListDropdown::Menu` + Popover | |
55
+ | Context Menu | same | `:right` button already parses |
56
+ | Slider | `draw_line` | arrows/PgUp; 25.2 also has a two-thumb *range* variant |
57
+ | Breadcrumbs | `Label`/`StyledString` | clickable path segments |
58
+ | Markdown | `TextView` + `StyledString` | Markdown subset → styled text; high value on a TTY |
59
+
60
+ ## Tier 2 — worth doing, each blocked on a new seam
61
+
62
+ | Component | Blocked on |
63
+ |---|---|
64
+ | **Grid** (the flagship gap) | column model + renderer strategies + typed items + horizontal scroll (L) |
65
+ | Form Layout | a field label/helper seam (Vaadin's `HasLabel`) — Tuile fields carry no caption |
66
+ | Email Field | a validation seam (`HasValidation`: invalid state + error line) |
67
+ | Date / Time / DateTime Picker | calendar-grid popup over Popover (L) |
68
+ | Multi Select Combo Box | Checkbox Group + ComboBox |
69
+ | Split Layout → Master Detail Layout | mouse **motion/drag**: Tuile runs X10 mode 1000 (press only, no release, no motion) |
70
+ | Virtual List | a lazy data-provider strategy on `List` |
71
+ | Side Nav | hierarchical collapsible list (the sampler's nav is the prototype) |
72
+ | App Layout | shell: title bar + drawer + content slot |
73
+ | Custom Field | formalize the composed typed-field pattern — or *reject* it as a shared base (COP: inherit to *be*, not to share); `D-integer-field` already sketches the taxonomy |
74
+ | Message Input / Message List / Login | nothing — pure assemblies, good example fodder |
75
+ | Upload | reinterpret as a file-chooser dialog (`file_commander` has the ingredients) |
76
+ | Icon | a glyph / Nerd-Font constants module |
77
+
78
+ ## Tier 3 — design tension or marginal
79
+
80
+ - **Scroller** — scrolling *arbitrary* content needs clipping/viewport
81
+ machinery and pushes against the top-down layout invariant (it wants to
82
+ measure content). Best kept as a documented road-not-taken.
83
+ - **Tooltip** — competes with Tuile's status-bar `keyboard_hint` idiom.
84
+ - **Card** — overlaps `Window` almost entirely.
85
+ - **Avatar / Avatar Group** — initials in a box; little value on a TTY.
86
+ - **Not applicable:** Field Highlighter (collaboration), Themable Mixin
87
+ (covered by `Theme`).
88
+
89
+ ## Infrastructure that gates clusters
90
+
91
+ These are prerequisites, not components, and each deserves its own idea
92
+ file when its cluster comes up:
93
+
94
+ 1. ~~**Box layouts** (H/V)~~ — **done** 2026-08-07 (`D-box-layouts`). Turned
95
+ out *not* to be structural: a `Box` is an `Absolute` subclass with a `rect=`
96
+ override, so it unblocked the form-shaped cluster without touching the
97
+ foundation. A future Grid should reuse its `Fixed`/`Percent`/`Expand`
98
+ constraints per row and column rather than invent a second vocabulary.
99
+ 2. **Field label + helper text seam** → Form Layout. Note this is what Form
100
+ Layout is actually blocked on — the layout half now exists.
101
+ 3. **Validation seam** → Email Field, forms generally.
102
+ 4. **Anchored Popover extraction** → Menu Bar, Context Menu, pickers,
103
+ Tooltip.
104
+ 5. **Mouse motion/drag** (modes 1002/1006, release events) → Split
105
+ divider, Slider drag, scrollbar drag.
106
+ 6. **Typed items + data provider on `List`** → List Box, Grid, Virtual
107
+ List.
108
+
109
+ Vaadin's `Binder` is the natural companion for the forms cluster but is
110
+ not a component; `D-has-value` already parks the forms-layer questions
111
+ (converters, read-only, required indicator).
112
+
113
+ ## ~~Cross-cutting open question: component color slots vs. theme tokens~~
114
+
115
+ **Settled 2026-08-01 as `DECISIONS.md` `D-color-slots`** — the slot, defaulting
116
+ to `nil`. Slider and Badge are bound by it when they land; Badge's promotion
117
+ trigger (a *second* built-in needing the same semantic color) is written up
118
+ there, so neither has to re-argue it.
@@ -0,0 +1,55 @@
1
+ # Per-component back buffers + a z-order compositor
2
+
3
+ **Status:** deferred, not worth doing yet. Graduated out of `Buffer`'s
4
+ rdoc (it was speculative design musing, not caller contract — see the
5
+ doc-kinds rules in AGENTS.md). Parked here so the thinking isn't lost.
6
+
7
+ ## The idea
8
+
9
+ Today there is one global {Tuile::Buffer}: every component paints into
10
+ the same grid via `set_line` / `set_char` / `fill`, and {Tuile::Screen}
11
+ flushes its minimal diff to the terminal.
12
+
13
+ Components already paint through that drawing surface *without knowing*
14
+ whether it's the one global buffer or a private one — the indirection is
15
+ deliberate. So per-component back buffers plus a z-order compositor could
16
+ drop in **without touching component code**: give each component its own
17
+ `Buffer`, let it paint into that, and have a compositor blend the
18
+ per-component buffers in stacking order into the frame the terminal sees.
19
+
20
+ ## Why it's not worth doing yet
21
+
22
+ The current single-buffer diff already captures most of the win a
23
+ compositor would give:
24
+
25
+ - The flush drops unchanged cells from the wire, so an unchanged region
26
+ costs nothing on the terminal side regardless of how many components
27
+ overlap it.
28
+ - An occluded component that didn't change is never repainted at all —
29
+ invalidation gates it out before `repaint` runs.
30
+
31
+ So a compositor would only save **residual `repaint` CPU**: the cost of a
32
+ component re-rendering its content into a buffer, when that content then
33
+ turns out to be occluded or unchanged after compositing.
34
+
35
+ ## The one regime where it pays off
36
+
37
+ High repeat-rate scroll — a held arrow key or a spun mouse wheel — over a
38
+ **large** component on a **large** screen, where re-rendering the
39
+ component's content on every repeat is the dominant cost. A per-component
40
+ buffer would let an unchanged-but-scrolled component be re-composited
41
+ (cheap) instead of re-rendered (expensive) each repeat.
42
+
43
+ Until Tuile has a real workload in that regime, the extra machinery
44
+ (per-component buffer allocation, a compositor pass, dirty propagation
45
+ across buffer layers) buys nothing over what the single-buffer diff
46
+ already delivers.
47
+
48
+ ## If we revisit
49
+
50
+ - The component-facing drawing API (`set_line` / `set_char` / `fill`)
51
+ stays the same — that's the whole point of the existing indirection.
52
+ - What changes is *who owns the buffer* and the addition of a
53
+ composite-into-frame step between `repaint` and `Buffer#flush`.
54
+ - Measure first: prove the residual-`repaint`-CPU regime is real and
55
+ material before adding a layer.
data/lib/tuile/buffer.rb CHANGED
@@ -21,17 +21,10 @@ module Tuile
21
21
  # size. There is deliberately no per-frame whole-buffer clear or copy;
22
22
  # un-touched cells retain the previous frame's value.
23
23
  #
24
- # The bookkeeping avoids hashing and full-grid scans: a dirty flag **on each
25
- # cell** (O(1) set, no `Set` bucket math, no separate array), a per-row
26
- # boolean so {#flush} scans only the rows that changed, and one global flag
27
- # so {#dirty?} and the "nothing changed" early-out are O(1). {#flush} clears
28
- # every flag it consumes.
29
- #
30
- # Cells are **mutable and pre-allocated**: the grid builds its {Cell}s once
24
+ # Cells are **mutable and pre-allocated** — the grid builds its {Cell}s once
31
25
  # (at construction and {#resize}) and rewrites them in place, so a normal
32
- # paint allocates nothing per cell. That is why {Cell} is a plain mutable
33
- # object rather than a frozen value type. The empty state of a cell is a
34
- # space in the default style.
26
+ # paint allocates nothing per cell. That's why {Cell} is a plain mutable
27
+ # object, not a frozen value type.
35
28
  #
36
29
  # ## Wide characters
37
30
  #
@@ -40,19 +33,6 @@ module Tuile
40
33
  # nothing for, since the glyph itself advances the cursor two columns).
41
34
  # Overwriting either half of a wide glyph blanks the orphaned half, so the
42
35
  # grid never holds a dangling continuation or a headless one.
43
- #
44
- # ## Future direction
45
- #
46
- # Components paint through this drawing surface ({#set_line} / {#set_char})
47
- # without knowing whether it is the one global buffer or a private one — that
48
- # indirection is deliberate, so per-component back buffers plus a z-order
49
- # compositor could drop in without touching component code. It is not worth
50
- # doing yet: the diff already drops unchanged cells from the wire, and an
51
- # occluded component that didn't change is never repainted at all, so a
52
- # compositor would only save residual `repaint` CPU. It pays off in exactly
53
- # one regime — high repeat-rate scroll (held arrow / mouse wheel) of a large
54
- # component on a large screen, where re-rendering the content each repeat is
55
- # the dominant cost.
56
36
  class Buffer
57
37
  # One screen cell: a single grapheme cluster, the {StyledString::Style} it's
58
38
  # drawn in, and a dirty flag. Mutable by design (see {Buffer} "Dirty
@@ -118,10 +98,15 @@ module Tuile
118
98
  # emoji), so the per-grapheme width lookup — the dominant cost of a repaint
119
99
  # (see `benchmark/display_width.rb`) — collapses to a Hash read after the
120
100
  # first sighting. Shared across all buffers and unbounded, but bounded in
121
- # practice by the font's glyph set; safe to share because all painting runs
122
- # on the single UI thread (see AGENTS.md "Threading rule").
101
+ # practice by the font's glyph set.
102
+ #
103
+ # The memo carries its weight most for emoji: resolving a sequence under
104
+ # {StyledString::EMOJI_WIDTH} costs ~20x a plain lookup, and this pays it
105
+ # once per distinct cluster. Racing writes from a non-UI thread are benign
106
+ # rather than merely absent — the value for a grapheme is deterministic, so
107
+ # a lost write only costs a recomputation.
123
108
  # @return [Hash{String => Integer}]
124
- WIDTH_CACHE = Hash.new { |h, g| h[g] = Unicode::DisplayWidth.of(g) }
109
+ WIDTH_CACHE = Hash.new { |h, g| h[g] = Unicode::DisplayWidth.of(g, emoji: StyledString::EMOJI_WIDTH) }
125
110
  private_constant :WIDTH_CACHE
126
111
 
127
112
  # Memoized {Unicode::DisplayWidth.of}. Use this for every paint-path width
@@ -174,9 +159,8 @@ module Tuile
174
159
  end
175
160
 
176
161
  # Writes a {StyledString} starting at `(x, y)`, advancing by each grapheme's
177
- # display width and clipping at the right edge. The workhorse that replaces
178
- # the old `screen.print(TTY::Cursor.move_to(x, y), styled.to_ansi)` per-row
179
- # paint. Newlines in the string are not handled — pass one physical line.
162
+ # display width and clipping at the right edge. Newlines are not handled —
163
+ # pass one physical line.
180
164
  # @param x [Integer] starting column.
181
165
  # @param y [Integer] row.
182
166
  # @param styled [StyledString]
@@ -346,21 +330,27 @@ module Tuile
346
330
  return unless in_bounds?(x, y)
347
331
  return if w <= 0
348
332
 
349
- if w == 2 && !in_bounds?(x + 1, y)
333
+ if w > 1 && !in_bounds?(x + w - 1, y)
350
334
  blank_left_partner(x, y)
351
335
  return write_cell(x, y, " ", style)
352
336
  end
353
337
 
354
- # Repair only the glyphs we'd leave half-overwritten on our flanks: a wide
355
- # glyph whose right half sits at `x`, or one whose left half sits at the
356
- # last cell we write. The cells we fully rewrite need no pre-blanking —
357
- # pre-blanking the continuation only to re-empty it would churn it
358
- # spuriously dirty, which misplaces the next flush onto the glyph's right
359
- # half (see bug/, balloon corruption).
338
+ # Repair only the glyphs we'd leave half-overwritten on our flanks: one
339
+ # whose tail reaches `x`, or one whose head sits at the last cell we write.
340
+ # The cells we fully rewrite need no pre-blanking — pre-blanking a
341
+ # continuation only to re-empty it would churn it spuriously dirty, which
342
+ # misplaces the next flush onto the glyph's right half (see bug/, balloon
343
+ # corruption).
360
344
  blank_left_partner(x, y)
361
345
  blank_right_partner(x + w - 1, y)
362
346
  write_cell(x, y, grapheme, style)
363
- write_cell(x + 1, y, "", style) if w == 2
347
+ # A while loop, not (1...w).each: this runs once per painted cell, and a
348
+ # Range allocation per cell is 8000 per full-screen repaint.
349
+ i = 1
350
+ while i < w
351
+ write_cell(x + i, y, "", style)
352
+ i += 1
353
+ end
364
354
  end
365
355
 
366
356
  # (Re)allocates a blank grid of `size` with clean dirty state. Callers
@@ -451,31 +441,42 @@ module Tuile
451
441
  @any_dirty = true
452
442
  end
453
443
 
454
- # If `(x, y)` holds the right half (continuation) of a wide glyph, blanks the
455
- # orphaned left half at `x - 1`. Called before a write lands on `x`, so the
456
- # wide glyph to the left isn't left headless.
444
+ # If `(x, y)` holds a continuation, blanks the head of the glyph it belongs to
445
+ # and every continuation up to — but not including — `x`. Called before a
446
+ # write lands on `x`, so the glyph reaching into `x` isn't left headless.
447
+ # Walks left rather than assuming the head sits at `x - 1`: a glyph may be
448
+ # wider than two columns, so its tail can run several cells.
457
449
  # @param x [Integer] column
458
450
  # @param y [Integer] row
459
451
  # @return [void]
460
452
  def blank_left_partner(x, y)
461
- return unless in_bounds?(x, y) && @cells[index(x, y)].continuation? && in_bounds?(x - 1, y)
453
+ return unless in_bounds?(x, y) && @cells[index(x, y)].continuation?
462
454
 
463
- write_cell(x - 1, y, " ", DEFAULT_STYLE)
455
+ head = x - 1
456
+ head -= 1 while in_bounds?(head, y) && @cells[index(head, y)].continuation?
457
+ return unless in_bounds?(head, y)
458
+
459
+ cx = head
460
+ while cx < x
461
+ write_cell(cx, y, " ", DEFAULT_STYLE)
462
+ cx += 1
463
+ end
464
464
  end
465
465
 
466
- # If the cell just right of `(x, y)` is a continuation (the right half of a
467
- # wide glyph whose origin is `(x, y)`), blanks it. Called before a write
468
- # lands on `x`, so overwriting a wide origin doesn't strand its continuation.
469
- # A continuation can only ever belong to the wide glyph immediately to its
470
- # left, so the empty-grapheme test is exact — and cheaper than re-measuring
471
- # the origin's width.
466
+ # Blanks the run of continuations immediately right of `(x, y)` — the tail of
467
+ # a glyph whose head is at or before `x`, and which the write landing on `x`
468
+ # is about to decapitate. A continuation always belongs to the nearest glyph
469
+ # on its left, so the empty-grapheme test is exact — and cheaper than
470
+ # re-measuring that glyph's width.
472
471
  # @param x [Integer] column
473
472
  # @param y [Integer] row
474
473
  # @return [void]
475
474
  def blank_right_partner(x, y)
476
- return unless in_bounds?(x + 1, y) && @cells[index(x + 1, y)].continuation?
477
-
478
- write_cell(x + 1, y, " ", DEFAULT_STYLE)
475
+ cx = x + 1
476
+ while in_bounds?(cx, y) && @cells[index(cx, y)].continuation?
477
+ write_cell(cx, y, " ", DEFAULT_STYLE)
478
+ cx += 1
479
+ end
479
480
  end
480
481
  end
481
482
  end
data/lib/tuile/color.rb CHANGED
@@ -29,16 +29,10 @@ module Tuile
29
29
  # Color.coerce(nil) # nil → nil
30
30
  # ```
31
31
  #
32
- # Which entry point to use is a deliberate policy split. High-traffic
33
- # call sites ({StyledString} and friends) stay lenient and {.coerce} raw
34
- # forms — you don't want factory ceremony on every styled span.
35
- # Declaration sites ({Theme}, defined once per app) are strict and take
36
- # only {Color} instances, where `Color.palette(130)` documents itself in
37
- # a way the bare `130` (palette index? RGB channel?) does not.
38
- #
39
- # {#to_ansi} renders a full SGR escape (`"\e[31m"`); {#sgr_codes} returns the
40
- # raw numeric codes so callers (notably {StyledString}) can combine them with
41
- # other SGR attributes in a single sequence.
32
+ # {.coerce} is the lenient entry point (raw forms plus `nil`); the named
33
+ # factories and constants are the strict, self-documenting path for
34
+ # declaration sites — see the book's chapter 6 for why theme colors take
35
+ # {Color} instances only.
42
36
  class Color
43
37
  # Symbolic color names. Order is significant: indices 0..7 map to the
44
38
  # standard ANSI colors (SGR 30..37 fg / 40..47 bg); indices 8..15 map to