tuile 0.14.0 → 0.16.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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +159 -49
  3. data/README.md +53 -17
  4. data/book/04-event-loop.md +12 -12
  5. data/book/05-focus.md +152 -22
  6. data/book/06-theming.md +105 -25
  7. data/book/07-components.md +531 -50
  8. data/book/08-testing.md +100 -20
  9. data/book/10-locale.md +216 -0
  10. data/book/README.md +19 -9
  11. data/examples/file_commander.rb +14 -5
  12. data/examples/hello_world.rb +17 -4
  13. data/examples/sampler.rb +654 -40
  14. data/lib/tuile/component/abstract_string_field.rb +114 -68
  15. data/lib/tuile/component/abstract_wrapping_field.rb +279 -0
  16. data/lib/tuile/component/big_decimal_field.rb +52 -79
  17. data/lib/tuile/component/button.rb +8 -8
  18. data/lib/tuile/component/checkbox.rb +9 -9
  19. data/lib/tuile/component/checkbox_group.rb +38 -21
  20. data/lib/tuile/component/combo_box.rb +102 -59
  21. data/lib/tuile/component/confirm_window.rb +7 -5
  22. data/lib/tuile/component/date_field.rb +347 -0
  23. data/lib/tuile/component/date_time_field.rb +275 -0
  24. data/lib/tuile/component/float_field.rb +57 -82
  25. data/lib/tuile/component/has_bad_input.rb +88 -0
  26. data/lib/tuile/component/has_caption.rb +8 -0
  27. data/lib/tuile/component/has_content.rb +32 -13
  28. data/lib/tuile/component/has_placeholder.rb +62 -0
  29. data/lib/tuile/component/has_validation.rb +115 -0
  30. data/lib/tuile/component/has_value.rb +28 -1
  31. data/lib/tuile/component/integer_field.rb +51 -78
  32. data/lib/tuile/component/label.rb +7 -39
  33. data/lib/tuile/component/layout/box.rb +90 -19
  34. data/lib/tuile/component/layout.rb +15 -5
  35. data/lib/tuile/component/list.rb +53 -38
  36. data/lib/tuile/component/list_dropdown.rb +7 -3
  37. data/lib/tuile/component/menu_bar/cascade.rb +5 -5
  38. data/lib/tuile/component/menu_bar.rb +18 -18
  39. data/lib/tuile/component/notification.rb +32 -18
  40. data/lib/tuile/component/overlay.rb +26 -8
  41. data/lib/tuile/component/picker_window.rb +27 -8
  42. data/lib/tuile/component/popup.rb +2 -2
  43. data/lib/tuile/component/progress_bar.rb +10 -4
  44. data/lib/tuile/component/radio_group.rb +41 -23
  45. data/lib/tuile/component/select.rb +23 -16
  46. data/lib/tuile/component/slot.rb +3 -3
  47. data/lib/tuile/component/tab_sheet.rb +6 -6
  48. data/lib/tuile/component/tabs.rb +11 -11
  49. data/lib/tuile/component/text_area.rb +26 -18
  50. data/lib/tuile/component/text_field.rb +55 -26
  51. data/lib/tuile/component/text_view.rb +40 -19
  52. data/lib/tuile/component/time_field.rb +479 -0
  53. data/lib/tuile/component/window.rb +26 -13
  54. data/lib/tuile/component.rb +635 -131
  55. data/lib/tuile/event_queue.rb +4 -4
  56. data/lib/tuile/fake_event_queue.rb +1 -1
  57. data/lib/tuile/fake_screen.rb +95 -3
  58. data/lib/tuile/final.rb +75 -0
  59. data/lib/tuile/locale.rb +851 -0
  60. data/lib/tuile/mouse/router.rb +217 -0
  61. data/lib/tuile/mouse.rb +177 -0
  62. data/lib/tuile/screen.rb +219 -68
  63. data/lib/tuile/screen_pane.rb +51 -42
  64. data/lib/tuile/styled_string.rb +5 -5
  65. data/lib/tuile/testing.rb +198 -0
  66. data/lib/tuile/theme.rb +110 -32
  67. data/lib/tuile/version.rb +1 -1
  68. data/lib/tuile/vertical_scroll_bar.rb +88 -12
  69. data/lib/tuile.rb +1 -0
  70. data/sig/tuile.rbs +4595 -825
  71. metadata +14 -9
  72. data/COMPARISON.md +0 -101
  73. data/DECISIONS.md +0 -5422
  74. data/TERMINOLOGY.md +0 -71
  75. data/ideas/arrow-key-navigation.md +0 -221
  76. data/ideas/modal-backdrop.md +0 -24
  77. data/ideas/new-components.md +0 -124
  78. data/ideas/per-component-buffers.md +0 -55
  79. data/lib/tuile/mouse_event.rb +0 -68
data/TERMINOLOGY.md DELETED
@@ -1,71 +0,0 @@
1
- # TERMINOLOGY.md
2
-
3
- Tuile's house vocabulary — one line per term, looked up by word.
4
-
5
- This file owns **definitions only**. The *rules that bite* live in AGENTS.md
6
- ("Nomenclature" and the sections each word belongs to); the *why we chose a word
7
- and not its synonym* lives in DECISIONS.md (`D_scroll_nomenclature` for the
8
- row/line/item split); the *concepts* live in the book. When a definition here
9
- needs a paragraph of justification, that paragraph belongs in one of those three.
10
-
11
- ## The grid
12
-
13
- | term | means |
14
- |---|---|
15
- | **row** | one row of the terminal grid — the framework's only word for it. A wrapped unit of text *is* a row; wrapping is what turns text into rows. |
16
- | **column** | one cell-column of the terminal grid; the unit `display_width` counts. |
17
- | **cell** | one grid position: a grapheme plus a {Tuile::StyledString::Style}, in {Tuile::Buffer}. |
18
- | **glyph** | what the terminal draws in one or more cells. Ambiguous-width glyphs count as **one** column (the bet in `D_ambiguous_width`). |
19
- | **cluster** | a grapheme cluster — the unit measurement, slicing, caret motion and deletion all work in. Never `each_char`. |
20
- | **row_in_viewport** | a row measured `0...rect.height`, i.e. relative to a component's own rect. |
21
- | **scroll_top_row** | the content row currently sitting at the top of the viewport. |
22
- | **left_column** | the content column currently painted in a widget's leftmost cell — the horizontal counterpart of `scroll_top_row`. Private wherever it exists (`TextField`, `Tabs`, `MenuBar`): what a caller relies on is the invariant it maintains — the caret, or the selected segment, is in view — not the number. |
23
- | **viewport_rows** | how many rows of content are visible — always `rect.height`; kept private, since `rect.height` is the public form. |
24
- | **row_count** | how many rows the wrapped content occupies. Public on `TextArea` (with `caret_row`, its companion); also on the private `WrappedText` and as `VerticalScrollBar.new(row_count:)`. Not on `TextView` / `List`, which have no caller for it. |
25
- | **caret_row** | the row a text input's caret sits in, counted from the content's first row. `TextArea` only. |
26
- | **extent** | the `Size` a widget actually paints inside the `rect` it was given — `Component#extent`, `nil` unless declared, always at the rect's top-left (`Component#extent_rect` positions it). What the widget clears outside of, hit-tests, highlights and anchors its dropdown to. The arithmetic is each widget's own (a `Checkbox`'s glyph plus caption; a `Tabs` strip's segments and separators). Distinct from a *slot extent*. |
27
- | **segment** | one tab's span on a {Tuile::Component::Tabs} strip: its caption plus a padding column either side. The unit a click resolves to; the separator column between two segments belongs to neither. |
28
-
29
- **Space rule 1.** An object with only one row space leaves `row` unqualified:
30
- {Tuile::Buffer} *is* the grid, so its rows are screen rows;
31
- `TextArea::WrappedText` is content, so its rows are content rows.
32
-
33
- **Space rule 2.** A component holding both spaces qualifies the viewport one
34
- (`row_in_viewport`); its unqualified `row` and its `scroll_top_row` are
35
- content-space.
36
-
37
- ## Text and content
38
-
39
- | term | means |
40
- |---|---|
41
- | **line** | a `\n`-delimited unit of a String — exactly what `String#lines` returns. **Never a coordinate.** |
42
- | **line_count** | a count of `\n` units (`TextView::Region#line_count`). Never a row count. |
43
- | **item** | a domain object a widget holds and renders — `List#items`, and the enum widgets above it. |
44
- | **renderer** | the `item -> row` proc a generic component uses to render an item it knows nothing about. |
45
- | **selection** | which item or tab a selector currently points at. *View state* when nothing would save it ({Tuile::Component::Tabs}`#selected`), a *value* when a form would (`RadioGroup#value`) — the split `D_tabs` calls the "would a form save it?" test. |
46
- | **item_count** / **item_index** | how a `List::Cursor` counts and addresses; equal to a row count in a `List`, but the cursor indexes *items*. |
47
- | **text** | the user-editable **value** of an input ({Tuile::Component::HasValue}, aliased as `text` on `AbstractStringField`). |
48
- | **caption** | app-authored **chrome** text ({Tuile::Component::HasCaption}) — a `Window` title, a `Button` label. Never a value. |
49
- | **chrome** | framework- or app-authored decoration around content: captions, borders, footers, an app's status line. |
50
- | **caret** | the index into an input's `text` where editing happens; always on a cluster boundary. Distinct from the *cursor*. |
51
-
52
- ## Tree, paint and theme
53
-
54
- | term | means |
55
- |---|---|
56
- | **component** | a node of the UI tree ({Tuile::Component}); the only thing that paints. |
57
- | **slot extent** | in a `Layout::Box`, the size a parent *allocates* a child along an axis — what `Fixed` / `Percent` / `Expand` declare, and what `main_extent` / `cross_extent` measure. The parent's allocation, where a component's *extent* is the child's own painted region; `D_extent` turns on the two being different. Here `slot` is the box's allocation for one child and has **nothing** to do with {Tuile::Component::Slot} — the phrase is glossary-only (the code says `main_extent` / `cross_extent`), so read it as one term, never as "the extent of a `Slot`". |
58
- | **tile** / **tiled** | the non-popup part of the tree — `ScreenPane#content` and its descendants. Also *to tile*: to cover a rect completely. |
59
- | **attached** | reachable from a {Tuile::ScreenPane} via the parent chain — the one axis `attached?` consults. |
60
- | **slot** | a named region of a container, reached by identity (`content`, `footer`) as well as through `children`. Two forms: a plain named child, when the occupant is permanent and integral (`HasContent#content`); or a {Tuile::Component::Slot}, the one-child region component, when the occupant may be absent or swapped (`Window#footer`). Capital-`S` `Slot` always means the class. |
61
- | **cascade** | the stack of open {Tuile::Component::ListDropdown} panels a {Tuile::Component::MenuBar} drives, one per level, the last deepest. Each is an overlay on the pane, not a child of the bar. |
62
- | **submenu** | a menu item that opens a further panel instead of doing something — `MenuBar::Item#submenu?`, true iff the item has children. Painted with a trailing `▸`. |
63
- | **mnemonic** | a letter that activates one {Tuile::Component::MenuBar} item, underlined in its caption. Always *level-scoped*: matched against the top-level items while the cascade is closed and the deepest open panel while it is open, never across the two. |
64
- | **strip** | the one-row {Tuile::Component::Tabs} component: captions, one selected, no content of its own. A {Tuile::Component::MenuBar} has one too — same word, and the same extent-based hit testing, deliberately not the same look. |
65
- | **tab** | a {Tuile::Component::Tabs::Tab} — a caption plus an identity, minted and owned by the strip. Not a component (it never paints itself) and not an *item* (it holds per-element state, and the set is never assigned whole). Say "a tab" and "the Tab key"; never let the two words touch. |
66
- | **pane** | the component a {Tuile::Component::TabSheet} shows for the selected tab. The unselected ones are *detached*, which is how Tuile hides a component. |
67
- | **invalidate** | record a component as needing repaint; the loop coalesces and repaints once per tick. |
68
- | **cursor** | *(two senses, both live)* the hardware terminal cursor (`Screen#cursor_position`), and a `List::Cursor` — the selection position within a list. |
69
- | **well** | the explicit background an input paints over its whole rect (`Theme#input_bg_color` / `#active_bg_color`), which opts it out of `bg_color` inheritance. |
70
- | **token** | a semantic colour name on {Tuile::Theme} — an accent, never a global fg/bg. |
71
- | **scheme** | `:dark` or `:light`; a {Tuile::ThemeDef} pairs one {Tuile::Theme} per scheme. |
@@ -1,221 +0,0 @@
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
- - **{Tuile::Component::Tabs} already left the vertical axis free for this.**
108
- The strip claims Left/Right and *declines* Up/Down specifically so that this
109
- feature can move focus out of it vertically while Left/Right keep switching
110
- tabs inside it (`D_tabs`). It composes for nothing: the strip declines, the
111
- key bubbles, the navigating ancestor moves. That is also the shape to copy
112
- for any future one-axis widget — claim one axis, leave the other.
113
-
114
- ## The honest argument against
115
-
116
- A *partially* live feature is worse than an absent one: if arrows navigate in
117
- 80% of positions the user can't build a model. Inside a form the exception set
118
- is mostly coherent — `TextArea` / `TextView` / `List` swallow arrows, and
119
- they're the *tall* widgets, where a user already expects arrows to move
120
- *inside* the box. That reads as "arrows move within a tall widget, between
121
- short ones", which is learnable and is the story MC tells.
122
-
123
- The three numeric fields break that story and are the real problem (see Q6).
124
-
125
- ## Open questions
126
-
127
- **Q1 — What exactly is the knob?** Candidates, roughly in order of how much
128
- API they add:
129
-
130
- a. keyword + accessor on `Layout`: `navigation: :vertical` / `:horizontal` /
131
- `:both` / `nil` (default `nil`).
132
- b. a strategy *object*: `layout.navigation = Layout::ArrowNavigation.new(...)`,
133
- leaving room for per-instance config and app subclassing.
134
- c. a module the app mixes in: `Vertical.new.extend(Layout::ArrowNavigable)`.
135
- Composable with any layout without touching `Layout`, but `extend` on a
136
- singleton class is obscure and hard to document.
137
- d. a general `Component#on_key` interceptor hook (the shape
138
- `AbstractStringField#on_key` already has), with the framework shipping a
139
- ready-made callable to assign. Most decoupled — touches `Layout` not at
140
- all — but adds a general hook whose merits should be argued on their own,
141
- not smuggled in under this feature.
142
-
143
- (a) is the smallest thing that works; (d) is the most COP-pure. Not decided.
144
-
145
- **Q2 — Where does the axis come from?** If the behavior is layout-agnostic it
146
- can't be derived from the class. `Vertical` → up/down and `Horizontal` →
147
- left/right are natural defaults, but `Absolute` has none. Does the knob always
148
- carry an explicit axis, or default per class and require it on `Absolute`?
149
-
150
- **Q3 — Should `Horizontal` / left-right navigation exist at all?**
151
- `AbstractStringField` *always* consumes Left/Right for the caret, so a row of
152
- text fields will never arrow-navigate while a row of Buttons/Checkboxes will.
153
- The rule stays uniform (widget wins); the outcome looks selective. Ship both
154
- axes, or vertical-only until someone asks?
155
-
156
- **Q4 — Ordering inside an `Absolute`.** Declaration order is all that's
157
- available and may not match visual order — the original worry that killed the
158
- idea of putting this on every layout. Options: document "declaration order is
159
- yours to get right"; or sort direct children geometrically per keypress (by
160
- `rect.top`, then `rect.left`), which is cheap and actually correct, and would
161
- make `Absolute` a first-class citizen here. Is geometric ordering worth it?
162
-
163
- **Q5 — Backwards entry into a multi-widget sibling.** `Layout#on_focus` always
164
- forwards to the *first* tab stop, so arrowing **Up** into a previous group
165
- lands on its first widget rather than its last. FTXUI has the same wart. Fix
166
- with a `last:` variant of the cascade, or accept it?
167
-
168
- **Q6 — The numeric fields.** `IntegerField` / `FloatField` / `BigDecimalField`
169
- consume Up/Down to step by ±1, and they are *one row tall* — so they break the
170
- "arrows move within tall widgets" story silently, with nothing on screen
171
- explaining why. This is the sharpest concrete collision. Options:
172
-
173
- a. leave it; document the exception,
174
- b. move stepping to `Ctrl+Up/Down` or `PgUp/PgDn` (breaking, but the fields
175
- are young),
176
- c. make stepping opt-in per field (`step = 1` / `nil`) — a knob, but on the
177
- widget that actually has the ambiguity, and a numeric field in a form
178
- usually doesn't want spinner behavior anyway.
179
-
180
- **Q7 — `List` at its edges.** It clamps and returns true, so arrows can never
181
- escape a focused list; only Tab does. Keep clamping (a list is a list, and MC
182
- agrees), or have it decline at its edges so arrows escape? Note this is
183
- exactly virtui's shape, so the answer matters more there than in a form.
184
-
185
- **Q8 — `ComboBox` is asymmetric.** Closed, it eats Down to open the menu
186
- (`combo_box.rb:181`) but declines Up. So Up would jump out of a closed combo
187
- while Down opens it. Browsers eat both. Deliberate choice or accident to fix?
188
-
189
- **Q12 — Down out of a `Tabs` strip: to the pane, or past the whole
190
- `TabSheet`?** The strip is a child of the sheet, not of the navigating layout,
191
- so "walk direct children" sees the *sheet* holding focus and would move to the
192
- sheet's next sibling — skipping the pane the user is looking at. Entering the
193
- pane is almost certainly what a user means by Down here. Options: let a
194
- `TabSheet` claim Down when focus is on its strip (a `handle_key` on the sheet,
195
- no framework change, but a second place that binds an arrow); or have the
196
- navigating walk descend into a child that holds focus deeper than its first
197
- tab stop. Interacts with Q1's placement question.
198
-
199
- **Q9 — Naming.** "Form" is the wrong word for the behavior — it's *focus
200
- navigation*. `navigation` / `arrow_nav` / `key_navigation` /
201
- `focus_navigation`? Whatever it is, it must not imply validation or submit,
202
- which Tuile has no notion of.
203
-
204
- **Q10 — Does Enter participate?** `dialog(1)` moves to the next field on
205
- Enter. Almost certainly out of scope — `Checkbox`, `Button` and `TextArea` all
206
- claim Enter already, and book ch5 has the per-widget Enter table — but worth
207
- rejecting explicitly rather than by omission.
208
-
209
- **Q11 — Where does the code live?** Zeitwerk wants one top-level constant per
210
- file. If Q1 lands on (b) or (c) it needs its own file under
211
- `lib/tuile/component/layout/`; if (a), it's a few lines on `Layout` itself.
212
- Also: any public signature change means `rake sig` in the same commit.
213
-
214
- ## Graduation
215
-
216
- If built: the user-facing half goes to book ch5 (the key/Enter tables live
217
- there), the invariants half to AGENTS.md's key-dispatch section, and the
218
- choice-plus-rejected-roads half to `DECISIONS.md` as `D_arrow_navigation` —
219
- which must record the `Layout::Form` rejection and the Vaadin FormGroup
220
- precedent behind it, since that's the reasoning most likely to be
221
- re-litigated. Then retire this file.
@@ -1,24 +0,0 @@
1
- # Modal backdrop — dim the content under a popup, or cast a shadow
2
-
3
- **Status:** seed, 2026-08-31. Deliberately not brainstormed yet; spun off from
4
- the `ConfirmWindow` design (`D_confirm_window`'s sizing paragraph).
5
-
6
- **The problem.** A modal `Popup` floats over the tiled content with no visual
7
- separation beyond its own border: the content underneath is neither dimmed nor
8
- shadowed. A small popup — a `ConfirmWindow` measuring a one-line "Overwrite?" —
9
- can sit in the middle of a busy screen and simply not be noticed.
10
-
11
- **The two candidate treatments** (every GUI stack ships at least one):
12
-
13
- - **Dim/tint** the non-popup cells under the topmost modal.
14
- - **A drop shadow** — a one-cell dark offset under/right of the popup box.
15
-
16
- **Hooks that exist today, for whoever picks this up:** `Screen#repaint`
17
- already partitions tiled vs. popup subtrees and repaints popups on top, so a
18
- dim pass has a natural slot between the two. Terminal cells are opaque
19
- (`D_bg_inherit`), so "dim" means restyling cells, not compositing — and
20
- `Color` has no darken/blend operation yet, which a dim factor would need.
21
-
22
- **Open when picked up:** flush-time transform in `Buffer` vs. repaint-time
23
- style override in components; does a shadow belong to `Overlay` or only
24
- `Popup`; interaction with themes and with the terminal-default (unset) bg.
@@ -1,124 +0,0 @@
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} takes typed items and a renderer since 2026-08-14
29
- (`D_list_items`), but has no multi-select — {Tuile::Component::CheckboxGroup}
30
- is the nearest thing.
31
-
32
- Tuile also has pieces Vaadin doesn't name as components — `ListDropdown`,
33
- `TextView`, `LogWindow`, `VerticalScrollBar` — so the gap is not
34
- symmetric.
35
-
36
- That leaves ~46 gaps.
37
-
38
- ## Tier 1 — reachable from what exists (S–M each)
39
-
40
- | Component | Builds on | Note |
41
- |---|---|---|
42
- | ~~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 |
43
- | ~~Checkbox~~ | `HasValue` | **built** 2026-07-30 (`D_boolean_fields`); tri-state still deferred |
44
- | ~~Radio Group~~ | `List` + `HasValue` | **built** 2026-07-31 (`D_radio_group`); composes a `List`, cursor roams and Space selects |
45
- | ~~Checkbox Group~~ | `List` + `HasValue` | **built** 2026-07-30 (`D_checkbox_group`); composes a `List`, frozen `Set` value |
46
- | ~~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 |
47
- | ~~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 |
48
- | ~~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` |
49
- | ~~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?` |
50
- | ~~Notification~~ | `Popup` + `Ticker` | **built** 2026-08-17 (`D_notification`, book ch7); one non-modal top-right box, N messages, one 3 s ticker retiring the oldest. Corner anchor is its own `reposition` override, so `Popup` was untouched — and the `Popover` extraction still waits for a second *kind* of anchoring |
51
- | ~~Confirm Dialog~~ | `Popup`+`Window`+`Button` | **built** 2026-08-31 as `ConfirmWindow` (`D_confirm_window`, book ch7); the component is the builder — `#button` plus the `alert`/`confirm`/`yes_no` factories — every button dismisses, MenuBar-shaped mnemonics with `q`/`g`/`G` reserved. The fold-`PickerWindow`-in idea is **rejected**: the two disagree on every semantic that matters (cursor, default, ESC, close-on-pick) and share only API shape |
52
- | Details → Accordion | `HasContent` | Details is the atom, Accordion the group |
53
- | ~~Tabs → TabSheet~~ | plain `Component` + the tree API | **built** 2026-08-23 (`D_tabs`, book ch7); a strip (one tab stop, Left/Right, immediate activation) plus a sheet whose `children` are `[strip, pane]`. Neither is `HasValue` — a tab selection is view state — and neither is `HasContent`; hiding a pane means *detaching* it, since Tuile has no visibility flag; the strip owns mutable `Tabs::Tab` handles rather than the `items`/`item_label` shell. Hidden/disabled/closeable tabs, lazy panes and a scrolling strip are deferred, each additive (`D_tabs`) |
54
- | 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. Nothing gates on it today: Menu Bar shipped without it |
55
- | ~~Menu Bar~~ | `ListDropdown` (+ `anchor_beside`) | **built** 2026-08-24 (`D_menu_bar`, book ch7), mnemonics the same day. Turned out *not* to need the Popover extraction: a focused strip drives a cascade of non-modal `ListDropdown`s the way `Select` drives one, so the additions were a side-anchor method, a highlighted-row rect and a cursor pass-through. Unlimited submenu depth; checkable/disabled items and global-shortcut activation deferred indefinitely |
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** → pickers, Tooltip. **No longer gates Menu
103
- Bar** — `D_menu_bar` argues the side-anchor is a sibling method on
104
- `ListDropdown`, since both callers still wrap a `List`; the extraction's
105
- trigger is now the first non-`List` content that wants anchoring.
106
- 5. **Mouse motion/drag** (modes 1002/1006, release events) → Split
107
- divider, Slider drag, scrollbar drag.
108
- 6. **Typed items + data provider on `List`** → List Box, Grid, Virtual
109
- List. **Half done** 2026-08-14 (`D_list_items`): `List` takes `items` +
110
- a `renderer` and renders only the visible rows, and the five composers
111
- are folded onto it. The remaining half is *sourcing* items lazily (a
112
- data provider behind `items`), which lazy rendering was chosen to keep
113
- reachable without a redesign.
114
-
115
- Vaadin's `Binder` is the natural companion for the forms cluster but is
116
- not a component; `D_has_value` already parks the forms-layer questions
117
- (converters, read-only, required indicator).
118
-
119
- ## ~~Cross-cutting open question: component color slots vs. theme tokens~~
120
-
121
- **Settled 2026-08-01 as `DECISIONS.md` `D_color_slots`** — the slot, defaulting
122
- to `nil`. Slider and Badge are bound by it when they land; Badge's promotion
123
- trigger (a *second* built-in needing the same semantic color) is written up
124
- there, so neither has to re-argue it.
@@ -1,55 +0,0 @@
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.
@@ -1,68 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Tuile
4
- # A mouse event.
5
- #
6
- # @!attribute [r] button
7
- # @return [Symbol, nil] one of `:left`, `:middle`, `:right`, `:scroll_up`,
8
- # `:scroll_down`, `:scroll_left`, `:scroll_right`; `nil` if not known.
9
- # @!attribute [r] x
10
- # @return [Integer] x coordinate, 0-based.
11
- # @!attribute [r] y
12
- # @return [Integer] y coordinate, 0-based.
13
- class MouseEvent < Data.define(:button, :x, :y)
14
- # @return [Point] the event's position.
15
- def point = Point.new(x, y)
16
-
17
- # Checks whether given key is a mouse event key. Returns true on the X10
18
- # `\e[M` prefix regardless of length — {.parse} is the place that
19
- # validates the full 6-byte shape and raises on malformed input.
20
- # @param key [String] key read via {Keys.getkey}
21
- # @return [Boolean] true if it is a mouse event
22
- def self.mouse_event?(key)
23
- key.start_with?("\e[M")
24
- end
25
-
26
- # Parses an X10 mouse report (`\e[M` + 3 bytes: button, x, y).
27
- #
28
- # Raises {Tuile::Error} when `key` starts with the mouse prefix but is
29
- # not exactly 6 bytes long. Both shorter and longer inputs are bugs in
30
- # the upstream key-reader: a shorter prefix means the tail was lost on
31
- # the way in, and a longer one means we over-consumed into the next
32
- # escape sequence. We refuse to silently truncate either case because
33
- # the trailing `\e` of an over-read corrupts the *next* getkey, and the
34
- # corruption then surfaces as garbled keystrokes in focused inputs
35
- # rather than as a parser failure pointing at the actual cause.
36
- # @param key [String] key read via {Keys.getkey}
37
- # @return [MouseEvent, nil] `nil` if `key` is not a mouse event
38
- # @raise [Tuile::Error] if `key` is a malformed mouse event
39
- def self.parse(key)
40
- return nil unless mouse_event?(key)
41
- unless key.bytesize == 6
42
- raise Tuile::Error,
43
- "malformed mouse event: expected 6 bytes after \\e[M prefix, got #{key.bytesize}: #{key.inspect}"
44
- end
45
-
46
- button = key[3].ord - 32
47
- # XTerm reports coordinates 1-based (column N is encoded as N + 32);
48
- # subtract 33 so that `x` and `y` are 0-based.
49
- x = key[4].ord - 33
50
- y = key[5].ord - 33
51
- button = case button
52
- when 0 then :left
53
- when 2 then :right
54
- when 1 then :middle
55
- when 64 then :scroll_up
56
- when 65 then :scroll_down
57
- when 66 then :scroll_left
58
- when 67 then :scroll_right
59
- end
60
- MouseEvent.new(button, x, y)
61
- end
62
-
63
- # @return [String]
64
- def self.start_tracking = "\e[?1000h"
65
- # @return [String]
66
- def self.stop_tracking = "\e[?1000l"
67
- end
68
- end