tuile 0.7.0 → 0.9.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 +26 -0
- data/README.md +51 -24
- data/book/01-first-app.md +186 -0
- data/book/02-repaint.md +177 -0
- data/book/03-layout.md +379 -0
- data/book/04-event-loop.md +209 -0
- data/book/05-focus.md +188 -0
- data/book/06-theming.md +250 -0
- data/book/07-components.md +231 -0
- data/book/08-testing.md +186 -0
- data/book/README.md +79 -0
- data/examples/hello_world.rb +1 -2
- data/examples/sampler.rb +109 -0
- data/lib/tuile/ansi.rb +16 -0
- data/lib/tuile/buffer.rb +481 -0
- data/lib/tuile/component/button.rb +3 -14
- data/lib/tuile/component/has_content.rb +0 -6
- data/lib/tuile/component/info_window.rb +4 -2
- data/lib/tuile/component/label.rb +15 -23
- data/lib/tuile/component/layout.rb +0 -21
- data/lib/tuile/component/list.rb +10 -37
- data/lib/tuile/component/log_window.rb +6 -5
- data/lib/tuile/component/picker_window.rb +4 -2
- data/lib/tuile/component/popup.rb +85 -55
- data/lib/tuile/component/text_area.rb +1 -1
- data/lib/tuile/component/text_field.rb +1 -1
- data/lib/tuile/component/text_input.rb +25 -9
- data/lib/tuile/component/text_view.rb +6 -30
- data/lib/tuile/component/window.rb +77 -112
- data/lib/tuile/component.rb +16 -71
- data/lib/tuile/event_queue.rb +31 -10
- data/lib/tuile/fake_event_queue.rb +21 -5
- data/lib/tuile/fake_screen.rb +14 -1
- data/lib/tuile/fraction.rb +46 -0
- data/lib/tuile/screen.rb +98 -105
- data/lib/tuile/screen_pane.rb +82 -19
- data/lib/tuile/styled_string.rb +40 -30
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +653 -282
- metadata +13 -3
- data/lib/tuile/sizing.rb +0 -59
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9cbba5366b6871e4ac6972819d1a3b03cb7b106bea1b3efc42ebf7fac27e279e
|
|
4
|
+
data.tar.gz: a72af4f77ce12a70ca0f3c477081047c81b193f4e2b8b5ca48dae3551daff524
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 883f93623cde985b9287ad548d941ee6cbca89639fbd2c19f5978581b13d8ca231731392c1d83860a34fae0124f3ccb27630581cc26f045064d3592bc45dd9da
|
|
7
|
+
data.tar.gz: 1c1c3ec8411c4368ecd6c7edd44da1681701fada1dc6a1345060c40a40ced7712dff9bb9433e90961e76b3300fc646e60a3c5755ef4acae1b759a9405732f32e
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.9.0] - 2026-07-05
|
|
4
|
+
|
|
5
|
+
Layout is now strictly top-down: a parent assigns each child's `rect`, and components no longer advertise how big they want to be. The book's chapter 3 is the long-form rationale.
|
|
6
|
+
|
|
7
|
+
- Add `Tuile::Fraction` (`HALF` / `FULL`, int-coercing, floor-at-1 `resolve`) — the one relational sizing primitive, scoped to `Popup#size=`.
|
|
8
|
+
- `Component::Popup#size=` accepts a `Size` (clamped to the screen) or a `Fraction` (resolved against the screen on every layout pass, so it tracks resize); default `Fraction::HALF`. Adds a `size:` kwarg to `Popup.new` / `Popup.open`, forwarded through `InfoWindow.open`. Popups no longer auto-size to their content.
|
|
9
|
+
- `Component::Window`: the single footer slot is split into two purpose-fit members — `footer_text=` (a `StyledString` embedded into the bottom border line at its own width with dashes filling the remainder, mirroring `caption` on top; not a component, not focusable) and `footer=` (a focusable component always spanning the full inner width of the bottom row). A `footer=` component present hides `footer_text`.
|
|
10
|
+
- Add optional `Component::Label.new(text = nil)` — constructor symmetry with `Window.new(caption)`, reusing the `text=` coercion path (`String | StyledString | nil`). Purely additive; `Label.new` still works.
|
|
11
|
+
- Add `EventQueue#tick_fps(fps)` = `tick(1.0 / fps)` for the frames-per-second animation idiom; lands on both the real and fake queues.
|
|
12
|
+
- Fix `Buffer#flush` corrupting the left neighbour of a wide glyph: a dirty continuation cell (the right half of a wide glyph) no longer opens a flush run, so the next glyph is no longer shifted onto — and blanked by — the wide glyph's right half (e.g. an emoji vanishing when an adjacent cell changed, persisting across tmux window switches).
|
|
13
|
+
- Memoize display-width measurement in `Buffer` and measure each grapheme once per paint: a full-screen 160×50 repaint drops from ~29.5ms to ~5.8ms (~5×). Adds `rake benchmark` (excluded from the packaged gem).
|
|
14
|
+
- **Breaking:** the eager bottom-up sizing channel is deleted — `Component#content_size` / `content_size=` / `on_child_content_size_changed`, and the `Component#popup_min_height` / `popup_max_height` height-advice hooks added in 0.8.0. Containers now compute their children's rectangles in plain Ruby (in a `rect=` override) and hand them down; content fills or scrolls within the rect it's given. Custom components that advertised a size must move that logic into the parent.
|
|
15
|
+
- **Breaking:** `Tuile::Sizing` (`FILL` / `WRAP_CONTENT` / `Sizing.fixed`) and `Window#footer_sizing`, both added in 0.6.0, are removed — a bottom-row `footer=` widget is always FILL by construction.
|
|
16
|
+
- **Breaking:** `EventQueue#tick(seconds)` now takes an interval in **seconds**, not frames-per-second. `tick(4)` used to mean "4 times a second"; it now means "every 4 seconds", matching `sleep` / `TimerTask` and every other Ruby scheduler. Use the new `tick_fps(fps)` for the animation mental model.
|
|
17
|
+
|
|
18
|
+
## [0.8.0] - 2026-06-11
|
|
19
|
+
|
|
20
|
+
- Render through a back buffer: `Screen#buffer` is now a `Tuile::Buffer` cell grid sized to the viewport. Components paint into it; `Screen#repaint` flushes only the cells that changed since the last frame, wrapped with the cursor move in one synchronized-output batch (DEC mode 2026). Repaint is flicker-free on **any** terminal regardless of mode-2026 support, because an unchanged cell is never rewritten — the full-scene overdraw a shrinking popup forces no longer reaches the wire. `FakeScreen` exposes the populated buffer for content assertions (`row_text`/`row_ansi`/`region_text`/`region_ansi`/`cell`); `prints` now holds only the assembled frame and cursor housekeeping.
|
|
21
|
+
- Add non-modal popups: `Component::Popup.new(modal: false)` still paints on top and auto-sizes to its content, but does not center, grab focus, capture keys, or block clicks — the focused component keeps the cursor and keeps receiving keys. This is the building block for autocomplete / slash-command menus anchored to a text input's caret. `Popup#modal?` exposes the mode; `ScreenPane#modal_popup` is the topmost *modal* popup (or nil), through which all "modal owner" reads (key scope, mouse fall-through, focus repair, Tab scope, global-shortcut gate) now route.
|
|
22
|
+
- Add `Component::TextInput#on_key` — an interceptor consulted before the input's own key handling (a truthy return consumes the key); the keyboard analog of `on_change`, for both `TextField` and `TextArea`. Lets app code layer Up/Down/Enter/ESC handling onto a field without subclassing.
|
|
23
|
+
- Add `Component#popup_min_height` / `popup_max_height` — content components can advise a wrapping `Popup` of preferred height bounds. `Component::LogWindow` uses them to stay readable at half-screen height when nearly empty and grow to full-screen for busy logs.
|
|
24
|
+
- `Component::LogWindow` now renders through a `TextView`, so long log lines wrap instead of being ellipsized.
|
|
25
|
+
- Add `examples/sampler.rb` "Slash menu" demo: a `TextArea` whose `on_change` refills a non-modal `Popup`-wrapped `List` anchored to the caret, with `on_key` forwarding navigation and ESC dismissing — focus and caret stay in the field throughout.
|
|
26
|
+
- **Breaking:** components paint into `Screen#buffer` via `set_line` / `fill` / `set_char` (handing the surface a `StyledString`) instead of writing escape sequences through `screen.print`. Custom components that drew via `screen.print(move_to, ansi)` must migrate to the buffer API.
|
|
27
|
+
- **Breaking:** key dispatch is centralized into a capture + bubble model in `ScreenPane#handle_key` (a `key_shortcut` match anywhere in scope focuses that component; the key is then delivered to `Screen#focused` and bubbles up its ancestor chain). A component's `handle_key` now acts on the key alone and never gates on its own `active?` state. `Layout#handle_key`, `Window#handle_key`, and `HasContent#handle_key` are removed (routing is the dispatcher's job), and the `active?` guards in `TextInput` / `List` / `Button` are dropped.
|
|
28
|
+
|
|
3
29
|
## [0.7.0] - 2026-06-09
|
|
4
30
|
|
|
5
31
|
- Lower the Ruby floor to 3.3 (was 3.4): replaced the `it` implicit block parameter (3.4+) with `_1` throughout, and added 3.3 to the CI matrix.
|
data/README.md
CHANGED
|
@@ -49,7 +49,16 @@ gem "tuile", git: "https://github.com/mvysny/tuile.git"
|
|
|
49
49
|
|
|
50
50
|
Tuile requires Ruby 3.3+.
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
## Documentation
|
|
53
|
+
|
|
54
|
+
- **[The Tuile guide](book/README.md)** teaches Tuile cover to cover — the
|
|
55
|
+
component tree, the top-down layout model and the case for why it's
|
|
56
|
+
enough, the single-threaded event loop and background work, focus, and
|
|
57
|
+
theming. Start here to learn the concepts and the *why*. It grows a
|
|
58
|
+
chapter at a time; the layout chapter is the heart of the design.
|
|
59
|
+
- **API reference:** every public class and method carries YARD headers —
|
|
60
|
+
browse them at <https://rubydoc.info/gems/tuile>, or run
|
|
61
|
+
`bundle exec rake yard` for a local site.
|
|
53
62
|
|
|
54
63
|
## Hello world
|
|
55
64
|
|
|
@@ -101,27 +110,37 @@ attachment checks and child-removed callbacks all work uniformly.
|
|
|
101
110
|
|
|
102
111
|
### Layout and repaint
|
|
103
112
|
|
|
104
|
-
|
|
105
|
-
|
|
113
|
+
Repainting has two halves: an *invalidation* pass decides which components
|
|
114
|
+
re-render, and a *back buffer* turns their output into the minimal set of
|
|
115
|
+
bytes sent to the terminal. There is no clipping in between.
|
|
106
116
|
|
|
107
117
|
1. A component that needs to redraw calls `invalidate`. This just records the
|
|
108
118
|
component in a set on the screen.
|
|
109
119
|
2. After the event loop drains the current batch of keyboard/mouse/posted
|
|
110
120
|
events, the screen runs a single `repaint` pass:
|
|
111
121
|
- Invalidated **tiled** components are sorted by tree depth (parents first)
|
|
112
|
-
and each one
|
|
122
|
+
and each one repaints its `rect`.
|
|
113
123
|
- If anything tiled was redrawn, **all popups** are drawn on top in
|
|
114
124
|
stacking order. Popups deliberately overdraw content; there is no
|
|
115
|
-
clipping.
|
|
125
|
+
clipping — overdraw is free because it only touches the buffer.
|
|
116
126
|
- The hardware cursor is moved to the focused component's
|
|
117
127
|
`cursor_position` (e.g. into a focused text field).
|
|
118
128
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
129
|
+
Components never write escape sequences to the terminal. They paint styled
|
|
130
|
+
cells into a back buffer (`Tuile::Buffer`) via `set_line` / `fill` /
|
|
131
|
+
`set_char`. When the pass finishes, `Buffer#flush` emits the **minimal diff**
|
|
132
|
+
— only the cells that actually changed since the last flush — wrapped in one
|
|
133
|
+
synchronized-output batch. That is what keeps repaint flicker-free on any
|
|
134
|
+
terminal regardless of mode-2026 support: an unchanged cell is never
|
|
135
|
+
rewritten, so a popup overdrawing content costs nothing on the wire unless it
|
|
136
|
+
changes a visible cell.
|
|
137
|
+
|
|
138
|
+
A component must fully cover its own `rect`, but it need not tile that rect
|
|
139
|
+
with children: the default `repaint` clears the background behind any gaps and
|
|
140
|
+
re-invalidates its children to paint on top, so a layout with mixed-width
|
|
141
|
+
fields shows no stale characters. Components that paint their entire rect
|
|
142
|
+
themselves (`Window`, `List`) opt out of that default. `Layout` paints nothing
|
|
143
|
+
of its own and positions its children within its rect.
|
|
125
144
|
|
|
126
145
|
### Single-threaded event loop
|
|
127
146
|
|
|
@@ -397,7 +416,7 @@ label = Tuile::Component::Label.new
|
|
|
397
416
|
label.text = "Hello, #{screen.theme.hint('world')}!"
|
|
398
417
|
```
|
|
399
418
|
|
|
400
|
-
Key API: `text=`, `
|
|
419
|
+
Key API: `text=`, `bg=`.
|
|
401
420
|
|
|
402
421
|
### `Component::Layout`
|
|
403
422
|
|
|
@@ -493,14 +512,16 @@ popup by default).
|
|
|
493
512
|
### `Component::Popup`
|
|
494
513
|
|
|
495
514
|
A modal overlay. It paints nothing itself: it wraps any component as
|
|
496
|
-
`content`,
|
|
497
|
-
|
|
498
|
-
content
|
|
515
|
+
`content`, sizes itself top-down from `size:` (a `Size`, or a `Fraction` of
|
|
516
|
+
the screen resolved every layout pass — default `Fraction::HALF`), centres
|
|
517
|
+
itself, and consumes `q` / `ESC` to close. The content fills that box and
|
|
518
|
+
scrolls/wraps within it; it does *not* drive the popup's size. Popups are
|
|
519
|
+
drawn on top of the tiled content; multiple popups stack.
|
|
499
520
|
|
|
500
521
|
```ruby
|
|
501
522
|
window = Tuile::Component::Window.new("Help")
|
|
502
523
|
window.content = help_list
|
|
503
|
-
Tuile::Component::Popup.open(content: window)
|
|
524
|
+
Tuile::Component::Popup.open(content: window, size: Tuile::Fraction::HALF)
|
|
504
525
|
# or, equivalently:
|
|
505
526
|
popup = Tuile::Component::Popup.new(content: window)
|
|
506
527
|
popup.open
|
|
@@ -508,7 +529,9 @@ popup.open
|
|
|
508
529
|
```
|
|
509
530
|
|
|
510
531
|
Bare content also works (a `Label`, a `List`…) and yields a borderless popup;
|
|
511
|
-
wrap in a `Window` if you want a frame.
|
|
532
|
+
wrap in a `Window` if you want a frame. Pass `modal: false` for a non-modal
|
|
533
|
+
overlay that floats above the content without grabbing focus — the caller
|
|
534
|
+
positions and drives it.
|
|
512
535
|
|
|
513
536
|
### `Component::InfoWindow`
|
|
514
537
|
|
|
@@ -578,10 +601,10 @@ Tuile.logger = Logger.new(Tuile::Component::LogWindow::IO.new(window))
|
|
|
578
601
|
|
|
579
602
|
Tuile ships with a `Tuile::FakeScreen` that you install in place of the real
|
|
580
603
|
screen for unit tests. It fixes the viewport at 160×50, disables the UI lock,
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
is never painted over.
|
|
604
|
+
paints into an in-memory back buffer (assert on it for painted content) while
|
|
605
|
+
capturing cursor/housekeeping escapes into an array, and uses a synchronous
|
|
606
|
+
`FakeEventQueue` (submitted blocks run inline; posted events are discarded).
|
|
607
|
+
No terminal IO happens, so the TTY running the tests is never painted over.
|
|
585
608
|
|
|
586
609
|
The standard setup is `Screen.fake` / `Screen.close` as a before/after pair —
|
|
587
610
|
this resets the singleton between examples, so state can't leak across
|
|
@@ -600,7 +623,7 @@ module Tuile
|
|
|
600
623
|
label.rect = Rect.new(0, 0, 5, 1)
|
|
601
624
|
label.text = "hi"
|
|
602
625
|
label.repaint
|
|
603
|
-
assert_equal [
|
|
626
|
+
assert_equal ["hi "], Screen.instance.buffer.region_text(label.rect)
|
|
604
627
|
end
|
|
605
628
|
end
|
|
606
629
|
end
|
|
@@ -608,8 +631,12 @@ end
|
|
|
608
631
|
|
|
609
632
|
Key hooks:
|
|
610
633
|
|
|
611
|
-
- `Screen.instance.
|
|
612
|
-
|
|
634
|
+
- `Screen.instance.buffer` — the back buffer painted content lands in. Assert
|
|
635
|
+
on `buffer.region_text(rect)` / `buffer.region_ansi(rect)` (per-row arrays)
|
|
636
|
+
or `buffer.cell(x, y)` for a single cell's grapheme and style.
|
|
637
|
+
- `Screen.instance.prints` — array of cursor/housekeeping escapes and the
|
|
638
|
+
assembled frame string. Assert against it (or `.join`) for cursor behavior,
|
|
639
|
+
not for painted content.
|
|
613
640
|
- `Screen.instance.repaint` — drive a repaint synchronously; production code
|
|
614
641
|
must not call this, but specs use it to flush the invalidated set after a
|
|
615
642
|
mutation.
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# 1. Your first Tuile app
|
|
2
|
+
|
|
3
|
+
This chapter builds the smallest useful Tuile program — a bordered window
|
|
4
|
+
that says hello — and uses it to name the handful of ideas the rest of
|
|
5
|
+
the guide leans on. There's almost no depth here on purpose: the goal is
|
|
6
|
+
the *shape* of a Tuile program and the mental model, not the details.
|
|
7
|
+
Every concept named in passing gets its own chapter later.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
Tuile is a gem; it needs Ruby 3.3 or newer. Add it to a project with
|
|
12
|
+
`bundle add tuile`, or install it standalone with `gem install tuile`,
|
|
13
|
+
and `require "tuile"` at the top of your script. That's the whole
|
|
14
|
+
prerequisite — Tuile builds on the `tty-*` toolkit but pulls in what it
|
|
15
|
+
needs itself, and it talks to the terminal you already have.
|
|
16
|
+
|
|
17
|
+
## The whole program
|
|
18
|
+
|
|
19
|
+
Here is a complete Tuile app. It draws a framed window containing the
|
|
20
|
+
text "Hello, world!" and waits; pressing `q` or `Esc` exits.
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
require "tuile"
|
|
24
|
+
|
|
25
|
+
# The Screen must exist before you wire components together: attaching a
|
|
26
|
+
# component (content=, focus, …) reaches for Tuile::Screen.instance.
|
|
27
|
+
screen = Tuile::Screen.new
|
|
28
|
+
|
|
29
|
+
label = Tuile::Component::Label.new("Hello, world!")
|
|
30
|
+
|
|
31
|
+
window = Tuile::Component::Window.new("Tuile")
|
|
32
|
+
window.content = label
|
|
33
|
+
|
|
34
|
+
screen.content = window
|
|
35
|
+
window.focus
|
|
36
|
+
|
|
37
|
+
begin
|
|
38
|
+
screen.run_event_loop
|
|
39
|
+
ensure
|
|
40
|
+
screen.close
|
|
41
|
+
end
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
That's it — no configuration, no layout file, no main loop you write by
|
|
45
|
+
hand. The rest of this chapter walks the seven lines that matter and
|
|
46
|
+
names what each one introduces.
|
|
47
|
+
|
|
48
|
+
## Build the screen first
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
screen = Tuile::Screen.new
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
{Tuile::Screen} is the runtime: it owns the terminal, the event loop, and
|
|
55
|
+
the one back buffer everything paints into. It is a **process
|
|
56
|
+
singleton** — there is exactly one screen per app, and `Screen.new`
|
|
57
|
+
installs it as *the* instance. After this line, any component can find it
|
|
58
|
+
with `Tuile::Screen.instance` (which is how the base {Tuile::Component}
|
|
59
|
+
implements its `screen` accessor).
|
|
60
|
+
|
|
61
|
+
Order matters: construct the screen *before* you start wiring components
|
|
62
|
+
together. Building a bare component (`Label.new`) touches nothing, but
|
|
63
|
+
the moment you attach one to the tree — `window.content = label`,
|
|
64
|
+
`window.focus` — that component reaches for `Screen.instance` to register
|
|
65
|
+
itself. No screen yet, and you get a "Screen not initialized" error. In
|
|
66
|
+
practice the rule is simply: `Screen.new` is the first line.
|
|
67
|
+
|
|
68
|
+
## Build a tree of components
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
label = Tuile::Component::Label.new("Hello, world!")
|
|
72
|
+
|
|
73
|
+
window = Tuile::Component::Window.new("Tuile")
|
|
74
|
+
window.content = label
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Everything you see on screen is a {Tuile::Component}: a piece of UI that
|
|
78
|
+
occupies a rectangle and knows how to paint itself into it. Components
|
|
79
|
+
nest into a **tree** — a component has a `parent` and `children` — and
|
|
80
|
+
that tree *is* your UI.
|
|
81
|
+
|
|
82
|
+
Here the tree is two nodes deep. A {Tuile::Component::Label} is a passive
|
|
83
|
+
one-liner of text, given straight to the constructor (or assigned later
|
|
84
|
+
with `label.text =`). A {Tuile::Component::Window} is a bordered frame
|
|
85
|
+
with a caption (`"Tuile"`, which you'll see drawn into the top edge) and a
|
|
86
|
+
single content slot. `window.content = label` puts the label inside the
|
|
87
|
+
window's frame and makes the window its parent. Assigning content is also
|
|
88
|
+
what *attaches* the label to the live tree, so from this point the label
|
|
89
|
+
is a real, paintable thing.
|
|
90
|
+
|
|
91
|
+
Notice you never say where anything goes or how big it is. You didn't
|
|
92
|
+
give the label a position or the window a size. **A component's parent
|
|
93
|
+
decides its rectangle** — the window will size the label to fit inside
|
|
94
|
+
its border, and something will size the window to fill the terminal.
|
|
95
|
+
That "something" is the next line, and the top-down layout rule behind it
|
|
96
|
+
is the whole of chapter 3.
|
|
97
|
+
|
|
98
|
+
## Put it on screen, and focus it
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
screen.content = window
|
|
102
|
+
window.focus
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`screen.content = window` makes the window the screen's **tiled content**
|
|
106
|
+
— the component that fills the terminal. Setting it triggers a layout
|
|
107
|
+
pass: the window is handed a rectangle spanning the whole screen (minus
|
|
108
|
+
one bottom row, which we'll get to), and it in turn sizes the label
|
|
109
|
+
inside its border. This is the top-down cascade in miniature — the screen
|
|
110
|
+
sizes the window, the window sizes its content — and it re-runs, top
|
|
111
|
+
down, every time the terminal is resized.
|
|
112
|
+
|
|
113
|
+
`window.focus` marks the window as the **focused** component: the one
|
|
114
|
+
that receives keystrokes. Focus flows down toward interactive content
|
|
115
|
+
where it can, so focusing the window is the natural way to say "this is
|
|
116
|
+
the active thing." In a one-window app it's mostly cosmetic (it draws the
|
|
117
|
+
border in the active color); in a real app, focus is what routes the
|
|
118
|
+
keyboard, and it gets its own chapter (chapter 5).
|
|
119
|
+
|
|
120
|
+
You may have noticed you never created a status bar, yet the app has one
|
|
121
|
+
— the bottom row showing `q quit`. That's because your window isn't the
|
|
122
|
+
whole story of what's on screen.
|
|
123
|
+
|
|
124
|
+
## The tree you didn't build
|
|
125
|
+
|
|
126
|
+
Your `window` isn't actually the root of the tree. The real root is a
|
|
127
|
+
structural node called the {Tuile::ScreenPane}, owned by the screen, and
|
|
128
|
+
it holds three things:
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
ScreenPane (structural root — paints nothing itself)
|
|
132
|
+
├── content your window (the tiled UI)
|
|
133
|
+
├── popups modal overlays, when you open them (none yet)
|
|
134
|
+
└── status_bar the bottom row (that "q quit" hint)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
You only ever manage the `content` slot directly (via `screen.content=`);
|
|
138
|
+
the pane, the popup stack, and the status bar are the framework's. The
|
|
139
|
+
reason everything — including popups — lives under one parent is
|
|
140
|
+
uniformity: focus traversal, "is this component still on screen?", and
|
|
141
|
+
cleanup when a component is removed all work the same way for every node,
|
|
142
|
+
with no special cases. You'll meet popups in chapter 7; for now it's
|
|
143
|
+
enough to know the pane is up there, quietly being the root.
|
|
144
|
+
|
|
145
|
+
## Run the loop, and always close
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
begin
|
|
149
|
+
screen.run_event_loop
|
|
150
|
+
ensure
|
|
151
|
+
screen.close
|
|
152
|
+
end
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
{Tuile::Screen#run_event_loop} is where your program *lives*. It puts the
|
|
156
|
+
terminal into raw mode, then loops: read a key (or a mouse event, or a
|
|
157
|
+
resize), dispatch it into the component tree, and — once the dust settles
|
|
158
|
+
— repaint whatever changed. It's a single thread doing one thing at a
|
|
159
|
+
time, and that single-threadedness is a deliberate, load-bearing design
|
|
160
|
+
choice (chapter 4). The loop returns when the user presses `q` or `Esc`
|
|
161
|
+
without any component consuming the key first.
|
|
162
|
+
|
|
163
|
+
The `ensure` is not optional. `run_event_loop` reconfigured your
|
|
164
|
+
terminal — raw mode, a hidden cursor, mouse tracking — and
|
|
165
|
+
{Tuile::Screen#close} is what puts it all back. Skip it and a crash leaves
|
|
166
|
+
the user staring at a terminal that no longer echoes what they type. Wrap
|
|
167
|
+
the loop in `begin`/`ensure` so teardown happens no matter how you exit.
|
|
168
|
+
|
|
169
|
+
## The shape of every Tuile program
|
|
170
|
+
|
|
171
|
+
Strip away the specifics and every Tuile app is the same four beats:
|
|
172
|
+
|
|
173
|
+
1. **Make the screen** — `Screen.new`, first, so components can find it.
|
|
174
|
+
2. **Build a tree** of components and hand its root to `screen.content=`.
|
|
175
|
+
3. **Run the loop** — `run_event_loop` reads input, updates the tree, and
|
|
176
|
+
repaints.
|
|
177
|
+
4. **Close** in an `ensure`, always.
|
|
178
|
+
|
|
179
|
+
That's the whole skeleton. Everything else in this guide is about the
|
|
180
|
+
second and third beats: how to build richer trees (the component library,
|
|
181
|
+
chapter 7), how the tree gets sized (layout, chapter 3), how it repaints
|
|
182
|
+
without flicker (chapter 2), and how input and background work move
|
|
183
|
+
through the loop (chapters 4 and 5).
|
|
184
|
+
|
|
185
|
+
Next up: chapter 2, on what actually happens between "a component changed"
|
|
186
|
+
and "the terminal shows it" — the repaint model.
|
data/book/02-repaint.md
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# 2. How the screen repaints
|
|
2
|
+
|
|
3
|
+
In chapter 1 you built a tree and ran the loop, and text appeared. This
|
|
4
|
+
chapter is about the gap between those two — what happens between "a
|
|
5
|
+
component changed" and "the terminal shows it." The short version is that
|
|
6
|
+
components don't paint when they change, and they never write to the
|
|
7
|
+
terminal at all. They *invalidate*, and once per loop tick the screen
|
|
8
|
+
paints everything that asked to be repainted, in one flicker-free batch.
|
|
9
|
+
|
|
10
|
+
Understanding this model matters for two reasons. It's the contract every
|
|
11
|
+
custom component has to honor (paint your rectangle, don't touch the
|
|
12
|
+
wire). And it's *why* Tuile stays smooth without any of the damage-region
|
|
13
|
+
or clipping machinery a UI toolkit would normally need.
|
|
14
|
+
|
|
15
|
+
## Components invalidate; they never paint the terminal
|
|
16
|
+
|
|
17
|
+
When something about a component changes — its text, its focus, its size
|
|
18
|
+
— it does not repaint itself on the spot, and it certainly doesn't emit
|
|
19
|
+
escape sequences to the terminal. It calls one protected method:
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
invalidate
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
That does exactly one thing: it records "this component needs to be
|
|
26
|
+
repainted" in a set the {Tuile::Screen} owns. Nothing is drawn yet. If
|
|
27
|
+
the same component is invalidated ten times before the next repaint —
|
|
28
|
+
because an event handler changed its text, then its color, then its size
|
|
29
|
+
— it's still just one entry in the set, and it repaints once.
|
|
30
|
+
|
|
31
|
+
That coalescing is the whole point of deferring. A single keystroke can
|
|
32
|
+
ripple through a dozen components; a naive "repaint on every change"
|
|
33
|
+
would draw the screen a dozen times per key, and you'd see it flicker and
|
|
34
|
+
tear. By separating *"I changed"* (`invalidate`, cheap, happens whenever)
|
|
35
|
+
from *"the screen is now redrawn"* (once per tick, batched), Tuile makes
|
|
36
|
+
the number of repaints independent of the number of changes.
|
|
37
|
+
|
|
38
|
+
`invalidate` is deliberately protected — you don't call it from outside a
|
|
39
|
+
component. You mutate a component through its public setters (`text=`,
|
|
40
|
+
`content=`, `rect=`, …) and *they* invalidate. And it's a no-op on a
|
|
41
|
+
component that isn't attached to the live tree, so mutating a component
|
|
42
|
+
you've stashed off-screen (say, inside a closed popup) is silent rather
|
|
43
|
+
than an error.
|
|
44
|
+
|
|
45
|
+
## When a component does paint, it paints into a buffer
|
|
46
|
+
|
|
47
|
+
Eventually the screen does call a component's {Tuile::Component#repaint}.
|
|
48
|
+
Even then, the component does not write to the terminal. It writes styled
|
|
49
|
+
cells into the screen's **back buffer** — a {Tuile::Buffer}, an in-memory
|
|
50
|
+
grid of styled cells mirroring the terminal — through three methods:
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
screen.buffer.set_line(x, y, styled_string) # a run of text
|
|
54
|
+
screen.buffer.set_char(x, y, grapheme, style) # one cell
|
|
55
|
+
screen.buffer.fill(rect, style) # a blank region
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
That's the entire painting vocabulary. A component's `repaint` computes
|
|
59
|
+
what its rectangle should look like and stamps it into the buffer. No
|
|
60
|
+
cursor moves, no color escapes, no `print` — just cells into a grid.
|
|
61
|
+
|
|
62
|
+
Keeping the buffer between the component and the terminal is what unlocks
|
|
63
|
+
everything in the rest of this chapter, so it's worth saying plainly: the
|
|
64
|
+
buffer *is* the seam. Components produce a desired grid state; the screen
|
|
65
|
+
turns grid state into the minimal set of bytes on the wire. Neither side
|
|
66
|
+
knows about the other's job.
|
|
67
|
+
|
|
68
|
+
## The repaint pass, once per tick
|
|
69
|
+
|
|
70
|
+
The event loop (chapter 4) processes whatever arrived — a key, a click, a
|
|
71
|
+
resize — and each of those may invalidate some components. When the queue
|
|
72
|
+
runs dry, the loop fires one repaint pass. The screen walks the
|
|
73
|
+
invalidated set and draws it in a specific order:
|
|
74
|
+
|
|
75
|
+
1. **Tiled components first, parent before child.** The framework walks
|
|
76
|
+
the tree in pre-order, which naturally visits a parent before its
|
|
77
|
+
children. That order matters: a parent lays down its background (and,
|
|
78
|
+
for a container with gaps between its children, re-clears them) *first*,
|
|
79
|
+
so a child drawing afterward paints on top of a clean surface rather
|
|
80
|
+
than fighting whatever was there before.
|
|
81
|
+
2. **Popups last, on top.** Any popups (chapter 7) repaint after the tiled
|
|
82
|
+
layer, in stacking order, so they overdraw the content beneath them.
|
|
83
|
+
There is no clipping and no "punch a hole in the content" step —
|
|
84
|
+
popups simply draw over what's below. If a tiled repaint touched cells
|
|
85
|
+
a popup also covers, the whole popup stack is reasserted on top so it
|
|
86
|
+
stays visually in front.
|
|
87
|
+
|
|
88
|
+
Notice what's *absent*: no region tracking, no dirty-rectangle geometry,
|
|
89
|
+
no z-buffer, no clip stack. The order is just "tree order, then popups,"
|
|
90
|
+
and the correctness comes from painting in that order into a buffer that
|
|
91
|
+
sorts out the rest.
|
|
92
|
+
|
|
93
|
+
## Overdraw is free; the wire is minimal
|
|
94
|
+
|
|
95
|
+
The reason all that overdraw doesn't cost anything is the buffer's flush.
|
|
96
|
+
After every component has painted into the buffer, the screen calls
|
|
97
|
+
{Tuile::Buffer#flush}, which computes the **minimal diff**: it compares the
|
|
98
|
+
buffer to the state the terminal was left in by the previous flush and
|
|
99
|
+
emits escape sequences only for the cells that actually changed. An
|
|
100
|
+
unchanged cell is never rewritten, no matter how many components painted
|
|
101
|
+
over it this tick.
|
|
102
|
+
|
|
103
|
+
So "overdraw is free" is literal. A popup can repaint over the entire
|
|
104
|
+
content area, a parent can re-clear a region a child then fills — none of
|
|
105
|
+
it reaches the terminal unless the *net* result differs from what was
|
|
106
|
+
already on screen. You get to write dead-simple paint code (just draw
|
|
107
|
+
your whole rectangle, every time) and pay only for what visibly changed.
|
|
108
|
+
|
|
109
|
+
The whole diff, plus the cursor repositioning, is wrapped in one
|
|
110
|
+
**synchronized-output batch** (the terminal's mode-2026 escape) and
|
|
111
|
+
written in a single `write`. A terminal that supports synchronized output
|
|
112
|
+
composites the frame atomically — no partial frame is ever visible. But
|
|
113
|
+
here's the part that makes Tuile flicker-free *everywhere*: the
|
|
114
|
+
smoothness doesn't depend on that support. Because an unchanged cell is
|
|
115
|
+
never touched, there's nothing to flicker even on a terminal that ignores
|
|
116
|
+
the batch escape entirely. Synchronized output is a bonus on top of a
|
|
117
|
+
model that's already tear-free.
|
|
118
|
+
|
|
119
|
+
## The contract: cover your own rect, and only your rect
|
|
120
|
+
|
|
121
|
+
All of this rests on one rule every component must follow:
|
|
122
|
+
|
|
123
|
+
> A component paints every cell it's responsible for, and never a cell
|
|
124
|
+
> outside its `rect`.
|
|
125
|
+
|
|
126
|
+
The "never outside" half keeps siblings from corrupting each other —
|
|
127
|
+
there's no clipping to save you, so drawing out of bounds means drawing
|
|
128
|
+
on someone else's cells. The "every cell it's responsible for" half is
|
|
129
|
+
what keeps stale pixels from surviving: if your rectangle used to show
|
|
130
|
+
"Loading…" and now shows nothing, the cells that held the old text have
|
|
131
|
+
to be actively overwritten (with blanks), or they'd linger.
|
|
132
|
+
|
|
133
|
+
Meeting that second half is easy, because the default
|
|
134
|
+
{Tuile::Component#repaint} does the tedious part for you:
|
|
135
|
+
|
|
136
|
+
- A **leaf** component (no children) gets its background cleared
|
|
137
|
+
automatically, so you can paint your content and trust the rest is
|
|
138
|
+
blanked.
|
|
139
|
+
- A **container whose children exactly tile its rect** skips the clear —
|
|
140
|
+
the children will cover everything anyway.
|
|
141
|
+
- A **container with gaps** between its children (a form with
|
|
142
|
+
mixed-width fields, say) gets the background cleared *and* its children
|
|
143
|
+
re-invalidated, so they repaint cleanly on top. This is what makes
|
|
144
|
+
gappy layouts safe without every container writing its own
|
|
145
|
+
damage-tracking pass.
|
|
146
|
+
|
|
147
|
+
The practical rule for writing a component: **call `super` in your
|
|
148
|
+
`repaint`** to inherit that clearing, then paint your content. The only
|
|
149
|
+
components that skip `super` are the few that paint every cell of their
|
|
150
|
+
rect themselves and don't want the redundant clear — {Tuile::Component::Window}
|
|
151
|
+
(its border and interior cover the whole rect) and
|
|
152
|
+
{Tuile::Component::List} (it paints every row explicitly). Everything else
|
|
153
|
+
should `super`.
|
|
154
|
+
|
|
155
|
+
You are *not* required to tile your rect with children — leaving gaps is
|
|
156
|
+
fine, the default handles it. You're only required to make sure that when
|
|
157
|
+
the dust settles, every cell you own reflects the current state, not a
|
|
158
|
+
previous one.
|
|
159
|
+
|
|
160
|
+
## Don't reach for repaint
|
|
161
|
+
|
|
162
|
+
One last rule, and it follows from everything above: **never call
|
|
163
|
+
`Screen#repaint` from inside a component.** Repaint is the loop's job,
|
|
164
|
+
fired once per tick after the queue drains. If you call it yourself you
|
|
165
|
+
break the coalescing — you force a draw mid-event, before other
|
|
166
|
+
components have reacted, and you may draw a half-updated frame. Just
|
|
167
|
+
`invalidate` (or, in practice, mutate through a setter that invalidates)
|
|
168
|
+
and let the loop batch it.
|
|
169
|
+
|
|
170
|
+
The one place you *will* see `Screen#repaint` called directly is in tests,
|
|
171
|
+
which drive the system a step at a time and need to force a paint to
|
|
172
|
+
assert on the buffer's contents. That's chapter 8; in application code,
|
|
173
|
+
invalidation is the only lever you touch.
|
|
174
|
+
|
|
175
|
+
With the repaint model in hand, the natural next question is the one this
|
|
176
|
+
chapter kept deferring: who decides a component's `rect` in the first
|
|
177
|
+
place? That's chapter 3 — layout.
|