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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +108 -0
- data/README.md +21 -12
- data/book/02-repaint.md +47 -19
- data/book/03-layout.md +98 -49
- data/book/04-event-loop.md +5 -4
- data/book/05-focus.md +12 -9
- data/book/06-theming.md +55 -17
- data/book/07-components.md +188 -40
- data/book/08-testing.md +115 -15
- data/book/10-locale.md +1 -1
- data/book/README.md +5 -5
- data/examples/file_commander.rb +38 -27
- data/examples/hello_world.rb +1 -1
- data/examples/sampler.rb +225 -169
- data/lib/tuile/buffer.rb +12 -1
- data/lib/tuile/canvas/backend.rb +46 -0
- data/lib/tuile/canvas.rb +212 -0
- data/lib/tuile/color.rb +38 -9
- data/lib/tuile/component/abstract_string_field.rb +81 -80
- data/lib/tuile/component/abstract_wrapping_field.rb +59 -54
- data/lib/tuile/component/big_decimal_field.rb +7 -6
- data/lib/tuile/component/button.rb +19 -11
- data/lib/tuile/component/checkbox.rb +12 -10
- data/lib/tuile/component/checkbox_group.rb +11 -13
- data/lib/tuile/component/combo_box.rb +30 -40
- data/lib/tuile/component/confirm_window.rb +27 -22
- data/lib/tuile/component/date_field.rb +27 -20
- data/lib/tuile/component/date_time_field.rb +75 -31
- data/lib/tuile/component/fill.rb +93 -0
- data/lib/tuile/component/float_field.rb +7 -6
- data/lib/tuile/component/form_item.rb +250 -0
- data/lib/tuile/component/form_layout.rb +206 -0
- data/lib/tuile/component/has_bad_input.rb +98 -27
- data/lib/tuile/component/has_caption.rb +14 -5
- data/lib/tuile/component/has_content.rb +5 -12
- data/lib/tuile/component/has_validation.rb +39 -13
- data/lib/tuile/component/has_value.rb +70 -16
- data/lib/tuile/component/integer_field.rb +7 -6
- data/lib/tuile/component/label.rb +8 -15
- data/lib/tuile/component/layout/absolute.rb +86 -0
- data/lib/tuile/component/layout/box.rb +38 -63
- data/lib/tuile/component/layout.rb +124 -10
- data/lib/tuile/component/list.rb +197 -94
- data/lib/tuile/component/list_dropdown.rb +148 -88
- data/lib/tuile/component/menu_bar/cascade.rb +97 -27
- data/lib/tuile/component/menu_bar.rb +84 -64
- data/lib/tuile/component/notification.rb +44 -31
- data/lib/tuile/component/overlay.rb +210 -52
- data/lib/tuile/component/password_field.rb +1 -8
- data/lib/tuile/component/picker_window.rb +15 -10
- data/lib/tuile/component/popup.rb +13 -24
- data/lib/tuile/component/progress_bar.rb +7 -7
- data/lib/tuile/component/radio_group.rb +10 -12
- data/lib/tuile/component/scroller.rb +266 -0
- data/lib/tuile/component/select.rb +15 -31
- data/lib/tuile/component/slot.rb +1 -2
- data/lib/tuile/component/tab_sheet.rb +21 -28
- data/lib/tuile/component/tabs.rb +39 -24
- data/lib/tuile/component/text_area/wrapped_text.rb +1 -1
- data/lib/tuile/component/text_area.rb +21 -19
- data/lib/tuile/component/text_field.rb +55 -39
- data/lib/tuile/component/text_view.rb +143 -79
- data/lib/tuile/component/time_field.rb +26 -21
- data/lib/tuile/component/vertical_scroll_bar.rb +257 -0
- data/lib/tuile/component/window.rb +27 -26
- data/lib/tuile/component.rb +481 -259
- data/lib/tuile/component_background.rb +177 -0
- data/lib/tuile/component_util.rb +43 -0
- data/lib/tuile/event.rb +29 -0
- data/lib/tuile/event_queue.rb +14 -0
- data/lib/tuile/fake_screen.rb +41 -10
- data/lib/tuile/keys.rb +15 -6
- data/lib/tuile/layout_pass.rb +180 -0
- data/lib/tuile/listeners.rb +219 -0
- data/lib/tuile/mouse/router.rb +51 -35
- data/lib/tuile/mouse.rb +96 -29
- data/lib/tuile/point.rb +6 -0
- data/lib/tuile/rect.rb +33 -0
- data/lib/tuile/screen.rb +419 -84
- data/lib/tuile/screen_pane.rb +144 -31
- data/lib/tuile/strict_layout.rb +127 -0
- data/lib/tuile/styled_string.rb +139 -9
- data/lib/tuile/testing/gestures.rb +35 -0
- data/lib/tuile/testing.rb +310 -36
- data/lib/tuile/theme.rb +170 -19
- data/lib/tuile/theme_def.rb +4 -0
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +53 -0
- data/sig/tuile.rbs +4951 -1158
- metadata +16 -2
- data/lib/tuile/vertical_scroll_bar.rb +0 -122
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dc03ee431fa704f276ff043efc3b49ebe17b1389d0432dea94a04dc65f96edbd
|
|
4
|
+
data.tar.gz: 992e0dfe0455101fd53f5b3c80c478db1e945aaf031f8724da6e2c3bbe3bf4fe
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
114
|
-
buffer is free.
|
|
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
|
|
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
|
|
188
|
-
| `Layout::
|
|
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
|
|
250
|
-
| `Popup` | The modal dialog: an `Overlay`
|
|
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
|
-
|
|
312
|
-
|
|
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
|
-
|
|
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
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
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
|
-
|
|
84
|
-
popups simply draw over what's below. If a tiled repaint
|
|
85
|
-
a popup also covers, the whole popup stack is reasserted
|
|
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
|
|
90
|
-
and the correctness comes from painting in 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
|
-
|
|
128
|
-
|
|
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
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
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
|
|
167
|
+
## Placing children: subclass `Layout`
|
|
160
168
|
|
|
161
|
-
The place you actually write layout code is a `
|
|
162
|
-
class for this is `Tuile::Component::Layout
|
|
163
|
-
the focus, key-dispatch and mouse-routing wiring, paints nothing
|
|
164
|
-
and asks only that you position your children
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
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
|
-
|
|
179
|
-
|
|
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 =
|
|
183
|
-
@sidebar.rect = Tuile::Rect.new(
|
|
184
|
-
@main.rect = Tuile::Rect.new(
|
|
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
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
@
|
|
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 =
|
|
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
|
-
`
|
|
220
|
-
tedious for the most common shape in any app: a stack. So
|
|
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 `
|
|
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
|
-
###
|
|
380
|
+
### Capping a proportion
|
|
341
381
|
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
347
|
-
|
|
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
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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 `
|
|
502
|
-
thing you do to be resize-aware — recompute in `
|
|
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 `
|
|
532
|
-
consulted.
|
|
580
|
+
supplied, and they hand them down through the same `relayout`. No child is
|
|
581
|
+
ever consulted.
|
data/book/04-event-loop.md
CHANGED
|
@@ -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
|
|
132
|
-
app down loudly (unhandled exceptions are bugs; surface them)
|
|
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
|
-
`
|
|
275
|
-
event
|
|
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
|