tuile 0.15.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +229 -80
- data/README.md +49 -24
- data/book/02-repaint.md +47 -19
- data/book/03-layout.md +98 -49
- data/book/04-event-loop.md +17 -16
- data/book/05-focus.md +106 -34
- data/book/06-theming.md +108 -38
- data/book/07-components.md +249 -46
- data/book/08-testing.md +134 -32
- data/book/10-locale.md +3 -3
- data/book/README.md +11 -10
- data/examples/file_commander.rb +52 -32
- data/examples/hello_world.rb +18 -5
- data/examples/sampler.rb +576 -146
- 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 +96 -97
- data/lib/tuile/component/abstract_wrapping_field.rb +99 -58
- data/lib/tuile/component/big_decimal_field.rb +7 -6
- data/lib/tuile/component/button.rb +27 -19
- data/lib/tuile/component/checkbox.rb +21 -19
- data/lib/tuile/component/checkbox_group.rb +17 -18
- data/lib/tuile/component/combo_box.rb +69 -64
- data/lib/tuile/component/confirm_window.rb +34 -27
- data/lib/tuile/component/date_field.rb +50 -18
- data/lib/tuile/component/date_time_field.rb +319 -0
- 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 +99 -28
- data/lib/tuile/component/has_caption.rb +14 -5
- data/lib/tuile/component/has_content.rb +8 -15
- data/lib/tuile/component/has_placeholder.rb +1 -1
- data/lib/tuile/component/has_validation.rb +40 -14
- data/lib/tuile/component/has_value.rb +71 -17
- 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 -60
- data/lib/tuile/component/layout.rb +127 -13
- data/lib/tuile/component/list.rb +233 -120
- data/lib/tuile/component/list_dropdown.rb +151 -91
- data/lib/tuile/component/menu_bar/cascade.rb +102 -32
- data/lib/tuile/component/menu_bar.rb +102 -82
- data/lib/tuile/component/notification.rb +76 -49
- data/lib/tuile/component/overlay.rb +217 -58
- data/lib/tuile/component/password_field.rb +1 -8
- data/lib/tuile/component/picker_window.rb +41 -17
- data/lib/tuile/component/popup.rb +15 -26
- data/lib/tuile/component/progress_bar.rb +17 -11
- data/lib/tuile/component/radio_group.rb +16 -17
- data/lib/tuile/component/scroller.rb +266 -0
- data/lib/tuile/component/select.rb +26 -43
- data/lib/tuile/component/slot.rb +4 -5
- data/lib/tuile/component/tab_sheet.rb +27 -34
- data/lib/tuile/component/tabs.rb +49 -34
- data/lib/tuile/component/text_area/wrapped_text.rb +1 -1
- data/lib/tuile/component/text_area.rb +32 -28
- data/lib/tuile/component/text_field.rb +68 -50
- data/lib/tuile/component/text_view.rb +157 -89
- data/lib/tuile/component/time_field.rb +51 -21
- data/lib/tuile/component/vertical_scroll_bar.rb +257 -0
- data/lib/tuile/component/window.rb +27 -26
- data/lib/tuile/component.rb +653 -323
- 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 +18 -4
- data/lib/tuile/fake_event_queue.rb +1 -1
- data/lib/tuile/fake_screen.rb +120 -7
- 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 +233 -0
- data/lib/tuile/mouse.rb +244 -0
- data/lib/tuile/point.rb +6 -0
- data/lib/tuile/rect.rb +33 -0
- data/lib/tuile/screen.rb +510 -138
- data/lib/tuile/screen_pane.rb +185 -67
- data/lib/tuile/strict_layout.rb +127 -0
- data/lib/tuile/styled_string.rb +144 -14
- data/lib/tuile/testing/gestures.rb +35 -0
- data/lib/tuile/testing.rb +316 -42
- data/lib/tuile/theme.rb +192 -53
- 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 +6084 -1507
- metadata +19 -17
- data/COMPARISON.md +0 -101
- data/DECISIONS.md +0 -8562
- data/TERMINOLOGY.md +0 -85
- data/ideas/arrow-key-navigation.md +0 -221
- data/ideas/binder.md +0 -177
- data/ideas/composite-field.md +0 -77
- data/ideas/focus-accent.md +0 -116
- data/ideas/form-layout.md +0 -151
- data/ideas/hover/probe.rb +0 -241
- data/ideas/hover/probe_spec.rb +0 -82
- data/ideas/hover.md +0 -909
- data/ideas/modal-backdrop.md +0 -24
- data/ideas/new-components.md +0 -144
- data/ideas/per-component-buffers.md +0 -55
- data/lib/tuile/mouse_event.rb +0 -68
- data/lib/tuile/vertical_scroll_bar.rb +0 -122
data/README.md
CHANGED
|
@@ -12,10 +12,12 @@ providers — is described in
|
|
|
12
12
|
Tuile is that approach applied to a terminal.
|
|
13
13
|
|
|
14
14
|
Tuile is the only actively maintained component-oriented TUI framework for
|
|
15
|
-
Ruby that we are aware of.
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
Ruby that we are aware of. Ruby does now reach two of the big non-Ruby
|
|
16
|
+
toolkits — Rust's ratatui through `ratatui_ruby`, Go's Charm stack through
|
|
17
|
+
CharmRuby — but both hand you a draw loop or an Elm-style model/update/view,
|
|
18
|
+
not a tree of components. [The research notes](design/research.md) size up each
|
|
19
|
+
neighbour, those two included, alongside tty-toolkit, vedeu and the curses
|
|
20
|
+
bindings your distro packages.
|
|
19
21
|
|
|
20
22
|
## Installation
|
|
21
23
|
|
|
@@ -55,8 +57,8 @@ else in Tuile loads it.
|
|
|
55
57
|
- **API reference:** every public class and method carries YARD headers —
|
|
56
58
|
browse them at <https://rubydoc.info/gems/tuile>, or run
|
|
57
59
|
`bundle exec rake yard` for a local site.
|
|
58
|
-
- **[
|
|
59
|
-
toolkits, and
|
|
60
|
+
- **[The research notes](design/research.md)** size up the neighbouring
|
|
61
|
+
toolkits, and answer what a Ruby program can reach without writing
|
|
60
62
|
bindings first.
|
|
61
63
|
|
|
62
64
|
## Hello world
|
|
@@ -108,13 +110,14 @@ write escape sequences. They call `invalidate`, and paint styled cells into a
|
|
|
108
110
|
back buffer when the loop asks them to; one flush per tick emits the
|
|
109
111
|
**minimal diff** — only the cells that actually changed — inside a
|
|
110
112
|
synchronized-output batch. There is no damage tracking to maintain and no
|
|
111
|
-
clipping to
|
|
112
|
-
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)
|
|
113
116
|
|
|
114
117
|
**Layout is top-down, and that is the whole model.** A parent computes its
|
|
115
118
|
children's rectangles in plain Ruby and assigns them; a component never
|
|
116
119
|
advertises a size it would like. No `min`/`preferred`/`max`, no negotiation
|
|
117
|
-
pass, no shrink-to-fit. Subclass `Layout
|
|
120
|
+
pass, no shrink-to-fit. Subclass `Layout` when the arithmetic is
|
|
118
121
|
yours, or use `Layout::Vertical` / `Layout::Horizontal` to declare each
|
|
119
122
|
child's extent as `Fixed` / `Percent` / `Expand`.
|
|
120
123
|
→ [chapter 3](book/03-layout.md)
|
|
@@ -137,9 +140,20 @@ between panes. A paste is deliberately *not* a burst of keys: with bracketed
|
|
|
137
140
|
paste it arrives whole, as one `handle_paste`.
|
|
138
141
|
→ [chapter 5](book/05-focus.md)
|
|
139
142
|
|
|
143
|
+
**The mouse is routed by position, and claimed by one component.** A press
|
|
144
|
+
focuses the innermost focusable under the pointer before any handler runs,
|
|
145
|
+
then bubbles outward through `handle_mouse_down?` until someone answers
|
|
146
|
+
`true` — and that claimant is *grabbed*, so the drags and the release reach
|
|
147
|
+
it wherever the pointer goes. The wheel bubbles the same way, so a list
|
|
148
|
+
already at its top hands the notch up to whatever scrolls around it. `capture_mouse:` picks how
|
|
149
|
+
much the terminal reports: `:clicks`, `:drag`, or `:hover` with enter/exit
|
|
150
|
+
hooks.
|
|
151
|
+
→ [chapter 5](book/05-focus.md)
|
|
152
|
+
|
|
140
153
|
**Theming is accents-only, and follows the OS.** A `Theme` carries semantic
|
|
141
|
-
accent tokens — the list cursor, an input
|
|
142
|
-
|
|
154
|
+
accent tokens for the chrome Tuile itself paints — the list cursor, an input
|
|
155
|
+
well, an active window border, a scrollbar — plus whatever `custom` tokens your
|
|
156
|
+
app adds for text of its own (a status row's shades live there). Everything
|
|
143
157
|
else inherits the terminal's own foreground and background, so Tuile looks at
|
|
144
158
|
home in the user's palette instead of fighting it. Tuile probes the terminal
|
|
145
159
|
background at startup, pairs a dark and a light theme in a `ThemeDef`, and
|
|
@@ -171,8 +185,9 @@ carries the per-method reference: `bundle exec rake yard`, or
|
|
|
171
185
|
|
|
172
186
|
| component | what it is |
|
|
173
187
|
|---|---|
|
|
174
|
-
| `Layout
|
|
175
|
-
| `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. |
|
|
176
191
|
|
|
177
192
|
### Framing and switching — [book ch7](book/07-components.md#framing-content)
|
|
178
193
|
|
|
@@ -180,6 +195,10 @@ carries the per-method reference: `bundle exec rake yard`, or
|
|
|
180
195
|
|---|---|
|
|
181
196
|
| `Window` | A frame with a `caption`, one content slot, and a border that lights up while the window is on the focus chain. `footer_text=` decorates the bottom border; `footer=` mounts a real component in it; `scrollbar=` reclaims the right border column. |
|
|
182
197
|
| `Slot` | A one-child region for content that may be absent, arrive late, or be swapped. Give a multi-region container one per region and the tree stays honest — the occupant fills the slot's rect, and an empty slot holds its place rather than collapsing. |
|
|
198
|
+
| `Scroller` | A viewport onto a taller pile of components: one content child, a scrollbar column, and `content_rows` — you say how tall the content is, since nothing measures. The wheel scrolls it, the bar drags, and so does focus: Tab into a field below the fold and it comes into view. |
|
|
199
|
+
| `VerticalScrollBar` | A one-column bar the user can drag, and press the track of to page. It moves nothing itself: it asks through `on_scroll_request` and the container that owns the column assigns `scroll_top_row`. Dragging wants `run_event_loop(capture_mouse: :drag)`. |
|
|
200
|
+
| `FormItem` | One row of a form: a `caption` above a field, an optional required marker beside it, and the message the field reports against itself — a validator's verdict, or input it cannot parse — mirrored into the row below. Three rows that never reflow — the message row doubles as the gap. Hide the item, not the field. |
|
|
201
|
+
| `FormLayout` | A column of `FormItem`s: `add(field, caption:, required:, rows:)` wraps the field, returns the item and stacks it below the last. No `spacing` — the message row is the gap — nothing measures, and items past the bottom edge are clipped rather than scrolled. |
|
|
183
202
|
| `MenuBar` | A one-row strip of menu captions, each dropping a cascade of submenus that nests without limit. Items are handles from `#add_item`, each with its own `on_click`. See [Menus](book/07-components.md#menus). |
|
|
184
203
|
| `Tabs` | A one-row strip of captions with one selected, Left/Right switching immediately. Knows nothing about content — pair it with `TabSheet`, or drive your own view swap from `on_tab_selected`. |
|
|
185
204
|
| `TabSheet` | A `Tabs` strip plus the pane belonging to the selected tab. Unselected panes are *detached*, so they keep their state and stay out of the Tab cycle. See [Switching between views](book/07-components.md#switching-between-views). |
|
|
@@ -191,6 +210,7 @@ carries the per-method reference: `bundle exec rake yard`, or
|
|
|
191
210
|
| `Label` | Static text, one row per line, no wrapping — long lines are ellipsized. Content is a `StyledString`, so ANSI passes through. |
|
|
192
211
|
| `TextView` | A read-only viewer for prose: word-wrapped, scrollable, appendable, and addressable in named `Region`s when you want to rewrite one part of the text in place. |
|
|
193
212
|
| `ProgressBar` | A one-row bar — `█` over a `░` track — driven by `value` within a `Range`, or `indeterminate` for a bouncing sweep that owns its own ticker while on screen. |
|
|
213
|
+
| `Fill` | One glyph in every cell of its rect, in a `color` that may be a live `Theme.ref`. A one-column `Fill` of `│` is a vertical rule between borderless panes, a one-row `─` a horizontal one. |
|
|
194
214
|
|
|
195
215
|
### Editing text — [book ch7](book/07-components.md#editing-text)
|
|
196
216
|
|
|
@@ -209,18 +229,19 @@ carries the per-method reference: `bundle exec rake yard`, or
|
|
|
209
229
|
| `BigDecimalField` | The same for money, where a binary `Float` is the wrong answer. Tuile's one optional dependency — add `bigdecimal` yourself if you name this component. |
|
|
210
230
|
| `DateField` | A one-row field whose `value` is a `Date` or `nil`, over a list of strftime formats taken from `Screen#locale`: it accepts any of them and writes the first one back when you leave the field. Manual entry — there is no calendar popup yet. |
|
|
211
231
|
| `TimeField` | A one-row field whose `value` is a time of day — a `Time` on a fixed epoch date, or `nil` — spelled the way `Screen#locale` says. `step` is both the Up/Down stride and the precision: it shows seconds only when set below a minute. |
|
|
232
|
+
| `DateTimeField` | The two above side by side on one row, behind a single `DateTime` at `+00:00`. Each half reddens its own bad input; the field reddens whole only for the fault no half can wear — one half filled and the other empty. |
|
|
212
233
|
|
|
213
234
|
### Choosing from a set — [book ch7](book/07-components.md#choosing-from-a-set)
|
|
214
235
|
|
|
215
236
|
| component | what it is |
|
|
216
237
|
|---|---|
|
|
217
238
|
| `Checkbox` | A one-row boolean: `[x]` / `[ ]` plus a caption, toggled by Space, Enter or a click on the glyph or label. |
|
|
218
|
-
| `List` | The workhorse: a scrollable column of *typed items*, one row each, rendered lazily by a `renderer` you supply and handing your callbacks the item itself. Add a `Cursor` (or `Cursor::Limited`) to make it navigable. |
|
|
239
|
+
| `List` | The workhorse: a scrollable column of *typed items*, one row each, rendered lazily by a `renderer` you supply and handing your callbacks the item itself. Add a `Cursor` (or `Cursor::Limited`) to make it navigable, or set `interactive = false` for a display-only pane Tab skips. |
|
|
219
240
|
| `RadioGroup` | Single-select over a set of items, one row each, with the marker painted in front of the label. Its `value` is the selected item. |
|
|
220
241
|
| `CheckboxGroup` | Multi-select over the same shape; its `value` is a frozen `Set` of the checked items. |
|
|
221
242
|
| `Select` | The enum field: a one-row face plus a `▾`, dropping open a list of options. Claims no printable key but Space, so your app's own keys keep working while it has focus. |
|
|
222
243
|
| `ComboBox` | A text field with a filtering dropdown — type to narrow, arrow to highlight, Enter to accept. Its `value` is the selected *item*, never the typed text. |
|
|
223
|
-
| `ListDropdown` | The floating, non-focusable list that `Select` and `ComboBox` drop open, and the `Menu` variant an app can drive itself. You rarely instantiate it directly. |
|
|
244
|
+
| `ListDropdown` | The floating, non-focusable list that `Select` and `ComboBox` drop open with `anchor_to(self)`, following the field as it moves, and the `Menu` variant an app can drive itself. You rarely instantiate it directly. |
|
|
224
245
|
|
|
225
246
|
### Taking an action — [book ch7](book/07-components.md#taking-an-action)
|
|
226
247
|
|
|
@@ -232,12 +253,12 @@ carries the per-method reference: `bundle exec rake yard`, or
|
|
|
232
253
|
|
|
233
254
|
| component | what it is |
|
|
234
255
|
|---|---|
|
|
235
|
-
| `Overlay` | The bare floating layer: it wraps any component, paints nothing itself, and sits
|
|
236
|
-
| `Popup` | The modal dialog: an `Overlay`
|
|
237
|
-
| `Notification` | A transient corner toast — `Notification.show("Saved")` — stacking messages in one box that a single ticker drains. Non-modal,
|
|
256
|
+
| `Overlay` | The bare floating layer: it wraps any component, paints nothing itself, and sits where its placement says — `open(Overlay::At[rect])`; the pane assigns the rect, again on every resize. Takes no focus and no keys — the building block for anchored panels and toasts. |
|
|
257
|
+
| `Popup` | The modal dialog: an `Overlay` placed centered by default, which grabs focus, scopes keys to its own subtree and blocks clicks beneath it. Sized by `declared_size=` (a `Size` or a `Fraction` of the screen) rather than by its content; ESC or `q` dismisses. |
|
|
258
|
+
| `Notification` | A transient corner toast — `Notification.show("Saved")` — stacking messages in one box that a single ticker drains. Non-modal, it never takes focus, and its inner `View` refuses the wheel so queued messages wait for the ticker. |
|
|
238
259
|
| `ConfirmWindow` | The confirm dialog: a message and a row of buttons in a popup sized to fit. `alert` / `confirm` / `yes_no` cover the common shapes; `#button` builds any other. Every button closes; ESC, `q` or an outside click fire `on_dismiss`. See [The confirm dialog](book/07-components.md#the-confirm-dialog). |
|
|
239
260
|
| `InfoWindow` | A `Window` with a read-only body, tiled or popped up: prose that wraps (`message=`), or rows that don't (`lines=`). |
|
|
240
|
-
| `PickerWindow` | A `Window` of options identified by single keystrokes, firing a callback with the key that was pressed. |
|
|
261
|
+
| `PickerWindow` | A `Window` of options identified by single keystrokes, firing a callback with the key that was pressed. Captions take a `StyledString` to color one. |
|
|
241
262
|
| `LogTextView` | An auto-scrolling `TextView` for log output. Point your logger at a `LogTextView::IO` and lines land here from any thread, marshalled through the event queue. |
|
|
242
263
|
| `LogWindow` | A `Window` framing a `LogTextView` — the framed log pane. |
|
|
243
264
|
|
|
@@ -292,10 +313,12 @@ module Tuile
|
|
|
292
313
|
|
|
293
314
|
it "renders text into its rect" do
|
|
294
315
|
label = Component::Label.new
|
|
295
|
-
label.rect = Rect.new(0, 0, 5, 1)
|
|
296
316
|
label.text = "hi"
|
|
297
|
-
|
|
298
|
-
|
|
317
|
+
holder = Component::Layout::Absolute.new
|
|
318
|
+
holder.add(label, Rect.new(0, 0, 5, 1)) # a parent places it; nothing else may
|
|
319
|
+
Screen.instance.content = holder
|
|
320
|
+
Screen.instance.repaint
|
|
321
|
+
assert_equal ["hi "], Screen.instance.buffer.region_text(label.absolute_rect)
|
|
299
322
|
end
|
|
300
323
|
end
|
|
301
324
|
end
|
|
@@ -342,14 +365,16 @@ run `bundle exec rake spec` to run the tests. You can also run `bin/console`
|
|
|
342
365
|
for an interactive prompt that will allow you to experiment.
|
|
343
366
|
|
|
344
367
|
To install this gem onto your local machine, run `bundle exec rake install`.
|
|
345
|
-
To release a new version, see [`
|
|
368
|
+
To release a new version, see [`design/releasing.md`](design/releasing.md).
|
|
346
369
|
|
|
347
370
|
## Contributing
|
|
348
371
|
|
|
349
372
|
Bug reports and pull requests are welcome on GitHub at
|
|
350
373
|
<https://github.com/mvysny/tuile>. Please read [`AGENTS.md`](AGENTS.md) before
|
|
351
374
|
opening a PR — it documents the architecture invariants (singleton screen,
|
|
352
|
-
invalidation/repaint contract, threading rule) that the framework relies on
|
|
375
|
+
invalidation/repaint contract, threading rule) that the framework relies on,
|
|
376
|
+
and routes you to [`design/`](design/), where
|
|
377
|
+
[`decisions.md`](design/decisions.md) answers "why is it like this?".
|
|
353
378
|
This project is intended to be a safe, welcoming space for collaboration, and
|
|
354
379
|
contributors are expected to adhere to the
|
|
355
380
|
[code of conduct](https://github.com/mvysny/tuile/blob/master/CODE_OF_CONDUCT.md).
|
data/book/02-repaint.md
CHANGED
|
@@ -10,7 +10,7 @@ paints everything that asked to be repainted, in one flicker-free batch.
|
|
|
10
10
|
Understanding this model matters for two reasons. It's the contract every
|
|
11
11
|
custom component has to honor (paint your rectangle, don't touch the
|
|
12
12
|
wire). And it's *why* Tuile stays smooth without any of the damage-region
|
|
13
|
-
|
|
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
|
|
@@ -188,28 +189,28 @@ class Spinner < Tuile::Component::Label
|
|
|
188
189
|
|
|
189
190
|
protected
|
|
190
191
|
|
|
191
|
-
def
|
|
192
|
+
def handle_attached
|
|
192
193
|
@ticker = screen.event_queue.tick_fps(8) { |n| self.text = FRAMES[n % FRAMES.size] }
|
|
193
194
|
end
|
|
194
195
|
|
|
195
|
-
def
|
|
196
|
+
def handle_detached
|
|
196
197
|
@ticker&.cancel
|
|
197
198
|
@ticker = nil
|
|
198
199
|
end
|
|
199
200
|
end
|
|
200
201
|
```
|
|
201
202
|
|
|
202
|
-
`
|
|
203
|
-
screen; `
|
|
203
|
+
`handle_attached` fires the moment this component's tree is mounted on the
|
|
204
|
+
screen; `handle_detached` fires the moment it's unmounted. Add the spinner to a
|
|
204
205
|
popup and it starts; close the popup and it stops. Nothing at the call site
|
|
205
206
|
remembers anything — `popup.close` is the whole teardown.
|
|
206
207
|
|
|
207
|
-
The contract is a mirror: **`
|
|
208
|
+
The contract is a mirror: **`handle_attached` starts what `handle_detached` stops.**
|
|
208
209
|
Keep both cheap and idempotent, because a component *moved* from one parent
|
|
209
|
-
to another gets `
|
|
210
|
+
to another gets `handle_detached` and then `handle_attached` — between those two
|
|
210
211
|
calls it genuinely is off the screen, possibly for a long time, so stopping
|
|
211
212
|
and restarting is the honest thing to do. And whatever you acquire in
|
|
212
|
-
`
|
|
213
|
+
`handle_attached` you must release in `handle_detached`, because nothing else will.
|
|
213
214
|
|
|
214
215
|
This generalizes well beyond tickers, and the interesting case is
|
|
215
216
|
subscriptions. A component may depend on a service, but a service must never
|
|
@@ -226,11 +227,11 @@ class BuildStatus < Tuile::Component::Label
|
|
|
226
227
|
|
|
227
228
|
protected
|
|
228
229
|
|
|
229
|
-
def
|
|
230
|
+
def handle_attached
|
|
230
231
|
@subscription = @service.on_change { |s| screen.event_queue.submit { self.text = s } }
|
|
231
232
|
end
|
|
232
233
|
|
|
233
|
-
def
|
|
234
|
+
def handle_detached
|
|
234
235
|
@subscription&.unsubscribe
|
|
235
236
|
@subscription = nil
|
|
236
237
|
end
|
|
@@ -245,16 +246,16 @@ screen, and no view-closing code path has to know that the subscription
|
|
|
245
246
|
exists at all.
|
|
246
247
|
|
|
247
248
|
`screen.close` counts as unmounting, so the `screen.close` at the end of
|
|
248
|
-
your `main` gives every component still on screen its `
|
|
249
|
+
your `main` gives every component still on screen its `handle_detached` — the
|
|
249
250
|
tickers stop, the subscriptions come off, and you didn't write any of that
|
|
250
251
|
teardown. What *doesn't* fire is a process that exits without closing the
|
|
251
252
|
screen at all: these are lifecycle hooks, not destructors, and Tuile
|
|
252
|
-
installs no `at_exit`. If your `
|
|
253
|
+
installs no `at_exit`. If your `handle_detached` does something that matters
|
|
253
254
|
beyond the process — flushing a file, say — close the screen deliberately
|
|
254
255
|
rather than relying on exit.
|
|
255
256
|
|
|
256
257
|
The other thing the hooks are not is a place to do layout. When
|
|
257
|
-
`
|
|
258
|
+
`handle_attached` runs, your parent hasn't assigned your `rect` yet. If you need
|
|
258
259
|
to paint, invalidate here and do the work in `repaint`, which is what
|
|
259
260
|
chapter 2 was about anyway.
|
|
260
261
|
|
|
@@ -271,8 +272,8 @@ the loop thread — where re-laying-out the tree (chapter 3) is safe.
|
|
|
271
272
|
This is why chapter 3 told you never to install your own `SIGWINCH`
|
|
272
273
|
handler: only one handler can win, and the framework's owns it. You react
|
|
273
274
|
to resize the normal way — recompute your children's rectangles in your
|
|
274
|
-
`
|
|
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
|