tuile 0.8.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3e4cd91c879f7eea941fd0a60a453a69de1f672a4907269282a0964645d919f8
4
- data.tar.gz: c5f6b3124a64904cf606cfd4bc8a28409e7335b2b52c45bc99c67844f2b7de41
3
+ metadata.gz: 9cbba5366b6871e4ac6972819d1a3b03cb7b106bea1b3efc42ebf7fac27e279e
4
+ data.tar.gz: a72af4f77ce12a70ca0f3c477081047c81b193f4e2b8b5ca48dae3551daff524
5
5
  SHA512:
6
- metadata.gz: 0c2d866c691d81685c3db892f933ce094fa9f770c8b7394a652f4344975595ec53acea525f05f1528ddcc934ce9a966ff631be9bf1292d58651c2606dd22fdaa
7
- data.tar.gz: 87889c8181d1da7c477678456a3eee02dcd3abcf553b162c9cc1b560856228a8e65f062dc46c6d0c0a1edff5a892dd3b295e506f509698bc5a5142925b5c9663
6
+ metadata.gz: 883f93623cde985b9287ad548d941ee6cbca89639fbd2c19f5978581b13d8ca231731392c1d83860a34fae0124f3ccb27630581cc26f045064d3592bc45dd9da
7
+ data.tar.gz: 1c1c3ec8411c4368ecd6c7edd44da1681701fada1dc6a1345060c40a40ced7712dff9bb9433e90961e76b3300fc646e60a3c5755ef4acae1b759a9405732f32e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
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
+
3
18
  ## [0.8.0] - 2026-06-11
4
19
 
5
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.
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
 
@@ -101,27 +110,37 @@ attachment checks and child-removed callbacks all work uniformly.
101
110
 
102
111
  ### Layout and repaint
103
112
 
104
- Tuile uses the simplest possible repaint model no damage tracking, no
105
- clipping, no diffing:
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 fully redraws its `rect`.
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
- 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.
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=`, `content_size`.
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`, 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.
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
- 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.
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 [TTY::Cursor.move_to(0, 0), "hi "], Screen.instance.prints
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.prints` — array of strings the screen would have written
612
- to the terminal. Assert against it (or `.join`) for repaint output.
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.
@@ -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.