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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -0
  3. data/DECISIONS.md +1961 -0
  4. data/README.md +82 -48
  5. data/book/01-first-app.md +186 -0
  6. data/book/02-repaint.md +177 -0
  7. data/book/03-layout.md +379 -0
  8. data/book/04-event-loop.md +295 -0
  9. data/book/05-focus.md +219 -0
  10. data/book/06-theming.md +302 -0
  11. data/book/07-components.md +585 -0
  12. data/book/08-testing.md +199 -0
  13. data/book/09-styled-text.md +132 -0
  14. data/book/README.md +85 -0
  15. data/examples/hello_world.rb +1 -2
  16. data/examples/sampler.rb +435 -20
  17. data/ideas/new-components.md +109 -0
  18. data/ideas/per-component-buffers.md +55 -0
  19. data/lib/tuile/buffer.rb +113 -43
  20. data/lib/tuile/color.rb +4 -10
  21. data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
  22. data/lib/tuile/component/button.rb +25 -29
  23. data/lib/tuile/component/checkbox.rb +133 -0
  24. data/lib/tuile/component/checkbox_group.rb +188 -0
  25. data/lib/tuile/component/combo_box.rb +281 -0
  26. data/lib/tuile/component/has_caption.rb +44 -0
  27. data/lib/tuile/component/has_content.rb +5 -5
  28. data/lib/tuile/component/has_value.rb +64 -0
  29. data/lib/tuile/component/info_window.rb +4 -2
  30. data/lib/tuile/component/integer_field.rb +135 -0
  31. data/lib/tuile/component/label.rb +20 -25
  32. data/lib/tuile/component/layout.rb +3 -26
  33. data/lib/tuile/component/list.rb +8 -33
  34. data/lib/tuile/component/list_dropdown.rb +106 -0
  35. data/lib/tuile/component/log_window.rb +0 -14
  36. data/lib/tuile/component/password_field.rb +105 -0
  37. data/lib/tuile/component/popup.rb +70 -79
  38. data/lib/tuile/component/progress_bar.rb +278 -0
  39. data/lib/tuile/component/radio_group.rb +188 -0
  40. data/lib/tuile/component/text_area.rb +189 -65
  41. data/lib/tuile/component/text_field.rb +170 -32
  42. data/lib/tuile/component/text_view.rb +57 -137
  43. data/lib/tuile/component/window.rb +88 -121
  44. data/lib/tuile/component.rb +246 -142
  45. data/lib/tuile/event_queue.rb +39 -21
  46. data/lib/tuile/fake_event_queue.rb +32 -7
  47. data/lib/tuile/fake_screen.rb +4 -5
  48. data/lib/tuile/fraction.rb +42 -0
  49. data/lib/tuile/screen.rb +210 -109
  50. data/lib/tuile/screen_pane.rb +56 -44
  51. data/lib/tuile/styled_string.rb +112 -83
  52. data/lib/tuile/theme.rb +78 -41
  53. data/lib/tuile/version.rb +1 -1
  54. data/sig/tuile.rbs +2291 -890
  55. metadata +28 -9
  56. data/ideas/back-buffer.md +0 -217
  57. 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
- API documentation: <https://rubydoc.info/gems/tuile>.
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 every
93
- component on the focus chain root → focused), and an optional `key_shortcut`
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
- Tuile uses the simplest possible repaint model — no damage tracking, no
105
- clipping, no diffing:
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 fully redraws its `rect`.
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
- This means a component is responsible for fully covering its own `rect` —
120
- parents do not paint behind their children. `Layout` enforces this by simply
121
- not drawing anything itself; its children must tile the entire layout area.
122
- The trade-off is that if you leave gaps, they will show stale characters; the
123
- upside is that the repaint code is tiny and predictable, and there is no
124
- flicker because the terminal is written to in a single batched pass per tick.
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
- Only unprintable keys are accepted (control characters, ESC, BACKSPACE,
179
- arrows, F-keys); printable keys raise so they can't hijack typing into
180
- a `TextField`. By default, the shortcut is suppressed while any popup
181
- is open and the popup receives the key; pass `over_popups: true` to
182
- pre-empt the popup.
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#key_shortcut`** — a declarative hotkey attached to a
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 dispatcher keep walking. Call `super` to keep 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
- super
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=`, `content_size`.
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`, centres itself on the screen, auto-sizes to the wrapped content,
497
- and consumes `q` / `ESC` to close. Popups are drawn on top of the tiled
498
- content; multiple popups stack.
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
- collects every string the framework "would have printed" into an array, and
582
- uses a synchronous `FakeEventQueue` (submitted blocks run inline; posted
583
- events are discarded). No terminal IO happens, so the TTY running the tests
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 [TTY::Cursor.move_to(0, 0), "hi "], Screen.instance.prints
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.prints` — array of strings the screen would have written
612
- to the terminal. Assert against it (or `.join`) for repaint output.
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.
@@ -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.