tuile 0.8.0 → 0.10.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 +47 -0
- data/DECISIONS.md +1961 -0
- data/README.md +82 -48
- 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 +295 -0
- data/book/05-focus.md +219 -0
- data/book/06-theming.md +302 -0
- data/book/07-components.md +585 -0
- data/book/08-testing.md +199 -0
- data/book/09-styled-text.md +132 -0
- data/book/README.md +85 -0
- data/examples/hello_world.rb +1 -2
- data/examples/sampler.rb +435 -20
- data/ideas/new-components.md +109 -0
- data/ideas/per-component-buffers.md +55 -0
- data/lib/tuile/buffer.rb +113 -43
- data/lib/tuile/color.rb +4 -10
- data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
- data/lib/tuile/component/button.rb +25 -29
- data/lib/tuile/component/checkbox.rb +133 -0
- data/lib/tuile/component/checkbox_group.rb +188 -0
- data/lib/tuile/component/combo_box.rb +281 -0
- data/lib/tuile/component/has_caption.rb +44 -0
- data/lib/tuile/component/has_content.rb +5 -5
- data/lib/tuile/component/has_value.rb +64 -0
- data/lib/tuile/component/info_window.rb +4 -2
- data/lib/tuile/component/integer_field.rb +135 -0
- data/lib/tuile/component/label.rb +20 -25
- data/lib/tuile/component/layout.rb +3 -26
- data/lib/tuile/component/list.rb +8 -33
- data/lib/tuile/component/list_dropdown.rb +106 -0
- data/lib/tuile/component/log_window.rb +0 -14
- data/lib/tuile/component/password_field.rb +105 -0
- data/lib/tuile/component/popup.rb +70 -79
- data/lib/tuile/component/progress_bar.rb +278 -0
- data/lib/tuile/component/radio_group.rb +188 -0
- data/lib/tuile/component/text_area.rb +189 -65
- data/lib/tuile/component/text_field.rb +170 -32
- data/lib/tuile/component/text_view.rb +57 -137
- data/lib/tuile/component/window.rb +88 -121
- data/lib/tuile/component.rb +246 -142
- data/lib/tuile/event_queue.rb +39 -21
- data/lib/tuile/fake_event_queue.rb +32 -7
- data/lib/tuile/fake_screen.rb +4 -5
- data/lib/tuile/fraction.rb +42 -0
- data/lib/tuile/screen.rb +210 -109
- data/lib/tuile/screen_pane.rb +56 -44
- data/lib/tuile/styled_string.rb +112 -83
- data/lib/tuile/theme.rb +78 -41
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +2291 -890
- metadata +28 -9
- data/ideas/back-buffer.md +0 -217
- data/lib/tuile/sizing.rb +0 -59
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
|
|
|
@@ -89,9 +98,8 @@ Shift+Tab move focus between the list and the demo's widgets.
|
|
|
89
98
|
### Component tree
|
|
90
99
|
|
|
91
100
|
Everything on screen is a `Tuile::Component`. Components have a `parent`,
|
|
92
|
-
`children`, a `rect` (absolute position), an `active?` flag (true for
|
|
93
|
-
component on the focus chain root → focused)
|
|
94
|
-
that the framework will route keys to from anywhere in the tree.
|
|
101
|
+
`children`, a `rect` (absolute position), and an `active?` flag (true for
|
|
102
|
+
every component on the focus chain root → focused).
|
|
95
103
|
|
|
96
104
|
A single `Tuile::Screen` (process singleton) owns the tree. Under it sits a
|
|
97
105
|
structural `ScreenPane` with three slots: tiled `content` (your app's main
|
|
@@ -101,27 +109,37 @@ attachment checks and child-removed callbacks all work uniformly.
|
|
|
101
109
|
|
|
102
110
|
### Layout and repaint
|
|
103
111
|
|
|
104
|
-
|
|
105
|
-
|
|
112
|
+
Repainting has two halves: an *invalidation* pass decides which components
|
|
113
|
+
re-render, and a *back buffer* turns their output into the minimal set of
|
|
114
|
+
bytes sent to the terminal. There is no clipping in between.
|
|
106
115
|
|
|
107
116
|
1. A component that needs to redraw calls `invalidate`. This just records the
|
|
108
117
|
component in a set on the screen.
|
|
109
118
|
2. After the event loop drains the current batch of keyboard/mouse/posted
|
|
110
119
|
events, the screen runs a single `repaint` pass:
|
|
111
120
|
- Invalidated **tiled** components are sorted by tree depth (parents first)
|
|
112
|
-
and each one
|
|
121
|
+
and each one repaints its `rect`.
|
|
113
122
|
- If anything tiled was redrawn, **all popups** are drawn on top in
|
|
114
123
|
stacking order. Popups deliberately overdraw content; there is no
|
|
115
|
-
clipping.
|
|
124
|
+
clipping — overdraw is free because it only touches the buffer.
|
|
116
125
|
- The hardware cursor is moved to the focused component's
|
|
117
126
|
`cursor_position` (e.g. into a focused text field).
|
|
118
127
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
128
|
+
Components never write escape sequences to the terminal. They paint styled
|
|
129
|
+
cells into a back buffer (`Tuile::Buffer`) via `set_line` / `fill` /
|
|
130
|
+
`set_char`. When the pass finishes, `Buffer#flush` emits the **minimal diff**
|
|
131
|
+
— only the cells that actually changed since the last flush — wrapped in one
|
|
132
|
+
synchronized-output batch. That is what keeps repaint flicker-free on any
|
|
133
|
+
terminal regardless of mode-2026 support: an unchanged cell is never
|
|
134
|
+
rewritten, so a popup overdrawing content costs nothing on the wire unless it
|
|
135
|
+
changes a visible cell.
|
|
136
|
+
|
|
137
|
+
A component must fully cover its own `rect`, but it need not tile that rect
|
|
138
|
+
with children: the default `repaint` clears the background behind any gaps and
|
|
139
|
+
re-invalidates its children to paint on top, so a layout with mixed-width
|
|
140
|
+
fields shows no stale characters. Components that paint their entire rect
|
|
141
|
+
themselves (`Window`, `List`) opt out of that default. `Layout` paints nothing
|
|
142
|
+
of its own and positions its children within its rect.
|
|
125
143
|
|
|
126
144
|
### Single-threaded event loop
|
|
127
145
|
|
|
@@ -175,11 +193,13 @@ mechanism that handles it wins:
|
|
|
175
193
|
screen.unregister_global_shortcut(Tuile::Keys::CTRL_L)
|
|
176
194
|
```
|
|
177
195
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
196
|
+
This registry sits above the component tree and nothing suppresses it,
|
|
197
|
+
so it only accepts keys no widget can need: printable keys raise (they'd
|
|
198
|
+
hijack typing), as do Tab/Shift+Tab and `Screen::EDITING_KEYS` (Enter,
|
|
199
|
+
Backspace, Delete, the arrows). Control characters, ESC, `PgUp`/`PgDn`
|
|
200
|
+
and F-keys are yours. By default, the shortcut is suppressed while any
|
|
201
|
+
popup is open and the popup receives the key; pass `over_popups: true`
|
|
202
|
+
to pre-empt the popup.
|
|
183
203
|
|
|
184
204
|
Pass `hint:` to surface the shortcut in the status bar. It's a
|
|
185
205
|
preformatted string the caller fully owns (color it however the rest
|
|
@@ -188,23 +208,10 @@ mechanism that handles it wins:
|
|
|
188
208
|
`over_popups: true` hints show up, prepended before the popup's
|
|
189
209
|
`q Close`. Omit `hint:` to leave the shortcut silent in the status bar.
|
|
190
210
|
|
|
191
|
-
3. **`Component#
|
|
192
|
-
component. The framework walks the focused component's subtree for a
|
|
193
|
-
match and focuses the winner. Good fit for "press F to focus the filter
|
|
194
|
-
field" or one-key tab pickers. The lookup is suppressed while the
|
|
195
|
-
focused component owns the hardware cursor (e.g. a `TextField` the user
|
|
196
|
-
is typing into) so editing isn't interrupted:
|
|
197
|
-
|
|
198
|
-
```ruby
|
|
199
|
-
filter_field.key_shortcut = "f"
|
|
200
|
-
```
|
|
201
|
-
|
|
202
|
-
4. **`Component#handle_key`** — override this on your own component when
|
|
211
|
+
3. **`Component#handle_key`** — override this on your own component when
|
|
203
212
|
it needs to react to keys directly (a list reacting to arrows, a custom
|
|
204
213
|
widget handling Enter, …). Return `true` to mark the key handled,
|
|
205
|
-
`false` to let the
|
|
206
|
-
default `key_shortcut` subtree lookup; suppress it only when you
|
|
207
|
-
deliberately want this component to swallow everything:
|
|
214
|
+
`false` to let the key keep travelling:
|
|
208
215
|
|
|
209
216
|
```ruby
|
|
210
217
|
class Toggle < Tuile::Component
|
|
@@ -214,7 +221,26 @@ mechanism that handles it wins:
|
|
|
214
221
|
invalidate
|
|
215
222
|
true
|
|
216
223
|
else
|
|
217
|
-
|
|
224
|
+
false
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
The key goes to the focused component first, then **bubbles up its
|
|
231
|
+
ancestors** to the scope root (the topmost popup, or the tiled content).
|
|
232
|
+
That makes an ancestor the right home for scope-wide keys — a form's
|
|
233
|
+
default button, or one-key jumps between panes — and it needs no special
|
|
234
|
+
protection, because a focused `TextField` consumes the key before the
|
|
235
|
+
ancestor ever sees it:
|
|
236
|
+
|
|
237
|
+
```ruby
|
|
238
|
+
class AppLayout < Tuile::Component::Layout::Absolute
|
|
239
|
+
def handle_key(key)
|
|
240
|
+
case key
|
|
241
|
+
when "1" then @files.focus; true
|
|
242
|
+
when "2" then @log.focus; true
|
|
243
|
+
else false
|
|
218
244
|
end
|
|
219
245
|
end
|
|
220
246
|
end
|
|
@@ -397,7 +423,7 @@ label = Tuile::Component::Label.new
|
|
|
397
423
|
label.text = "Hello, #{screen.theme.hint('world')}!"
|
|
398
424
|
```
|
|
399
425
|
|
|
400
|
-
Key API: `text=`, `
|
|
426
|
+
Key API: `text=`, `bg=`.
|
|
401
427
|
|
|
402
428
|
### `Component::Layout`
|
|
403
429
|
|
|
@@ -493,14 +519,16 @@ popup by default).
|
|
|
493
519
|
### `Component::Popup`
|
|
494
520
|
|
|
495
521
|
A modal overlay. It paints nothing itself: it wraps any component as
|
|
496
|
-
`content`,
|
|
497
|
-
|
|
498
|
-
content
|
|
522
|
+
`content`, sizes itself top-down from `size:` (a `Size`, or a `Fraction` of
|
|
523
|
+
the screen resolved every layout pass — default `Fraction::HALF`), centres
|
|
524
|
+
itself, and consumes `q` / `ESC` to close. The content fills that box and
|
|
525
|
+
scrolls/wraps within it; it does *not* drive the popup's size. Popups are
|
|
526
|
+
drawn on top of the tiled content; multiple popups stack.
|
|
499
527
|
|
|
500
528
|
```ruby
|
|
501
529
|
window = Tuile::Component::Window.new("Help")
|
|
502
530
|
window.content = help_list
|
|
503
|
-
Tuile::Component::Popup.open(content: window)
|
|
531
|
+
Tuile::Component::Popup.open(content: window, size: Tuile::Fraction::HALF)
|
|
504
532
|
# or, equivalently:
|
|
505
533
|
popup = Tuile::Component::Popup.new(content: window)
|
|
506
534
|
popup.open
|
|
@@ -508,7 +536,9 @@ popup.open
|
|
|
508
536
|
```
|
|
509
537
|
|
|
510
538
|
Bare content also works (a `Label`, a `List`…) and yields a borderless popup;
|
|
511
|
-
wrap in a `Window` if you want a frame.
|
|
539
|
+
wrap in a `Window` if you want a frame. Pass `modal: false` for a non-modal
|
|
540
|
+
overlay that floats above the content without grabbing focus — the caller
|
|
541
|
+
positions and drives it.
|
|
512
542
|
|
|
513
543
|
### `Component::InfoWindow`
|
|
514
544
|
|
|
@@ -578,10 +608,10 @@ Tuile.logger = Logger.new(Tuile::Component::LogWindow::IO.new(window))
|
|
|
578
608
|
|
|
579
609
|
Tuile ships with a `Tuile::FakeScreen` that you install in place of the real
|
|
580
610
|
screen for unit tests. It fixes the viewport at 160×50, disables the UI lock,
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
is never painted over.
|
|
611
|
+
paints into an in-memory back buffer (assert on it for painted content) while
|
|
612
|
+
capturing cursor/housekeeping escapes into an array, and uses a synchronous
|
|
613
|
+
`FakeEventQueue` (submitted blocks run inline; posted events are discarded).
|
|
614
|
+
No terminal IO happens, so the TTY running the tests is never painted over.
|
|
585
615
|
|
|
586
616
|
The standard setup is `Screen.fake` / `Screen.close` as a before/after pair —
|
|
587
617
|
this resets the singleton between examples, so state can't leak across
|
|
@@ -600,7 +630,7 @@ module Tuile
|
|
|
600
630
|
label.rect = Rect.new(0, 0, 5, 1)
|
|
601
631
|
label.text = "hi"
|
|
602
632
|
label.repaint
|
|
603
|
-
assert_equal [
|
|
633
|
+
assert_equal ["hi "], Screen.instance.buffer.region_text(label.rect)
|
|
604
634
|
end
|
|
605
635
|
end
|
|
606
636
|
end
|
|
@@ -608,8 +638,12 @@ end
|
|
|
608
638
|
|
|
609
639
|
Key hooks:
|
|
610
640
|
|
|
611
|
-
- `Screen.instance.
|
|
612
|
-
|
|
641
|
+
- `Screen.instance.buffer` — the back buffer painted content lands in. Assert
|
|
642
|
+
on `buffer.region_text(rect)` / `buffer.region_ansi(rect)` (per-row arrays)
|
|
643
|
+
or `buffer.cell(x, y)` for a single cell's grapheme and style.
|
|
644
|
+
- `Screen.instance.prints` — array of cursor/housekeeping escapes and the
|
|
645
|
+
assembled frame string. Assert against it (or `.join`) for cursor behavior,
|
|
646
|
+
not for painted content.
|
|
613
647
|
- `Screen.instance.repaint` — drive a repaint synchronously; production code
|
|
614
648
|
must not call this, but specs use it to flush the invalidated set after a
|
|
615
649
|
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.
|