tuile 0.16.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 (92) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +108 -0
  3. data/README.md +21 -12
  4. data/book/02-repaint.md +47 -19
  5. data/book/03-layout.md +98 -49
  6. data/book/04-event-loop.md +5 -4
  7. data/book/05-focus.md +12 -9
  8. data/book/06-theming.md +55 -17
  9. data/book/07-components.md +188 -40
  10. data/book/08-testing.md +115 -15
  11. data/book/10-locale.md +1 -1
  12. data/book/README.md +5 -5
  13. data/examples/file_commander.rb +38 -27
  14. data/examples/hello_world.rb +1 -1
  15. data/examples/sampler.rb +225 -169
  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 +81 -80
  21. data/lib/tuile/component/abstract_wrapping_field.rb +59 -54
  22. data/lib/tuile/component/big_decimal_field.rb +7 -6
  23. data/lib/tuile/component/button.rb +19 -11
  24. data/lib/tuile/component/checkbox.rb +12 -10
  25. data/lib/tuile/component/checkbox_group.rb +11 -13
  26. data/lib/tuile/component/combo_box.rb +30 -40
  27. data/lib/tuile/component/confirm_window.rb +27 -22
  28. data/lib/tuile/component/date_field.rb +27 -20
  29. data/lib/tuile/component/date_time_field.rb +75 -31
  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 +98 -27
  35. data/lib/tuile/component/has_caption.rb +14 -5
  36. data/lib/tuile/component/has_content.rb +5 -12
  37. data/lib/tuile/component/has_validation.rb +39 -13
  38. data/lib/tuile/component/has_value.rb +70 -16
  39. data/lib/tuile/component/integer_field.rb +7 -6
  40. data/lib/tuile/component/label.rb +8 -15
  41. data/lib/tuile/component/layout/absolute.rb +86 -0
  42. data/lib/tuile/component/layout/box.rb +38 -63
  43. data/lib/tuile/component/layout.rb +124 -10
  44. data/lib/tuile/component/list.rb +197 -94
  45. data/lib/tuile/component/list_dropdown.rb +148 -88
  46. data/lib/tuile/component/menu_bar/cascade.rb +97 -27
  47. data/lib/tuile/component/menu_bar.rb +84 -64
  48. data/lib/tuile/component/notification.rb +44 -31
  49. data/lib/tuile/component/overlay.rb +210 -52
  50. data/lib/tuile/component/password_field.rb +1 -8
  51. data/lib/tuile/component/picker_window.rb +15 -10
  52. data/lib/tuile/component/popup.rb +13 -24
  53. data/lib/tuile/component/progress_bar.rb +7 -7
  54. data/lib/tuile/component/radio_group.rb +10 -12
  55. data/lib/tuile/component/scroller.rb +266 -0
  56. data/lib/tuile/component/select.rb +15 -31
  57. data/lib/tuile/component/slot.rb +1 -2
  58. data/lib/tuile/component/tab_sheet.rb +21 -28
  59. data/lib/tuile/component/tabs.rb +39 -24
  60. data/lib/tuile/component/text_area/wrapped_text.rb +1 -1
  61. data/lib/tuile/component/text_area.rb +21 -19
  62. data/lib/tuile/component/text_field.rb +55 -39
  63. data/lib/tuile/component/text_view.rb +143 -79
  64. data/lib/tuile/component/time_field.rb +26 -21
  65. data/lib/tuile/component/vertical_scroll_bar.rb +257 -0
  66. data/lib/tuile/component/window.rb +27 -26
  67. data/lib/tuile/component.rb +481 -259
  68. data/lib/tuile/component_background.rb +177 -0
  69. data/lib/tuile/component_util.rb +43 -0
  70. data/lib/tuile/event.rb +29 -0
  71. data/lib/tuile/event_queue.rb +14 -0
  72. data/lib/tuile/fake_screen.rb +41 -10
  73. data/lib/tuile/keys.rb +15 -6
  74. data/lib/tuile/layout_pass.rb +180 -0
  75. data/lib/tuile/listeners.rb +219 -0
  76. data/lib/tuile/mouse/router.rb +51 -35
  77. data/lib/tuile/mouse.rb +96 -29
  78. data/lib/tuile/point.rb +6 -0
  79. data/lib/tuile/rect.rb +33 -0
  80. data/lib/tuile/screen.rb +419 -84
  81. data/lib/tuile/screen_pane.rb +144 -31
  82. data/lib/tuile/strict_layout.rb +127 -0
  83. data/lib/tuile/styled_string.rb +139 -9
  84. data/lib/tuile/testing/gestures.rb +35 -0
  85. data/lib/tuile/testing.rb +310 -36
  86. data/lib/tuile/theme.rb +170 -19
  87. data/lib/tuile/theme_def.rb +4 -0
  88. data/lib/tuile/version.rb +1 -1
  89. data/lib/tuile.rb +53 -0
  90. data/sig/tuile.rbs +4951 -1158
  91. metadata +16 -2
  92. data/lib/tuile/vertical_scroll_bar.rb +0 -122
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 442d863affeb579ca0fdc7d99531c0d255a9c6129c82c09e0a91ebbb1404e651
4
- data.tar.gz: a502cb86f7c2c9fc0c871699838c223b6076ec98e9099ef1771f8e0f3ef973f1
3
+ metadata.gz: dc03ee431fa704f276ff043efc3b49ebe17b1389d0432dea94a04dc65f96edbd
4
+ data.tar.gz: 992e0dfe0455101fd53f5b3c80c478db1e945aaf031f8724da6e2c3bbe3bf4fe
5
5
  SHA512:
6
- metadata.gz: cc042252f3248883da046ad9328417fe7bc609314b1633a56751bbf62608c0c5e1142dd943bd33027b2d1da0661dadea4306442db8d8ea800bd96a5e882765f5
7
- data.tar.gz: '09e3aecaedcf12cfdb0da848fc92baec87c393c990a927f58f7c2796c19734032fc7fe8ab2f3678b9433c98579dbc6c1953e08040b7316c75c353e49a9434f15'
6
+ metadata.gz: c52068efb9cce15a110f390082af422270044930e21662bca95ecbf7fdb454d1951cc2860825f8146d226f601303e097e27a4159e5b6848572407b326d7493b8
7
+ data.tar.gz: 4cf57cf18348f0be0a29695ba4216c2ad13afa0c6c787eff1c64c1be7fe0b3317870b53f1222ccd30e991061c75c005afb26bbb39d6bb423e7556368ea800541
data/CHANGELOG.md CHANGED
@@ -1,5 +1,113 @@
1
+ <!-- An entry is one sentence — `Add` / `Fix` / `**Breaking:**`, the symbol, what changed, ≈40
2
+ words; a trailing See `D_<slug>` doesn't count, and a breaking entry earns a second sentence
3
+ for the migration only. Group `Add`, then `Fix`, then `**Breaking:**`; a themed release may
4
+ carry a ≤3-sentence preamble under its version heading, once. -->
5
+
1
6
  ## [Unreleased]
2
7
 
8
+ ## [0.17.0] - 2026-09-23
9
+
10
+ - Add derived theme tokens — any `Theme` token may be a Proc of the terminal background (and optionally a resolver reading sibling tokens), which the screen re-derives into a `Color` whenever the theme or `Screen#background_color` changes, walking the tree once. See `design/decisions.md` `D_derived_tokens`.
11
+ - Add `Theme#resolve(background)` and `Theme#derived?` — the resolution the screen runs, for reading a derived token outside a screen; reading one on an unresolved theme raises `Tuile::Error`.
12
+ - Add `Component::Fill` — paints one glyph into every cell of its rect in a `color` slot taking a live `Theme.ref`, so a one-column `Fill.new("│")` is a vertical rule between borderless panes, sized by its parent and following theme changes on its own. See `design/decisions.md` `D_color_slots`.
13
+ - Add `StyledString.validate_glyph(char, name)` — the check every glyph knob runs at assignment, raising unless `char` is one grapheme cluster one column wide.
14
+ - Add `Color#rgb` — the `[r, g, b]` behind an RGB color or a palette index 16..255, for app color math such as a contrast check; `nil` for the 16 named colors, in either spelling, whose look the terminal's scheme decides.
15
+ - Add `Component::Layout::Percent#clamp(range)` — bounds a `Box` child's share with an inclusive `Range` of cells, so `Percent[50].clamp(..60)` says "half the width, but never more than 60 columns", on either axis. See `design/decisions.md` `D_box_layouts`.
16
+ - Add `Tuile::ComponentBackground` — a component's background chain, reached through the protected `Component#bg`: `bg.default_color=` states a widget's own well, `ComponentBackground::INPUT_WELL` being the input one, and `bg.effective` / `bg.ambient` answer what to paint. See `design/decisions.md` `D_bg_surface`.
17
+ - Add `StyledString#ellipsize`'s `at:` — `:start` keeps the *tail* and prepends the ellipsis, for text whose end identifies it and whose head is context (`…/shared/markdown/`); `:end` stays the default.
18
+ - Add `StyledString#under_fg` — the foreground counterpart of `#under_bg`: sets `fg` only on spans that carry none, skipping `inverse` spans, so a caption takes a default tone while a part the app colored itself keeps its own. See [#63](https://github.com/mvysny/tuile/issues/63).
19
+ - Add `StyledString#ljust`, `#rjust` and `#center` — pad with unstyled fill to a *display* width, never truncating, so `s.ellipsize(w).ljust(w)` is exactly `w` columns; a custom pad must be one cluster one column wide.
20
+ - Add `Component::Scroller#scrollbar_visibility = :auto` — the bar shows only while `content_rows` exceeds the viewport, and the content gets the full width back while it fits. See [#62](https://github.com/mvysny/tuile/issues/62).
21
+ - Add `Component::TextView#scrollbar_visibility = :auto` — the bar shows only while the text, wrapped at the full width, overflows the viewport, rewrapping two columns narrower when it appears and back when it goes. See [#62](https://github.com/mvysny/tuile/issues/62).
22
+ - Add `Component::List#scrollbar_visibility = :auto` — the bar shows only while the items outnumber the rows and hands its column back while they fit, so a borderless pane shows no idle track yet still signals overflow; `ListDropdown` now uses it. See [#62](https://github.com/mvysny/tuile/issues/62).
23
+ - Add `Component::List#interactive=` — `false` makes a display-only list, neither focusable nor a tab stop, whose rows a press no longer chooses, for a summary pane beside the field the user types in; the wheel still scrolls it. See [#64](https://github.com/mvysny/tuile/issues/64).
24
+ - Add `Tuile.strict_layout` — the stale-rect diagnostic: under `:raise` or `:warn`, a `Component#rect` read the app makes while an ancestor owes a `relayout` names that ancestor and the read site instead of answering the previous pass's rectangle; off by default outside a `FakeScreen`. See `design/decisions.md` `D_strict_layout`.
25
+ - Add `Tuile.without_strict_layout` — runs a block with the diagnostic off, for a read that is pre-settle on purpose, and restores whatever was in force, default included.
26
+ - Add `Testing.place(component, rect)` — moves a component within the parent it already has, through whatever places it there, since `rect=` raises outside the parent's `relayout`: a parentless root in a throwaway holder, an `Absolute` child by its constraint, an open overlay by `At[rect]`; it never attaches or re-parents.
27
+ - Add `Testing.paint(component)` — paints a component and its subtree, attached or not, into a `Buffer` of its own whose `(0, 0)` is the component's top-left; ancestors don't clip it and popups over it don't show.
28
+ - Add `Buffer#text` — every row's plain text, `region_text` over the whole buffer, so `Testing.paint(window).text` is the whole paint.
29
+ - Add `Screen#canvas_for(component, backend:, root:)` and `Screen#clip_for(component, root:)` — a canvas onto another backend, positioned and bounded within an ancestor rather than the screen.
30
+ - Add `Component#rect_stale?` — whether `rect` is the previous pass's, because an ancestor owes a `relayout`; `Component#layout_dirty?`, which answers the same question about a container's *children*, is public now.
31
+ - Add `Component#handle_rect_changed(old_rect)` — the hook a component reacts to a new rect in, since `rect=` can no longer be overridden.
32
+ - Add `FakeScreen#resize_terminal(width, height)` — the terminal reporting a new size, routed as its own report would be.
33
+ - Add `Screen.fake(width:, height:)` — the terminal size a spec starts at, 160×50 unless given, so a narrow or short terminal is a `before` hook rather than a resize.
34
+ - Add `Component::VerticalScrollBar` — a one-column bar in the tree that the user drags, and presses the track of to page a viewport; it moves nothing itself, firing `on_scroll_request` for its owner to assign. `Scroller` now holds one. See `design/decisions.md` `D_draggable_scrollbar`.
35
+ - Add `Component::FormItem` — the chrome around one field: a `caption` row carrying an optional `required:` marker, the field, and the message it reports against itself — a verdict, or input it cannot parse — mirrored into the last row, which doubles as the gap so nothing reflows when a field goes invalid. See `design/decisions.md` `D_form_item`.
36
+ - Add `Component::FormLayout` — the column that stacks `FormItem`s: `add(field, caption:, required:, rows:)` wraps the field and returns the item, captions above, no `spacing` because the message row is the gap, and overflow clipped rather than scrolled. See `design/decisions.md` `D_form_layout`.
37
+ - Add a *FormLayout* pane to `examples/sampler.rb` under Shell — first name, surname and date of birth as captioned items, where each name field's own `on_value_change` writes `error_message`, so the message row fills and empties as you type and nothing below it moves.
38
+ - Add a *Scroller* pane to `examples/sampler.rb` under Shell — an eight-field `FormLayout` in a ten-row `Scroller`, each field validated non-empty, so Tab walks focus past the bottom edge and the view follows, message rows included.
39
+ - Add `Component::FormItem#scroll_to_visible` — a field scrolled into view brings its caption and message row along; when the item is taller than the viewport, the field's own request wins.
40
+ - Add `Tuile::Listeners` — a listener slot holding many callables instead of one, registered through the reader (`button.on_click { save }`) and removed with the expression that added them; there is no setter and no `clear`, so a claim can never be silently replaced. See `design/decisions.md` `D_listeners`.
41
+ - Add `Tuile::Listeners::Declare` — the `listener :on_foo` macro a class or module extends in, building the slot lazily on first read and taking an optional block fired on the empty↔non-empty transition.
42
+ - Add `Component::HasBadInput#on_bad_input_change` — the notice for whoever has cells to paint the message in: it fires the *showable* report, `bad_input_message` gated by `bad_input_settled?` and diffed, so a field announces once rather than per keystroke. See `design/decisions.md` `D_bad_input`.
43
+ - Add `Component::HasValidation#shown_message` — the one place the two error channels' precedence is stated: the field's own bad-input report when it is showable, else the verdict a validator wrote. See `design/decisions.md` `D_has_validation`.
44
+ - Add `Component::HasBadInput#wears_bad_input_ink?` — the protected hook a composite answers `false` from while a child holds the fault, so the well reddens where it happened; `bad_input_settled?` is public now, gating the notice as well as the ink.
45
+ - Add `Tuile::Event` — the marker every event includes, now carried by all five `Mouse` events and all seven `EventQueue` ones, so `is_a?(Tuile::Event)` spans the three namespaces; it mandates no members and supplies no defaults.
46
+ - Add `Tuile::Canvas` — the frozen paint context handed to `Component#repaint`, carrying the background it applies to every write; derive one for a span with `with(bg_color:) { |canvas| … }`, which raises without a block. See `design/decisions.md` `D_canvas`.
47
+ - Add `Tuile::Canvas#origin` — where a canvas's `(0, 0)` lands in its backend, set by `Screen#canvas_for` to where the component sits on screen and carried through `with(bg_color:)`, so a `repaint` paints in its own coordinates. See `design/decisions.md` `D_canvas`.
48
+ - Add `Component#local_rect` / `#local_extent_rect` — the component's rect and extent at `(0, 0)`: the region argument `canvas.fill` takes, and the space its children's rects are measured in.
49
+ - Add `Rect#moved_by(point)` — `#at`'s relative counterpart, the same size shifted, for converting a rectangle between two coordinate spaces one offset apart.
50
+ - Add `Component#absolute_rect` / `#absolute_extent_rect` / `#to_screen(point)` / `#to_local(point)` — the named ways out of a component's own coordinates into the screen's and back, summing every ancestor's offset, derived per call and never cached. See `design/decisions.md` `D_relative_rect`.
51
+ - Add `Point::ZERO` — the frozen `(0, 0)`, named for the value rather than a role: every coordinate space has an origin, and `Canvas#origin` is a different point entirely.
52
+ - Add `Tuile::Canvas::Backend` — the mixin naming where a canvas's cells land (`set_text` / `set_char` / `fill`, fully resolved styles). `Tuile::Buffer` includes it unadapted, so `Screen#canvas` is a `Canvas` over the back buffer and `Screen#canvas_for(component)` derives one carrying that component's background.
53
+ - Add `Testing.click` / `Testing.set_value` — gestures that drive a located component only where a user could have: `click` routes a real press at the cell it paints, `set_value` refuses a field outside the key scope and moves no focus.
54
+ - Add `Testing::Gestures` — a refinement giving those receiver syntax (`button._click`, `field._value = 25`), activated with `using Tuile::Testing::Gestures` per file or per `describe`; nothing is added to `Component` itself.
55
+ - Add `ScreenPane#key_scope` — the root keys bubble to and Tab cycles within (the topmost modal popup, else `content`), extracted from the three places that computed it.
56
+ - Add `Screen#clip_for` — the cells a component may write, its own `rect` intersected with every ancestor's: a write past the rect a component was given is dropped rather than landing on a neighbour, and a parent may hand out a rect it will not show in full. No component declares or widens it. See `design/decisions.md` `D_clip`.
57
+ - Add `Tuile::Canvas#clip` — the backend-space region a canvas may write to, built by `Screen#canvas_for` from `clip_for` and carried through `with(bg_color:)`; a row crossing an edge is sliced, a glyph the edge falls inside blanked rather than split, and a caret outside it hides the cursor. See `design/decisions.md` `D_clip`.
58
+ - Add `Component::Scroller` — a viewport onto one taller content child: `content_rows=` says how tall the content is (nothing measures), the wheel and every focus change scroll it, and a `VerticalScrollBar` takes the right-hand column. It claims no keys. See `design/decisions.md` `D_scroller`.
59
+ - Add `Component#scroll_to_visible(rect = local_extent_rect)` — a component's "show me this" request, climbing the parent chain re-expressed a level at a time to whatever scrolls above it; `Screen#focused=` makes it between `handle_focus` and `on_focus_changed`, so Tab brings a scrolled-out child into view. A hidden component, or a request reaching a hidden ancestor, raises.
60
+ - Add a warning to `Screen#focused=`: a target whose `clip_for` is still empty after `scroll_to_visible` — a stale `Scroller#content_rows`, a `Fixed[0]` collapse — logs to `Tuile.logger` rather than silently taking keys the user cannot see.
61
+ - Add `Rect#intersect(other)` — the region both cover, half-open like `#contains?` and yielding an empty rectangle rather than `nil` when they are disjoint, so a chain of clips folds with no nil test per level.
62
+ - Add `Component#flush_layout` — the force-now that runs every pending `relayout` in a subtree, for reading a rect in the same turn that dirtied it and for a tree assembled before any `Screen` exists. See `design/decisions.md` `D_deferred_layout`.
63
+ - Add `Component::AbstractStringField#escape_clears_focus` — the named opt-out for the field's own ESC blur, `true` by default and the whole representation of it, so giving ESC another meaning no longer means removing a pre-registered listener by identity and discarding the answer. See `design/decisions.md` `D_escape_opt_out`.
64
+ - Add `HasValue::ValueChangeEvent#from_user?` and `HasValue#set_value(value, from_user:)` — every value change says whether the user made it (typing, a paste, a key, a click, a pick, `Testing.set_value`) or code did (`value=`), and an app writing on the user's behalf declares `true`. See [#61](https://github.com/mvysny/tuile/issues/61), `design/decisions.md` `D_from_user`.
65
+ - Fix `Component#invalidate_layout` to drop a mark made during the container's own `relayout` before it places a child, so hiding or adding a child there first no longer costs a second pass or raises a false stale-rect error on a freshly built tree. See `design/decisions.md` `D_deferred_layout`.
66
+ - Fix `Component#rect_stale?`, and so `Tuile.strict_layout`, missing a rect the running `relayout` had not placed yet — a sibling read from a `handle_rect_changed` or `handle_focus` fired mid-pass now reports as stale. See `design/decisions.md` `D_strict_layout`.
67
+ - Fix `Screen#focused=` inside a `relayout` — a focused child hidden there, a menu closed from `handle_rect_changed` — scrolling and notifying against rects the pass had not placed yet: `scroll_to_visible` and `on_focus_changed` now wait for the drain, and `flush_layout` raises inside a pass. See `design/decisions.md` `D_deferred_layout`.
68
+ - Fix `ScreenPane` stranding its content and popups at their last rects when the terminal shrinks to nothing: the content gets the empty pane rect and every popup collapses, both restored on the next resize. See `design/decisions.md` `D_empty_ancestor`.
69
+ - Fix `Screen#flush_layout` and `Component#flush_layout` hanging the UI thread on a layout that never settles — they raise `Tuile::Error` after 50 rounds, naming the containers still marking, and `Screen#close` still unmounts such a tree. See `design/decisions.md` `D_deferred_layout`.
70
+ - Fix `Component::Layout::Absolute` leaving a moved child's old cells painted: `Component#rect=` now invalidates the parent too, so every container blanks what a child vacated without re-invalidating itself in its `relayout`.
71
+ - Fix `Screen#flush_layout` running a container twice when it was marked while already waiting in the current drain round.
72
+ - Fix `Component::List#auto_scroll` ignoring a wheel notch or a PgUp: both bypassed `scroll_top_row=` and left `following?` armed, so the next incoming row yanked the viewport back to the tail.
73
+ - Fix `Component::List`'s and `Component::TextView`'s scrollbars being ink you could not touch: a visible one is a `Component::VerticalScrollBar` child now, so its handle drags (wanting `run_event_loop(capture_mouse: :drag)`) and its track pages, and it sits up to a row from where the old painter drew it. See `design/decisions.md` `D_draggable_scrollbar`.
74
+ - Fix a left press on a `Component::List`'s scrollbar column choosing the item behind it: the bar claims that column now and scrolls instead.
75
+ - Fix `Component::MenuBar` drilling into a submenu you only arrowed past: a menu stepped to sideways now opens with no row highlighted, so Right steps on and Down, Enter or Space moves in (Up onto the last row). See `design/decisions.md` `D_menu_bar`.
76
+ - Fix the sideways walk dying on a top-level item with no menu: the bar now keeps *menu mode* across it, so the next Right opens its neighbour's menu, and ESC there leaves the mode rather than reaching the app's quit key. See `design/decisions.md` `D_menu_bar`.
77
+ - Fix a mouse click past column 223 being dead: `Screen#run_event_loop` also requests the SGR encoding (mode 1006), whose coordinates are uncapped, and `Mouse.parse` reads both wire forms — a terminal ignoring the request keeps sending X10. See `design/research.md` `R_mouse_reporting`.
78
+ - Fix `Component::List`, `Component::TextView` and `Component::TextArea` ignoring a height-only resize: an `auto_scroll` list or view re-pins to the bottom as it grows taller, and a text area keeps its caret in view as it shrinks.
79
+ - **Breaking:** `Component::List#renderer` is now called as `(item, text_width) -> row` — the columns the row body gets, so a row can align a right-hand column down the pane or elide a path from the left, re-rendered whenever that width changes. Give every renderer a second parameter (`->(u) { … }` becomes `->(u, _w) { … }`); a one-argument callable raises when a row is rendered. See `design/decisions.md` `D_list_items`.
80
+ - **Breaking:** a `FakeScreen` now defaults `Tuile.strict_layout` to `:raise`, so a spec that reads a rect it has not settled raises instead of asserting against the previous pass's rectangle. Settle it (`component.flush_layout`), or wrap a deliberately unsettled read in `Tuile.without_strict_layout { … }`; `Tuile.strict_layout = false` opts a whole suite out.
81
+ - **Breaking:** an overlay's placement must now include `Component::Overlay::Placement` and answer `rect_for(overlay, screen_size, anchor_rect)`, checked where the app hands it over rather than failing as a `NoMethodError` inside the pane a settle later. Add the `include` and the third parameter; a placement hanging off a widget answers `anchor` with the component (or a screen `Rect`, or `nil` for none) and the pane resolves it.
82
+ - **Breaking:** `Component::Overlay` and its subclasses now raise unless they are adopted as one of `ScreenPane#popups` — `Overlay#open` is the only door, and the pane's `content` slot is refused too. Replace `layout.add(overlay)` / `screen.content = overlay` with `overlay.open(placement)`.
83
+ - **Breaking:** `Component#default_bg_color` is no longer an override hook — a widget states its well once at construction with `bg.default_color = …`, taking a `Color`, a `Theme::Ref` or a state Hash. Replace `def default_bg_color = active? ? screen.theme.active_bg_color : screen.theme.input_bg_color` with `bg.default_color = ComponentBackground::INPUT_WELL` in `initialize`, and hand any other theme color over as a `Theme.ref`.
84
+ - **Breaking:** `Component::BG_INHERIT` and `Component::BG_STATES` are now `ComponentBackground::INHERIT` and `ComponentBackground::STATES`, and the protected `Component#effective_bg_color` is `bg.effective`. Rename the references; `bg_color` / `bg_color=` and the `error_bg_color` hook are unchanged.
85
+ - **Breaking:** `Component#rect=` is protected and raises unless the component's parent is running its `relayout` — a component with no parent included — so a rect is only ever assigned by what places it. Move a child through its parent (`Layout::Absolute#constrain`, `Layout::Box#constrain`, `Overlay#placement=`), size a detached tree by holding it in a `Layout::Absolute`, and move a `rect=` override's body into `handle_rect_changed`.
86
+ - **Breaking:** An overlay opens with a placement and `ScreenPane`'s layout pass assigns its rect, again on every resize — `Overlay#open(Overlay::At[rect])`, `Overlay::Centered[]` (a `Popup`'s default) or `Overlay::TopRight[]` (a `Notification`'s), and `Overlay#placement=` moves an open one. Replace `overlay.rect = r; overlay.open` with `overlay.open(Overlay::At[r])`; `Popup#center` is gone, and `Overlay#reposition` now asks the pane to place the overlay again rather than being an override point — override `declared_size_in` for a derived size.
87
+ - **Breaking:** `ListDropdown#anchor_to` and `#anchor_beside` take no `rows:` (the item count is read live), accept the driver component itself as the anchor, which the dropdown then follows as it moves, and open the dropdown when it is closed. Replace `drop.open; drop.anchor_to(field.absolute_rect, rows: n)` with `drop.anchor_to(field)`.
88
+ - **Breaking:** `Component::Layout::Absolute` now places each child at a fixed `Rect` — `add(child, rect)`, `constrain(child, rect)` to move it — and the base to subclass with your own `relayout` is `Component::Layout`. Change `class Foo < Layout::Absolute` to `< Layout`, and a bare `Absolute` whose children you sized by writing their `rect` to `add(child, rect)`.
89
+ - **Breaking:** `Component#relayout` is the sole place a container assigns its children's rects, replacing the `rect=` override, `HasContent#layout(content)` and `ScreenPane#layout`. Move the body of your `rect=` override into a protected zero-arg `relayout` dividing `local_rect`, and drop the `super`. See `design/decisions.md` `D_relayout`.
90
+ - **Breaking:** Layout is deferred — a mutation marks, and `Screen#dispatch` settles after each event — so a rect read in the same turn that dirtied it is stale, detached trees included. Call `Component#flush_layout` where you need one now. See `design/decisions.md` `D_deferred_layout`.
91
+ - **Breaking:** `Component#handle_child_visibility_changed` is gone; `visible=` now marks *and* invalidates the parent for every container, so a `relayout` override already covers what the hook was overridden for. Delete the override. See `design/decisions.md` `D_relayout`.
92
+ - **Breaking:** `Testing::LookupError` is gone; every lookup and gesture raises `Testing::AssertionError`, an `Exception` rather than a `Tuile::Error`, since an assertion is not production's error and nobody should catch one. Rename it in `assert_raises`, and replace a `rescue Tuile::Error` around a lookup with the class itself.
93
+ - **Breaking:** `Testing.find` / `.get` no longer take `caption:` — lookup handles are structural (a class, an `id`, a subtree), never what a component shows. Replace `get(Button, caption: "Save")` with an `id:`, or with the block that already did this: `get(Component::Button) { _1.caption.to_s == "Save" }`. See `design/decisions.md` `D_component_lookup`.
94
+ - **Breaking:** Every listener slot is a list with no setter — `on_foo=` is gone and the reader registers. Write `button.on_click { save }` for `button.on_click = -> { save }`, `field.on_change << cb` for `field.on_change = cb`, and `field.on_change.remove(cb)` to unsubscribe. See `design/decisions.md` `D_listeners`.
95
+ - **Breaking:** A slot fires one `Tuile::Event` rather than positional arguments, so `->(index, item) { … }` becomes `{ |e| … e.index, e.item }`. A listener taking no arguments is unaffected; each slot's rdoc names its event class, and one needing two arguments now raises at registration.
96
+ - **Breaking:** `Screen#on_error` ships no default listener — an *empty* slot is what re-raises. Only code that read or called the old re-raising `Proc` needs changing; registering a listener still takes the error over.
97
+ - **Breaking:** `ListDropdown#on_item_chosen=` and `#on_cursor_changed=` are gone, along with `AbstractWrappingField#on_enter=`. Reach the dropdown's rows through the new `ListDropdown#list` (`drop.list.on_item_chosen { … }`); `AbstractWrappingField#on_enter` is now an ordinary slot that still commits before your listener runs.
98
+ - **Breaking:** `AbstractStringField#on_escape` is an append-only list and `#default_on_escape` is gone, the blur it performed having become the `escape_clears_focus` flag. Write `field.escape_clears_focus = false` where you wrote `field.on_escape = nil`, and put the same line ahead of an `on_escape <<` that is meant to replace the blur rather than follow it.
99
+ - **Breaking:** `Component#repaint` takes a required `Tuile::Canvas`, and `#draw_text` / `#draw_char` / `#clear_background` are gone — the canvas applies the background. An override becomes `def repaint(canvas)` painting through `canvas.set_text` / `canvas.set_char` / `canvas.fill(area)`, `super` forwards the canvas unchanged, and `screen.canvas_for(component)` builds one for painting a single component.
100
+ - **Breaking:** the protected `Component#clear_inside_extent` and `#clear_outside_extent` hooks take the `Tuile::Canvas` too. Change an override to `def clear_inside_extent(canvas)` and paint through the canvas it is handed.
101
+ - **Breaking:** A canvas paints in the component's own coordinates — `(0, 0)` is its `rect`'s top-left, not the screen's. Drop the offset from every paint call (`canvas.set_text(rect.left + x, rect.top + y, …)` becomes `canvas.set_text(x, y, …)`) and pass `local_rect` where you passed `rect`.
102
+ - **Breaking:** `Component#rect` is measured **inside its parent**, so a container adds no position of its own. `child.rect = Rect.new(rect.left + x, rect.top + y, w, h)` becomes `Rect.new(x, y, w, h)`, "fill me" is `local_rect`, and anything wanting the screen — a dropdown's `anchor_to`, a spec reading the buffer — asks for `absolute_rect`. See `design/decisions.md` `D_relative_rect`.
103
+ - **Breaking:** A `Mouse::Event` reaches a component in that component's own coordinates, and `Component#cursor_position` answers in them. Drop the `event.x - rect.left` and `rect.top +` from every mouse handler and cursor position; `Screen#cursor_position` still reports screen coordinates, and `FakeScreen#click` / `#drag` still take them.
104
+ - **Breaking:** `Component#extent_rect` is gone — it was the parent-space form, and nothing asks in that space now. Use `local_extent_rect` (hit-testing, clearing) or `absolute_extent_rect` (anchoring an overlay).
105
+ - **Breaking:** `Tuile::VerticalScrollBar`, the geometry-only painter, is gone — the name now belongs to `Component::VerticalScrollBar`, the draggable bar in the tree, which also owns the two app-global glyphs. Move the knob: `Tuile::Component::VerticalScrollBar.handle_char = "▐"`.
106
+ - **Breaking:** `Component::AbstractStringField#text=` is removed — `value=` was the same write, and `text` stays as the reader. Replace `field.text = s` with `field.value = s` on a `TextField`, `PasswordField` or `TextArea`; `Label#text=` and `TextView#text=` are unaffected. See `design/decisions.md` `D_has_value`.
107
+ - **Breaking:** `Component::AbstractStringField#on_change` and its `ChangeEvent` are removed — `on_value_change` fired alongside it for every change. Subscribe with `field.on_value_change { |e| … e.value … }` in place of `field.on_change { |e| … e.text … }`. See `design/decisions.md` `D_has_value`.
108
+ - **Breaking:** `Component#handle_width_changed` is gone — `rect=` marks the component itself, so its `relayout` already runs after every change on either axis. Move the override's body into `relayout` (keying anything costly on the width it was built at), or into `handle_rect_changed(old_rect)` for a reaction to the change itself. See `design/decisions.md` `D_relayout`.
109
+ - **Breaking:** a `HasValue` includer's override point is `set_value(new_value, from_user:)`, never `value=`, which the contract suite now fails. Rename `def value=(v)` to `def set_value(v, from_user:)` and pass `from_user:` on to `super` or the editor's `set_value`.
110
+
3
111
  ## [0.16.0] - 2026-09-18
4
112
 
5
113
  0.16.0 settles two vocabularies. Every override point is now `handle_foo` and
data/README.md CHANGED
@@ -110,13 +110,14 @@ write escape sequences. They call `invalidate`, and paint styled cells into a
110
110
  back buffer when the loop asks them to; one flush per tick emits the
111
111
  **minimal diff** — only the cells that actually changed — inside a
112
112
  synchronized-output batch. There is no damage tracking to maintain and no
113
- clipping to think about: popups simply overdraw, because overdraw into a
114
- 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)
115
116
 
116
117
  **Layout is top-down, and that is the whole model.** A parent computes its
117
118
  children's rectangles in plain Ruby and assigns them; a component never
118
119
  advertises a size it would like. No `min`/`preferred`/`max`, no negotiation
119
- pass, no shrink-to-fit. Subclass `Layout::Absolute` when the arithmetic is
120
+ pass, no shrink-to-fit. Subclass `Layout` when the arithmetic is
120
121
  yours, or use `Layout::Vertical` / `Layout::Horizontal` to declare each
121
122
  child's extent as `Fixed` / `Percent` / `Expand`.
122
123
  → [chapter 3](book/03-layout.md)
@@ -184,8 +185,9 @@ carries the per-method reference: `bundle exec rake yard`, or
184
185
 
185
186
  | component | what it is |
186
187
  |---|---|
187
- | `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. |
188
- | `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. |
189
191
 
190
192
  ### Framing and switching — [book ch7](book/07-components.md#framing-content)
191
193
 
@@ -193,6 +195,10 @@ carries the per-method reference: `bundle exec rake yard`, or
193
195
  |---|---|
194
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. |
195
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. |
196
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). |
197
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`. |
198
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). |
@@ -204,6 +210,7 @@ carries the per-method reference: `bundle exec rake yard`, or
204
210
  | `Label` | Static text, one row per line, no wrapping — long lines are ellipsized. Content is a `StyledString`, so ANSI passes through. |
205
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. |
206
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. |
207
214
 
208
215
  ### Editing text — [book ch7](book/07-components.md#editing-text)
209
216
 
@@ -229,12 +236,12 @@ carries the per-method reference: `bundle exec rake yard`, or
229
236
  | component | what it is |
230
237
  |---|---|
231
238
  | `Checkbox` | A one-row boolean: `[x]` / `[ ]` plus a caption, toggled by Space, Enter or a click on the glyph or label. |
232
- | `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. |
233
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. |
234
241
  | `CheckboxGroup` | Multi-select over the same shape; its `value` is a frozen `Set` of the checked items. |
235
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. |
236
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. |
237
- | `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. |
238
245
 
239
246
  ### Taking an action — [book ch7](book/07-components.md#taking-an-action)
240
247
 
@@ -246,8 +253,8 @@ carries the per-method reference: `bundle exec rake yard`, or
246
253
 
247
254
  | component | what it is |
248
255
  |---|---|
249
- | `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. |
250
- | `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. |
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. |
251
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. |
252
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). |
253
260
  | `InfoWindow` | A `Window` with a read-only body, tiled or popped up: prose that wraps (`message=`), or rows that don't (`lines=`). |
@@ -306,10 +313,12 @@ module Tuile
306
313
 
307
314
  it "renders text into its rect" do
308
315
  label = Component::Label.new
309
- label.rect = Rect.new(0, 0, 5, 1)
310
316
  label.text = "hi"
311
- label.repaint
312
- 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)
313
322
  end
314
323
  end
315
324
  end
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
@@ -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