tuile 0.15.0 → 0.17.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 (109) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +229 -80
  3. data/README.md +49 -24
  4. data/book/02-repaint.md +47 -19
  5. data/book/03-layout.md +98 -49
  6. data/book/04-event-loop.md +17 -16
  7. data/book/05-focus.md +106 -34
  8. data/book/06-theming.md +108 -38
  9. data/book/07-components.md +249 -46
  10. data/book/08-testing.md +134 -32
  11. data/book/10-locale.md +3 -3
  12. data/book/README.md +11 -10
  13. data/examples/file_commander.rb +52 -32
  14. data/examples/hello_world.rb +18 -5
  15. data/examples/sampler.rb +576 -146
  16. data/lib/tuile/buffer.rb +12 -1
  17. data/lib/tuile/canvas/backend.rb +46 -0
  18. data/lib/tuile/canvas.rb +212 -0
  19. data/lib/tuile/color.rb +38 -9
  20. data/lib/tuile/component/abstract_string_field.rb +96 -97
  21. data/lib/tuile/component/abstract_wrapping_field.rb +99 -58
  22. data/lib/tuile/component/big_decimal_field.rb +7 -6
  23. data/lib/tuile/component/button.rb +27 -19
  24. data/lib/tuile/component/checkbox.rb +21 -19
  25. data/lib/tuile/component/checkbox_group.rb +17 -18
  26. data/lib/tuile/component/combo_box.rb +69 -64
  27. data/lib/tuile/component/confirm_window.rb +34 -27
  28. data/lib/tuile/component/date_field.rb +50 -18
  29. data/lib/tuile/component/date_time_field.rb +319 -0
  30. data/lib/tuile/component/fill.rb +93 -0
  31. data/lib/tuile/component/float_field.rb +7 -6
  32. data/lib/tuile/component/form_item.rb +250 -0
  33. data/lib/tuile/component/form_layout.rb +206 -0
  34. data/lib/tuile/component/has_bad_input.rb +99 -28
  35. data/lib/tuile/component/has_caption.rb +14 -5
  36. data/lib/tuile/component/has_content.rb +8 -15
  37. data/lib/tuile/component/has_placeholder.rb +1 -1
  38. data/lib/tuile/component/has_validation.rb +40 -14
  39. data/lib/tuile/component/has_value.rb +71 -17
  40. data/lib/tuile/component/integer_field.rb +7 -6
  41. data/lib/tuile/component/label.rb +8 -15
  42. data/lib/tuile/component/layout/absolute.rb +86 -0
  43. data/lib/tuile/component/layout/box.rb +38 -60
  44. data/lib/tuile/component/layout.rb +127 -13
  45. data/lib/tuile/component/list.rb +233 -120
  46. data/lib/tuile/component/list_dropdown.rb +151 -91
  47. data/lib/tuile/component/menu_bar/cascade.rb +102 -32
  48. data/lib/tuile/component/menu_bar.rb +102 -82
  49. data/lib/tuile/component/notification.rb +76 -49
  50. data/lib/tuile/component/overlay.rb +217 -58
  51. data/lib/tuile/component/password_field.rb +1 -8
  52. data/lib/tuile/component/picker_window.rb +41 -17
  53. data/lib/tuile/component/popup.rb +15 -26
  54. data/lib/tuile/component/progress_bar.rb +17 -11
  55. data/lib/tuile/component/radio_group.rb +16 -17
  56. data/lib/tuile/component/scroller.rb +266 -0
  57. data/lib/tuile/component/select.rb +26 -43
  58. data/lib/tuile/component/slot.rb +4 -5
  59. data/lib/tuile/component/tab_sheet.rb +27 -34
  60. data/lib/tuile/component/tabs.rb +49 -34
  61. data/lib/tuile/component/text_area/wrapped_text.rb +1 -1
  62. data/lib/tuile/component/text_area.rb +32 -28
  63. data/lib/tuile/component/text_field.rb +68 -50
  64. data/lib/tuile/component/text_view.rb +157 -89
  65. data/lib/tuile/component/time_field.rb +51 -21
  66. data/lib/tuile/component/vertical_scroll_bar.rb +257 -0
  67. data/lib/tuile/component/window.rb +27 -26
  68. data/lib/tuile/component.rb +653 -323
  69. data/lib/tuile/component_background.rb +177 -0
  70. data/lib/tuile/component_util.rb +43 -0
  71. data/lib/tuile/event.rb +29 -0
  72. data/lib/tuile/event_queue.rb +18 -4
  73. data/lib/tuile/fake_event_queue.rb +1 -1
  74. data/lib/tuile/fake_screen.rb +120 -7
  75. data/lib/tuile/keys.rb +15 -6
  76. data/lib/tuile/layout_pass.rb +180 -0
  77. data/lib/tuile/listeners.rb +219 -0
  78. data/lib/tuile/mouse/router.rb +233 -0
  79. data/lib/tuile/mouse.rb +244 -0
  80. data/lib/tuile/point.rb +6 -0
  81. data/lib/tuile/rect.rb +33 -0
  82. data/lib/tuile/screen.rb +510 -138
  83. data/lib/tuile/screen_pane.rb +185 -67
  84. data/lib/tuile/strict_layout.rb +127 -0
  85. data/lib/tuile/styled_string.rb +144 -14
  86. data/lib/tuile/testing/gestures.rb +35 -0
  87. data/lib/tuile/testing.rb +316 -42
  88. data/lib/tuile/theme.rb +192 -53
  89. data/lib/tuile/theme_def.rb +4 -0
  90. data/lib/tuile/version.rb +1 -1
  91. data/lib/tuile.rb +53 -0
  92. data/sig/tuile.rbs +6084 -1507
  93. metadata +19 -17
  94. data/COMPARISON.md +0 -101
  95. data/DECISIONS.md +0 -8562
  96. data/TERMINOLOGY.md +0 -85
  97. data/ideas/arrow-key-navigation.md +0 -221
  98. data/ideas/binder.md +0 -177
  99. data/ideas/composite-field.md +0 -77
  100. data/ideas/focus-accent.md +0 -116
  101. data/ideas/form-layout.md +0 -151
  102. data/ideas/hover/probe.rb +0 -241
  103. data/ideas/hover/probe_spec.rb +0 -82
  104. data/ideas/hover.md +0 -909
  105. data/ideas/modal-backdrop.md +0 -24
  106. data/ideas/new-components.md +0 -144
  107. data/ideas/per-component-buffers.md +0 -55
  108. data/lib/tuile/mouse_event.rb +0 -68
  109. data/lib/tuile/vertical_scroll_bar.rb +0 -122
data/README.md CHANGED
@@ -12,10 +12,12 @@ providers — is described in
12
12
  Tuile is that approach applied to a terminal.
13
13
 
14
14
  Tuile is the only actively maintained component-oriented TUI framework for
15
- Ruby that we are aware of. If you have looked at the alternatives —
16
- tty-toolkit, vedeu, ratatui, or the curses bindings your distro packages —
17
- [COMPARISON.md](COMPARISON.md) sizes each one up and says which of them you
18
- can actually reach from Ruby.
15
+ Ruby that we are aware of. Ruby does now reach two of the big non-Ruby
16
+ toolkits — Rust's ratatui through `ratatui_ruby`, Go's Charm stack through
17
+ CharmRuby — but both hand you a draw loop or an Elm-style model/update/view,
18
+ not a tree of components. [The research notes](design/research.md) size up each
19
+ neighbour, those two included, alongside tty-toolkit, vedeu and the curses
20
+ bindings your distro packages.
19
21
 
20
22
  ## Installation
21
23
 
@@ -55,8 +57,8 @@ else in Tuile loads it.
55
57
  - **API reference:** every public class and method carries YARD headers —
56
58
  browse them at <https://rubydoc.info/gems/tuile>, or run
57
59
  `bundle exec rake yard` for a local site.
58
- - **[COMPARISON.md](COMPARISON.md)** places Tuile among the neighbouring
59
- toolkits, and answers what a Ruby program can reach without writing
60
+ - **[The research notes](design/research.md)** size up the neighbouring
61
+ toolkits, and answer what a Ruby program can reach without writing
60
62
  bindings first.
61
63
 
62
64
  ## Hello world
@@ -108,13 +110,14 @@ write escape sequences. They call `invalidate`, and paint styled cells into a
108
110
  back buffer when the loop asks them to; one flush per tick emits the
109
111
  **minimal diff** — only the cells that actually changed — inside a
110
112
  synchronized-output batch. There is no damage tracking to maintain and no
111
- clipping to think about: popups simply overdraw, because overdraw into a
112
- buffer is free. → [chapter 2](book/02-repaint.md)
113
+ clipping to manage — a component is bounded by its own rectangle for you, and
114
+ popups simply overdraw, because overdraw into a buffer is free.
115
+ → [chapter 2](book/02-repaint.md)
113
116
 
114
117
  **Layout is top-down, and that is the whole model.** A parent computes its
115
118
  children's rectangles in plain Ruby and assigns them; a component never
116
119
  advertises a size it would like. No `min`/`preferred`/`max`, no negotiation
117
- pass, no shrink-to-fit. Subclass `Layout::Absolute` when the arithmetic is
120
+ pass, no shrink-to-fit. Subclass `Layout` when the arithmetic is
118
121
  yours, or use `Layout::Vertical` / `Layout::Horizontal` to declare each
119
122
  child's extent as `Fixed` / `Percent` / `Expand`.
120
123
  → [chapter 3](book/03-layout.md)
@@ -137,9 +140,20 @@ between panes. A paste is deliberately *not* a burst of keys: with bracketed
137
140
  paste it arrives whole, as one `handle_paste`.
138
141
  → [chapter 5](book/05-focus.md)
139
142
 
143
+ **The mouse is routed by position, and claimed by one component.** A press
144
+ focuses the innermost focusable under the pointer before any handler runs,
145
+ then bubbles outward through `handle_mouse_down?` until someone answers
146
+ `true` — and that claimant is *grabbed*, so the drags and the release reach
147
+ it wherever the pointer goes. The wheel bubbles the same way, so a list
148
+ already at its top hands the notch up to whatever scrolls around it. `capture_mouse:` picks how
149
+ much the terminal reports: `:clicks`, `:drag`, or `:hover` with enter/exit
150
+ hooks.
151
+ → [chapter 5](book/05-focus.md)
152
+
140
153
  **Theming is accents-only, and follows the OS.** A `Theme` carries semantic
141
- accent tokens — the list cursor, an input well, an active window border,
142
- status-bar hints — plus whatever `custom` tokens your app adds. Everything
154
+ accent tokens for the chrome Tuile itself paints — the list cursor, an input
155
+ well, an active window border, a scrollbar — plus whatever `custom` tokens your
156
+ app adds for text of its own (a status row's shades live there). Everything
143
157
  else inherits the terminal's own foreground and background, so Tuile looks at
144
158
  home in the user's palette instead of fighting it. Tuile probes the terminal
145
159
  background at startup, pairs a dark and a light theme in a `ThemeDef`, and
@@ -171,8 +185,9 @@ carries the per-method reference: `bundle exec rake yard`, or
171
185
 
172
186
  | component | what it is |
173
187
  |---|---|
174
- | `Layout::Absolute` | Positions children by assigning their `rect` in a `rect=` override, and paints nothing itself. The base to subclass when the arithmetic is yours. |
175
- | `Layout::Vertical`, `Layout::Horizontal` | Stack children along one axis from declared extents — `Fixed[n]`, `Percent[n]`, `Expand[weight]` — with box-global `spacing` and `padding`. Sugar over `Absolute`, not a new sizing model. |
188
+ | `Layout` | Positions children by assigning their `rect` in a `relayout` override, and paints nothing itself. The base to subclass when the arithmetic is yours. |
189
+ | `Layout::Absolute` | Places each child at the fixed `Rect` it was added with; `constrain` moves one. |
190
+ | `Layout::Vertical`, `Layout::Horizontal` | Stack children along one axis from declared extents — `Fixed[n]`, `Percent[n]`, `Expand[weight]`, a `Percent` bounded with `.clamp(range)` — with box-global `spacing` and `padding`. Sugar over a hand-written `relayout`, not a new sizing model. |
176
191
 
177
192
  ### Framing and switching — [book ch7](book/07-components.md#framing-content)
178
193
 
@@ -180,6 +195,10 @@ carries the per-method reference: `bundle exec rake yard`, or
180
195
  |---|---|
181
196
  | `Window` | A frame with a `caption`, one content slot, and a border that lights up while the window is on the focus chain. `footer_text=` decorates the bottom border; `footer=` mounts a real component in it; `scrollbar=` reclaims the right border column. |
182
197
  | `Slot` | A one-child region for content that may be absent, arrive late, or be swapped. Give a multi-region container one per region and the tree stays honest — the occupant fills the slot's rect, and an empty slot holds its place rather than collapsing. |
198
+ | `Scroller` | A viewport onto a taller pile of components: one content child, a scrollbar column, and `content_rows` — you say how tall the content is, since nothing measures. The wheel scrolls it, the bar drags, and so does focus: Tab into a field below the fold and it comes into view. |
199
+ | `VerticalScrollBar` | A one-column bar the user can drag, and press the track of to page. It moves nothing itself: it asks through `on_scroll_request` and the container that owns the column assigns `scroll_top_row`. Dragging wants `run_event_loop(capture_mouse: :drag)`. |
200
+ | `FormItem` | One row of a form: a `caption` above a field, an optional required marker beside it, and the message the field reports against itself — a validator's verdict, or input it cannot parse — mirrored into the row below. Three rows that never reflow — the message row doubles as the gap. Hide the item, not the field. |
201
+ | `FormLayout` | A column of `FormItem`s: `add(field, caption:, required:, rows:)` wraps the field, returns the item and stacks it below the last. No `spacing` — the message row is the gap — nothing measures, and items past the bottom edge are clipped rather than scrolled. |
183
202
  | `MenuBar` | A one-row strip of menu captions, each dropping a cascade of submenus that nests without limit. Items are handles from `#add_item`, each with its own `on_click`. See [Menus](book/07-components.md#menus). |
184
203
  | `Tabs` | A one-row strip of captions with one selected, Left/Right switching immediately. Knows nothing about content — pair it with `TabSheet`, or drive your own view swap from `on_tab_selected`. |
185
204
  | `TabSheet` | A `Tabs` strip plus the pane belonging to the selected tab. Unselected panes are *detached*, so they keep their state and stay out of the Tab cycle. See [Switching between views](book/07-components.md#switching-between-views). |
@@ -191,6 +210,7 @@ carries the per-method reference: `bundle exec rake yard`, or
191
210
  | `Label` | Static text, one row per line, no wrapping — long lines are ellipsized. Content is a `StyledString`, so ANSI passes through. |
192
211
  | `TextView` | A read-only viewer for prose: word-wrapped, scrollable, appendable, and addressable in named `Region`s when you want to rewrite one part of the text in place. |
193
212
  | `ProgressBar` | A one-row bar — `█` over a `░` track — driven by `value` within a `Range`, or `indeterminate` for a bouncing sweep that owns its own ticker while on screen. |
213
+ | `Fill` | One glyph in every cell of its rect, in a `color` that may be a live `Theme.ref`. A one-column `Fill` of `│` is a vertical rule between borderless panes, a one-row `─` a horizontal one. |
194
214
 
195
215
  ### Editing text — [book ch7](book/07-components.md#editing-text)
196
216
 
@@ -209,18 +229,19 @@ carries the per-method reference: `bundle exec rake yard`, or
209
229
  | `BigDecimalField` | The same for money, where a binary `Float` is the wrong answer. Tuile's one optional dependency — add `bigdecimal` yourself if you name this component. |
210
230
  | `DateField` | A one-row field whose `value` is a `Date` or `nil`, over a list of strftime formats taken from `Screen#locale`: it accepts any of them and writes the first one back when you leave the field. Manual entry — there is no calendar popup yet. |
211
231
  | `TimeField` | A one-row field whose `value` is a time of day — a `Time` on a fixed epoch date, or `nil` — spelled the way `Screen#locale` says. `step` is both the Up/Down stride and the precision: it shows seconds only when set below a minute. |
232
+ | `DateTimeField` | The two above side by side on one row, behind a single `DateTime` at `+00:00`. Each half reddens its own bad input; the field reddens whole only for the fault no half can wear — one half filled and the other empty. |
212
233
 
213
234
  ### Choosing from a set — [book ch7](book/07-components.md#choosing-from-a-set)
214
235
 
215
236
  | component | what it is |
216
237
  |---|---|
217
238
  | `Checkbox` | A one-row boolean: `[x]` / `[ ]` plus a caption, toggled by Space, Enter or a click on the glyph or label. |
218
- | `List` | The workhorse: a scrollable column of *typed items*, one row each, rendered lazily by a `renderer` you supply and handing your callbacks the item itself. Add a `Cursor` (or `Cursor::Limited`) to make it navigable. |
239
+ | `List` | The workhorse: a scrollable column of *typed items*, one row each, rendered lazily by a `renderer` you supply and handing your callbacks the item itself. Add a `Cursor` (or `Cursor::Limited`) to make it navigable, or set `interactive = false` for a display-only pane Tab skips. |
219
240
  | `RadioGroup` | Single-select over a set of items, one row each, with the marker painted in front of the label. Its `value` is the selected item. |
220
241
  | `CheckboxGroup` | Multi-select over the same shape; its `value` is a frozen `Set` of the checked items. |
221
242
  | `Select` | The enum field: a one-row face plus a `▾`, dropping open a list of options. Claims no printable key but Space, so your app's own keys keep working while it has focus. |
222
243
  | `ComboBox` | A text field with a filtering dropdown — type to narrow, arrow to highlight, Enter to accept. Its `value` is the selected *item*, never the typed text. |
223
- | `ListDropdown` | The floating, non-focusable list that `Select` and `ComboBox` drop open, and the `Menu` variant an app can drive itself. You rarely instantiate it directly. |
244
+ | `ListDropdown` | The floating, non-focusable list that `Select` and `ComboBox` drop open with `anchor_to(self)`, following the field as it moves, and the `Menu` variant an app can drive itself. You rarely instantiate it directly. |
224
245
 
225
246
  ### Taking an action — [book ch7](book/07-components.md#taking-an-action)
226
247
 
@@ -232,12 +253,12 @@ carries the per-method reference: `bundle exec rake yard`, or
232
253
 
233
254
  | component | what it is |
234
255
  |---|---|
235
- | `Overlay` | The bare floating layer: it wraps any component, paints nothing itself, and sits at the rect you assign it. Takes no focus and no keys — the building block for anchored panels and toasts. |
236
- | `Popup` | The modal dialog: an `Overlay` that centers itself, grabs focus, scopes keys to its own subtree and blocks clicks beneath it. Sized by `declared_size=` (a `Size` or a `Fraction` of the screen) rather than by its content; ESC or `q` dismisses. |
237
- | `Notification` | A transient corner toast — `Notification.show("Saved")` — stacking messages in one box that a single ticker drains. Non-modal, and it never takes focus. |
256
+ | `Overlay` | The bare floating layer: it wraps any component, paints nothing itself, and sits where its placement says — `open(Overlay::At[rect])`; the pane assigns the rect, again on every resize. Takes no focus and no keys — the building block for anchored panels and toasts. |
257
+ | `Popup` | The modal dialog: an `Overlay` placed centered by default, which grabs focus, scopes keys to its own subtree and blocks clicks beneath it. Sized by `declared_size=` (a `Size` or a `Fraction` of the screen) rather than by its content; ESC or `q` dismisses. |
258
+ | `Notification` | A transient corner toast — `Notification.show("Saved")` — stacking messages in one box that a single ticker drains. Non-modal, it never takes focus, and its inner `View` refuses the wheel so queued messages wait for the ticker. |
238
259
  | `ConfirmWindow` | The confirm dialog: a message and a row of buttons in a popup sized to fit. `alert` / `confirm` / `yes_no` cover the common shapes; `#button` builds any other. Every button closes; ESC, `q` or an outside click fire `on_dismiss`. See [The confirm dialog](book/07-components.md#the-confirm-dialog). |
239
260
  | `InfoWindow` | A `Window` with a read-only body, tiled or popped up: prose that wraps (`message=`), or rows that don't (`lines=`). |
240
- | `PickerWindow` | A `Window` of options identified by single keystrokes, firing a callback with the key that was pressed. |
261
+ | `PickerWindow` | A `Window` of options identified by single keystrokes, firing a callback with the key that was pressed. Captions take a `StyledString` to color one. |
241
262
  | `LogTextView` | An auto-scrolling `TextView` for log output. Point your logger at a `LogTextView::IO` and lines land here from any thread, marshalled through the event queue. |
242
263
  | `LogWindow` | A `Window` framing a `LogTextView` — the framed log pane. |
243
264
 
@@ -292,10 +313,12 @@ module Tuile
292
313
 
293
314
  it "renders text into its rect" do
294
315
  label = Component::Label.new
295
- label.rect = Rect.new(0, 0, 5, 1)
296
316
  label.text = "hi"
297
- label.repaint
298
- assert_equal ["hi "], Screen.instance.buffer.region_text(label.rect)
317
+ holder = Component::Layout::Absolute.new
318
+ holder.add(label, Rect.new(0, 0, 5, 1)) # a parent places it; nothing else may
319
+ Screen.instance.content = holder
320
+ Screen.instance.repaint
321
+ assert_equal ["hi "], Screen.instance.buffer.region_text(label.absolute_rect)
299
322
  end
300
323
  end
301
324
  end
@@ -342,14 +365,16 @@ run `bundle exec rake spec` to run the tests. You can also run `bin/console`
342
365
  for an interactive prompt that will allow you to experiment.
343
366
 
344
367
  To install this gem onto your local machine, run `bundle exec rake install`.
345
- To release a new version, see [`RELEASING.md`](RELEASING.md).
368
+ To release a new version, see [`design/releasing.md`](design/releasing.md).
346
369
 
347
370
  ## Contributing
348
371
 
349
372
  Bug reports and pull requests are welcome on GitHub at
350
373
  <https://github.com/mvysny/tuile>. Please read [`AGENTS.md`](AGENTS.md) before
351
374
  opening a PR — it documents the architecture invariants (singleton screen,
352
- invalidation/repaint contract, threading rule) that the framework relies on.
375
+ invalidation/repaint contract, threading rule) that the framework relies on,
376
+ and routes you to [`design/`](design/), where
377
+ [`decisions.md`](design/decisions.md) answers "why is it like this?".
353
378
  This project is intended to be a safe, welcoming space for collaboration, and
354
379
  contributors are expected to adhere to the
355
380
  [code of conduct](https://github.com/mvysny/tuile/blob/master/CODE_OF_CONDUCT.md).
data/book/02-repaint.md CHANGED
@@ -10,7 +10,7 @@ paints everything that asked to be repainted, in one flicker-free batch.
10
10
  Understanding this model matters for two reasons. It's the contract every
11
11
  custom component has to honor (paint your rectangle, don't touch the
12
12
  wire). And it's *why* Tuile stays smooth without any of the damage-region
13
- or clipping machinery a UI toolkit would normally need.
13
+ bookkeeping a UI toolkit would normally need.
14
14
 
15
15
  ## Components invalidate; they never paint the terminal
16
16
 
@@ -44,21 +44,37 @@ than an error.
44
44
 
45
45
  ## When a component does paint, it paints into a buffer
46
46
 
47
- Eventually the screen does call a component's {Tuile::Component#repaint}.
48
- Even then, the component does not write to the terminal. It writes styled
49
- cells into the screen's **back buffer** — a {Tuile::Buffer}, an in-memory
50
- grid of styled cells mirroring the terminal — through three methods:
47
+ Eventually the screen does call a component's {Tuile::Component#repaint},
48
+ handing it a {Tuile::Canvas}. Even then, the component does not write to
49
+ the terminal. It writes styled cells through the canvas, which puts them
50
+ in the screen's **back buffer** — a {Tuile::Buffer}, an in-memory grid of
51
+ styled cells mirroring the terminal. Three methods:
51
52
 
52
53
  ```ruby
53
- screen.buffer.set_text(x, y, styled_string) # a run of text
54
- screen.buffer.set_char(x, y, grapheme, style) # one cell
55
- screen.buffer.fill(rect, style) # a blank region
54
+ def repaint(canvas)
55
+ canvas.set_text(0, 0, styled_string) # a run of text
56
+ canvas.set_char(4, 1, grapheme, style) # one cell
57
+ canvas.fill(local_rect) # a blank region
58
+ end
56
59
  ```
57
60
 
58
61
  That's the entire painting vocabulary. A component's `repaint` computes
59
- what its rectangle should look like and stamps it into the buffer. No
62
+ what its rectangle should look like and stamps it through the canvas. No
60
63
  cursor moves, no color escapes, no `print` — just cells into a grid.
61
64
 
65
+ Note the coordinates: `(0, 0)` is the component's **own** top-left, not
66
+ the screen's. The canvas arrives positioned at the component's rectangle
67
+ and offsets every write, so a `repaint` never mentions where on screen it
68
+ sits — which is why `canvas.fill(local_rect)` above, and not
69
+ `canvas.fill(rect)`.
70
+
71
+ Those are the component's *own* coordinates, and they are the only ones
72
+ it deals in. Its `rect` is measured inside its parent (chapter 3), a
73
+ mouse event arrives counted from its corner (chapter 5), and the cursor
74
+ position it reports is counted the same way. Nothing a component writes
75
+ names where it sits on the terminal — and when you genuinely need that,
76
+ `absolute_rect` and `to_screen` sum the offsets for you.
77
+
62
78
  Keeping the buffer between the component and the terminal is what unlocks
63
79
  everything in the rest of this chapter, so it's worth saying plainly: the
64
80
  buffer *is* the seam. Components produce a desired grid state; the screen
@@ -80,15 +96,15 @@ invalidated set and draws it in a specific order:
80
96
  than fighting whatever was there before.
81
97
  2. **Popups last, on top.** Any popups (chapter 7) repaint after the tiled
82
98
  layer, in stacking order, so they overdraw the content beneath them.
83
- There is no clipping and no "punch a hole in the content" step —
84
- popups simply draw over what's below. If a tiled repaint touched cells
85
- a popup also covers, the whole popup stack is reasserted on top so it
86
- stays visually in front.
99
+ No layer clips another, and there is no "punch a hole in the content"
100
+ step — popups simply draw over what's below. If a tiled repaint
101
+ touched cells a popup also covers, the whole popup stack is reasserted
102
+ on top so it stays visually in front.
87
103
 
88
104
  Notice what's *absent*: no region tracking, no dirty-rectangle geometry,
89
- no z-buffer, no clip stack. The order is just "tree order, then popups,"
90
- and the correctness comes from painting in that order into a buffer that
91
- sorts out the rest.
105
+ no z-buffer, no clip stack to push and pop. The order is just "tree
106
+ order, then popups," and the correctness comes from painting in that
107
+ order into a buffer that sorts out the rest.
92
108
 
93
109
  ## Overdraw is free; the wire is minimal
94
110
 
@@ -123,9 +139,21 @@ All of this rests on one rule every component must follow:
123
139
  > A component paints every cell it's responsible for, and never a cell
124
140
  > outside its `rect`.
125
141
 
126
- The "never outside" half keeps siblings from corrupting each other —
127
- there's no clipping to save you, so drawing out of bounds means drawing
128
- on someone else's cells. The "every cell it's responsible for" half is
142
+ The "never outside" half keeps siblings from corrupting each other, and
143
+ Tuile enforces it rather than trusting you to get it right. {Tuile::Screen}
144
+ bounds every component by its own `rect` and by every ancestor's, so a
145
+ write past yours is dropped before it reaches the screen. A bug here
146
+ therefore shows up as *your* widget looking truncated, never as someone
147
+ else's cells going strange — which is much harder to trace back to
148
+ whoever caused it.
149
+
150
+ That bound is also what lets a parent hand out a rectangle it doesn't
151
+ intend to show in full. A scrolling viewport gives its forty-row child
152
+ all forty rows and shows five; the child paints normally, knowing
153
+ nothing about it, and the thirty-five that don't fit go nowhere. It
154
+ changes nothing about the rule you follow here.
155
+
156
+ The "every cell it's responsible for" half is
129
157
  what keeps stale pixels from surviving: if your rectangle used to show
130
158
  "Loading…" and now shows nothing, the cells that held the old text have
131
159
  to be actively overwritten (with blanks), or they'd linger.
data/book/03-layout.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # 3. Layout: the parent sets the size
2
2
 
3
- In chapter 1 every component gained a `rect` — its absolute position
4
- and size on the screen. In chapter 2 we saw that a component is
5
- responsible for painting every cell of that `rect` and nothing outside
6
- it. This chapter answers the question those two left open: **who decides
7
- what a component's `rect` is?**
3
+ In chapter 1 every component gained a `rect` — its position and size
4
+ *inside its parent*. In chapter 2 we saw that a component is responsible
5
+ for painting every cell of that rectangle and nothing outside it. This
6
+ chapter answers the question those two left open: **who decides what a
7
+ component's `rect` is?**
8
8
 
9
9
  The answer is a single rule, and the rest of the chapter is about why
10
10
  that one rule is enough:
@@ -28,12 +28,20 @@ Every container positions its children by computing their rectangles
28
28
  from its own. A two-pane split is arithmetic:
29
29
 
30
30
  ```ruby
31
- left.rect = Tuile::Rect.new(rect.left, rect.top, rect.width / 2, rect.height)
32
- right.rect = Tuile::Rect.new(rect.left + rect.width / 2, rect.top,
33
- rect.width - rect.width / 2, rect.height)
31
+ half = width / 2
32
+ left.rect = Tuile::Rect.new(0, 0, half, height)
33
+ right.rect = Tuile::Rect.new(half, 0, width - half, height)
34
34
  ```
35
35
 
36
- Notice there is no negotiation. `left` does not announce a desired
36
+ Notice what is *absent*: this container never mentions where it sits.
37
+ A child's rect is measured from the parent's own top-left, so `(0, 0)`
38
+ is "my corner," not the terminal's — the same coordinates chapter 2's
39
+ `repaint` paints in. Move the container and its whole subtree moves with
40
+ it, no arithmetic re-run. When you do need to know where something
41
+ landed on screen — to hang an overlay off a field, say — you ask:
42
+ `field.absolute_rect` sums the offsets up the parent chain for you.
43
+
44
+ Notice also there is no negotiation. `left` does not announce a desired
37
45
  width that the parent then reconciles against `right`'s desired width.
38
46
  The parent simply *decides*, and the two children fill exactly the
39
47
  rectangles they are given. If new content arrives that is too tall for
@@ -156,17 +164,18 @@ So staying simple isn't a compromise you're tolerating. On this medium
156
164
  it is the *correct* fit, and the elaborate alternative would degrade the
157
165
  common case, debuggability, and auditability all at once.
158
166
 
159
- ## Placing children: `Layout::Absolute`
167
+ ## Placing children: subclass `Layout`
160
168
 
161
- The place you actually write layout code is a `rect=` override. The base
162
- class for this is `Tuile::Component::Layout::Absolute`: it inherits all
163
- the focus, key-dispatch and mouse-routing wiring, paints nothing itself,
164
- and asks only that you position your children whenever your own rectangle
165
- is assigned —
166
- which happens once at startup and again on every resize.
169
+ The place you actually write layout code is a `relayout` override. The
170
+ base class for this is `Tuile::Component::Layout`: it inherits
171
+ all the focus, key-dispatch and mouse-routing wiring, paints nothing
172
+ itself, and asks only that you position your children. The framework
173
+ calls `relayout` whenever anything that feeds your arithmetic changed —
174
+ your own rectangle, a child added, removed or hidden — which covers
175
+ startup, every resize, and every mutation in between.
167
176
 
168
177
  ```ruby
169
- class SplitPane < Tuile::Component::Layout::Absolute
178
+ class SplitPane < Tuile::Component::Layout
170
179
  def initialize
171
180
  super
172
181
  @sidebar = Tuile::Component::List.new
@@ -175,14 +184,14 @@ class SplitPane < Tuile::Component::Layout::Absolute
175
184
  add(@main)
176
185
  end
177
186
 
178
- def rect=(new_rect)
179
- super
187
+ protected
188
+
189
+ def relayout
180
190
  # 40 / 60 split — resolved to exact integers, remainder assigned
181
191
  # explicitly to the right pane so no column is ever lost.
182
- left_w = rect.width * 4 / 10
183
- @sidebar.rect = Tuile::Rect.new(rect.left, rect.top, left_w, rect.height)
184
- @main.rect = Tuile::Rect.new(rect.left + left_w, rect.top,
185
- rect.width - left_w, rect.height)
192
+ left_w = width * 4 / 10
193
+ @sidebar.rect = Tuile::Rect.new(0, 0, left_w, height)
194
+ @main.rect = Tuile::Rect.new(left_w, 0, width - left_w, height)
186
195
  end
187
196
  end
188
197
  ```
@@ -198,27 +207,58 @@ method — collapse the sidebar below some width, give the main pane
198
207
  everything:
199
208
 
200
209
  ```ruby
201
- def rect=(new_rect)
202
- super
203
- if rect.width < 60
204
- @sidebar.rect = Tuile::Rect.new(0, 0, 0, 0) # hidden
205
- @main.rect = rect
210
+ def relayout
211
+ if width < 60
212
+ @sidebar.rect = Tuile::Rect.new(0, 0, 0, 0) # collapsed
213
+ @main.rect = local_rect
206
214
  else
207
- left_w = rect.width * 4 / 10
215
+ left_w = width * 4 / 10
208
216
  # …as above
209
217
  end
210
218
  end
211
219
  ```
212
220
 
221
+ Note `local_rect` rather than `rect`: a rectangle is measured *inside*
222
+ its parent, so a container divides its own rectangle moved to the origin
223
+ and never adds its own position back in.
224
+
225
+ One more rule, and it is the whole of the deferral story: **`relayout`
226
+ is never called from inside the mutation that needs it.** Mutating marks
227
+ the container, and the framework runs the pass once, at the end of the
228
+ event that did the mutating. So your `relayout` always sees a container
229
+ whose own bookkeeping is finished, twenty `add`s cost one pass, and you
230
+ may write your own mutators in whatever order reads best. The one thing
231
+ it costs: a rectangle read in the *same* turn that dirtied it is still
232
+ the old one. If you need it now — a spec, or a container assembled
233
+ before any screen exists — call `flush_layout`:
234
+
235
+ ```ruby
236
+ form.add(field)
237
+ form.flush_layout
238
+ field.rect # assigned, rather than whatever it had before
239
+ ```
240
+
213
241
  That's the whole "responsive" story: plain Ruby, recomputed on a
214
242
  discrete resize event. No breakpoint DSL, no media queries — just the
215
243
  arithmetic you'd write anyway.
216
244
 
245
+ When the rectangles don't depend on the container's size at all, there
246
+ is nothing to compute, and `Layout::Absolute` holds a fixed `Rect` per
247
+ child instead. Moving a child is `constrain`, not a write to its `rect`,
248
+ because the layout's `relayout` is still what assigns it:
249
+
250
+ ```ruby
251
+ board = Tuile::Component::Layout::Absolute.new
252
+ board.add(title, Tuile::Rect.new(0, 0, 40, 1))
253
+ board.add(body, Tuile::Rect.new(0, 2, 40, 10))
254
+ board.constrain(body, Tuile::Rect.new(0, 2, 40, 20))
255
+ ```
256
+
217
257
  ## Stacks without the arithmetic: `Vertical` and `Horizontal`
218
258
 
219
- `Absolute` is the right tool for genuinely two-dimensional geometry, and
220
- tedious for the most common shape in any app: a stack. So Tuile ships two
221
- *box* layouts that do that arithmetic for you. You declare what extent each
259
+ A hand-written `relayout` is the right tool for genuinely two-dimensional
260
+ geometry, and tedious for the most common shape in any app: a stack. So
261
+ Tuile ships two *box* layouts that do that arithmetic for you. You declare what extent each
222
262
  child should get, and the box hands down rectangles through the very same
223
263
  `rect=`:
224
264
 
@@ -311,7 +351,7 @@ equal `Expand`s in 12 rows get `3, 3, 2, 2, 2` — never `2, 2, 2, 2, 4`, which
311
351
  is what "give the leftover to the last one" produces. On a character grid a
312
352
  doubled pane is plainly visible, so spare cells are spread rather than dumped.
313
353
  One wrinkle, since this chapter showed you the hand-written version first: the
314
- two-pane `Absolute` example above gives the odd column to the *right* pane,
354
+ two-pane `SplitPane` example above gives the odd column to the *right* pane,
315
355
  while two `Expand[1]` children give it to the *left*. Both are deterministic;
316
356
  they're just different code.
317
357
 
@@ -337,23 +377,32 @@ form.add(pair, Fixed[2]) # blank row around the p
337
377
  That *states* the grouping instead of faking it with a per-child gap — boxes
338
378
  within boxes, which is how the rest of Tuile composes anyway.
339
379
 
340
- ### When to stay with `Absolute`
380
+ ### Capping a proportion
341
381
 
342
- The boxes are sugar, not a replacement, and they can't say everything. A **cap
343
- on a proportion** is the case to recognise:
382
+ "A third of the width, but never more than 16 columns" is a proportion with a
383
+ bound, and a `Percent` takes one with `clamp` — the same `Range` that
384
+ `Integer#clamp` takes:
344
385
 
345
386
  ```ruby
346
- group_width = [16, rect.width / 3].min # a third, but never more than 16
347
- list_width = (rect.width / 3).clamp(20, 40) # a third, but never <20 or >40
387
+ row.add(sidebar, Percent[33].clamp(..16)) # a third, but never more than 16
388
+ row.add(list, Percent[33].clamp(20..40)) # a third, but never <20 or >40
389
+ row.add(log, Expand[1]) # takes what the cap gave up
348
390
  ```
349
391
 
350
- The first is in `examples/sampler.rb` twice — the sidebar in its CheckboxGroup
351
- pane and the one in its List pane — and both keep a `rect=` override. That's
352
- the intended division of labour rather than a gap to work around: use a box for
353
- the stack, drop to `Absolute` for the region that genuinely needs arithmetic —
354
- usually nesting one inside the other, so only the awkward part carries any. The
355
- sampler does exactly that, and porting it to these layouts took it from 59
356
- hand-written rectangles down to a handful (5 today).
392
+ The cells a cap gives up are simply unassigned, so an `Expand` beside it picks
393
+ them up. A floor is best-effort: when the box runs out, it still starves
394
+ children in declaration order, clamped or not. Only a `Percent` clamps: a
395
+ `Fixed` is already exact, and an `Expand`'s share depends on its siblings, so a
396
+ cap would have to hand cells back to them — a whole-group calculation rather
397
+ than a bound on one child.
398
+
399
+ ### When to keep your own `relayout`
400
+
401
+ The boxes are sugar, not a replacement. Anything that isn't a stack — a child
402
+ overlapping another, a position computed from something other than the space
403
+ available — belongs in a `Layout` subclass. Nest it inside a box so only the
404
+ awkward region carries any arithmetic. `examples/sampler.rb` started with 59
405
+ hand-written rectangles; ported to boxes and clamps, it has none left.
357
406
 
358
407
  ## Geometry: `Point`, `Size`, `Rect`
359
408
 
@@ -498,8 +547,8 @@ once.
498
547
 
499
548
  You've already seen the mechanism without the plumbing: when the
500
549
  terminal resizes, the framework reassigns rectangles from the root down,
501
- and your `rect=` override recomputes its children. That's the *only*
502
- thing you do to be resize-aware — recompute in `rect=`. Do **not**
550
+ and your `relayout` override recomputes its children. That's the *only*
551
+ thing you do to be resize-aware — recompute in `relayout`. Do **not**
503
552
  install your own `SIGWINCH` handler; only one handler can win and the
504
553
  framework owns it. Chapter 4 covers how the resize event travels through
505
554
  the event queue and why it's handled there rather than off the signal.
@@ -528,5 +577,5 @@ sizes automatically, it's on the road back to the constraint solver.
528
577
 
529
578
  Note that the box layouts above are not an exception to any of this. They
530
579
  compute rectangles *for* you, but they compute them from constraints you
531
- supplied, and they hand them down through the same `rect=`. No child is ever
532
- consulted.
580
+ supplied, and they hand them down through the same `relayout`. No child is
581
+ ever consulted.
@@ -128,8 +128,9 @@ loop thread might be reading it mid-repaint.
128
128
 
129
129
  Note the division of error handling. A block you `submit` runs *inside*
130
130
  the loop, so if it raises, the exception flows through the loop's error
131
- path — {Tuile::Screen#on_error}, which by default re-raises and tears the
132
- app down loudly (unhandled exceptions are bugs; surface them). But a raise
131
+ path — {Tuile::Screen#on_error}, which while *empty* re-raises and tears the
132
+ app down loudly (unhandled exceptions are bugs; surface them); register a
133
+ listener there and it takes the error instead. But a raise
133
134
  in your background thread *before* `submit` — in the `slow_http_fetch`
134
135
  itself — is yours to catch; it's your thread, and Tuile never sees it.
135
136
  Wrap the slow work in your own `rescue` and `submit` an error display if
@@ -188,28 +189,28 @@ class Spinner < Tuile::Component::Label
188
189
 
189
190
  protected
190
191
 
191
- def on_attached
192
+ def handle_attached
192
193
  @ticker = screen.event_queue.tick_fps(8) { |n| self.text = FRAMES[n % FRAMES.size] }
193
194
  end
194
195
 
195
- def on_detached
196
+ def handle_detached
196
197
  @ticker&.cancel
197
198
  @ticker = nil
198
199
  end
199
200
  end
200
201
  ```
201
202
 
202
- `on_attached` fires the moment this component's tree is mounted on the
203
- screen; `on_detached` fires the moment it's unmounted. Add the spinner to a
203
+ `handle_attached` fires the moment this component's tree is mounted on the
204
+ screen; `handle_detached` fires the moment it's unmounted. Add the spinner to a
204
205
  popup and it starts; close the popup and it stops. Nothing at the call site
205
206
  remembers anything — `popup.close` is the whole teardown.
206
207
 
207
- The contract is a mirror: **`on_attached` starts what `on_detached` stops.**
208
+ The contract is a mirror: **`handle_attached` starts what `handle_detached` stops.**
208
209
  Keep both cheap and idempotent, because a component *moved* from one parent
209
- to another gets `on_detached` and then `on_attached` — between those two
210
+ to another gets `handle_detached` and then `handle_attached` — between those two
210
211
  calls it genuinely is off the screen, possibly for a long time, so stopping
211
212
  and restarting is the honest thing to do. And whatever you acquire in
212
- `on_attached` you must release in `on_detached`, because nothing else will.
213
+ `handle_attached` you must release in `handle_detached`, because nothing else will.
213
214
 
214
215
  This generalizes well beyond tickers, and the interesting case is
215
216
  subscriptions. A component may depend on a service, but a service must never
@@ -226,11 +227,11 @@ class BuildStatus < Tuile::Component::Label
226
227
 
227
228
  protected
228
229
 
229
- def on_attached
230
+ def handle_attached
230
231
  @subscription = @service.on_change { |s| screen.event_queue.submit { self.text = s } }
231
232
  end
232
233
 
233
- def on_detached
234
+ def handle_detached
234
235
  @subscription&.unsubscribe
235
236
  @subscription = nil
236
237
  end
@@ -245,16 +246,16 @@ screen, and no view-closing code path has to know that the subscription
245
246
  exists at all.
246
247
 
247
248
  `screen.close` counts as unmounting, so the `screen.close` at the end of
248
- your `main` gives every component still on screen its `on_detached` — the
249
+ your `main` gives every component still on screen its `handle_detached` — the
249
250
  tickers stop, the subscriptions come off, and you didn't write any of that
250
251
  teardown. What *doesn't* fire is a process that exits without closing the
251
252
  screen at all: these are lifecycle hooks, not destructors, and Tuile
252
- installs no `at_exit`. If your `on_detached` does something that matters
253
+ installs no `at_exit`. If your `handle_detached` does something that matters
253
254
  beyond the process — flushing a file, say — close the screen deliberately
254
255
  rather than relying on exit.
255
256
 
256
257
  The other thing the hooks are not is a place to do layout. When
257
- `on_attached` runs, your parent hasn't assigned your `rect` yet. If you need
258
+ `handle_attached` runs, your parent hasn't assigned your `rect` yet. If you need
258
259
  to paint, invalidate here and do the work in `repaint`, which is what
259
260
  chapter 2 was about anyway.
260
261
 
@@ -271,8 +272,8 @@ the loop thread — where re-laying-out the tree (chapter 3) is safe.
271
272
  This is why chapter 3 told you never to install your own `SIGWINCH`
272
273
  handler: only one handler can win, and the framework's owns it. You react
273
274
  to resize the normal way — recompute your children's rectangles in your
274
- `rect=` override — and the framework calls it for you when the resize
275
- event is processed.
275
+ `relayout` override — and the framework calls it for you when the resize
276
+ event has been processed.
276
277
 
277
278
  One consequence worth knowing: the screen's size is valid *before* the
278
279
  first resize ever happens. `Screen.instance.size` is seeded at