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.
data/book/03-layout.md ADDED
@@ -0,0 +1,379 @@
1
+ # 3. Layout: the parent sets the size
2
+
3
+ In chapter 1 every component gained a `rect` — its absolute position
4
+ and size on the screen. In chapter 2 we saw that a component is
5
+ responsible for painting every cell of that `rect` and nothing outside
6
+ it. This chapter answers the question those two left open: **who decides
7
+ what a component's `rect` is?**
8
+
9
+ The answer is a single rule, and the rest of the chapter is about why
10
+ that one rule is enough:
11
+
12
+ > A component's parent assigns its `rect`. Layout is top-down. A
13
+ > component is as big as told to be - it never asks how big it wants to be.
14
+
15
+ If you have come from a desktop or web toolkit, this will feel
16
+ almost aggressively simple. There is no `preferred_size`, no
17
+ `min`/`max`, no "shrink to fit," no constraint solver, no reflow
18
+ engine. A parent computes rectangles for its children in plain Ruby and
19
+ hands them down. That's the whole model. The surprising part is not that
20
+ it's small — it's that on a terminal, nothing larger is warranted. The
21
+ bulk of this chapter makes that case, because understanding *why* the
22
+ simple model is correct is what lets you stop reaching for the machinery
23
+ you may be used to.
24
+
25
+ ## The rule, concretely
26
+
27
+ Every container positions its children by computing their rectangles
28
+ from its own. A two-pane split is arithmetic:
29
+
30
+ ```ruby
31
+ left.rect = Tuile::Rect.new(rect.left, rect.top, rect.width / 2, rect.height)
32
+ right.rect = Tuile::Rect.new(rect.left + rect.width / 2, rect.top,
33
+ rect.width - rect.width / 2, rect.height)
34
+ ```
35
+
36
+ Notice there is no negotiation. `left` does not announce a desired
37
+ width that the parent then reconciles against `right`'s desired width.
38
+ The parent simply *decides*, and the two children fill exactly the
39
+ rectangles they are given. If new content arrives that is too tall for
40
+ its pane, the pane scrolls or clips — it does not push back on the
41
+ parent to grow.
42
+
43
+ This is already how Tuile's tiled UI works today: `ScreenPane` sizes
44
+ your content and the status bar; every real layout you write positions
45
+ its children the same way. The chapter's job is to convince you that
46
+ this is a feature, then show you the few pieces of vocabulary that make
47
+ it comfortable.
48
+
49
+ ## Why a character grid changes everything
50
+
51
+ It's worth being precise about *why* desktop and web layout are complex,
52
+ because the reason turns out not to apply to a terminal at all.
53
+
54
+ Toolkits like Swing, JavaFX, and CSS are complex because they must be
55
+ **resolution-independent**. They target a continuous, unknown output:
56
+ pixels of unknown physical size (DPI varies per monitor), fractional
57
+ and hi-DPI scaling, fonts whose on-screen extent can't be known without
58
+ measuring them, and the demand that one layout reflow gracefully from a
59
+ phone to a 4K panel. Because the target is continuous and unknown *at
60
+ the time you write the layout*, you cannot name a position and have it
61
+ mean anything. Layout has to be expressed *relationally* — flex, grid,
62
+ percentages, `min`/`preferred`/`max` — and then *resolved* against the
63
+ real device when it finally renders. The constraint solver, the
64
+ intrinsic-sizing pass, the three size knobs per axis: all of it is the
65
+ **price of not knowing your output device**. (In this sense CSS is
66
+ closer to vector graphics than to what we're doing — the pixel has
67
+ already stopped being the real unit.)
68
+
69
+ A terminal owes none of that price, because the medium removes the very
70
+ uncertainty the machinery exists to absorb:
71
+
72
+ - **The canvas is a grid of character cells** — integer rows by integer
73
+ columns. There is no sub-cell position. This is a hard property of the
74
+ medium; it will never change.
75
+ - **Text extent is known, not measured.** N characters occupy N cells.
76
+ The one wrinkle — wide glyphs and grapheme clusters — Tuile resolves
77
+ *below* the layout layer, in the buffer's display-width pass, so your
78
+ layout coordinates stay exact integers.
79
+ - **The grid's size is known** at layout time, and changes only by a
80
+ discrete `SIGWINCH` event you re-lay-out on — a coarse resampling, not
81
+ a continuous reflow.
82
+
83
+ So the constraint solver, the intrinsic-sizing subsystem, and the
84
+ `min`/`preferred`/`max` negotiation are all solving one problem — *"I
85
+ don't know my output device"* — that a character grid simply does not
86
+ have. `Tuile::Rect.new(10, 3, 40, 5)` means exactly one thing on every
87
+ terminal that is that size. Because the space is discrete and known, we
88
+ can settle the layout in plain integers and take the coordinates
89
+ literally.
90
+
91
+ There's a nice way to frame this. Automated, relational layout carries a
92
+ fixed overhead — system complexity, indirection, a solver whose output
93
+ you sometimes have to reverse-engineer — but it scales to huge, unknown
94
+ canvases. Explicit layout has a per-element cost (you place each thing)
95
+ but zero overhead and total control. As the canvas gets coarser and more
96
+ fixed, the per-element cost collapses (there are few elements) and the
97
+ automation stops paying for itself. A Commodore 64 artist placing
98
+ pixels by hand and a TUI author placing rectangles by hand sit on the
99
+ *same side* of that crossover; modern GUI and web sit far on the other.
100
+ "Back to the roots" is a real engineering position here, not nostalgia.
101
+
102
+ ## Why simple is also *enough*
103
+
104
+ The section above argues the relational machinery is *unnecessary*.
105
+ A fair follow-up is whether the simple model is *sufficient* — or
106
+ whether you'll hit a wall and wish for flexbox. You won't, and there are
107
+ two independent reasons.
108
+
109
+ **A terminal holds few regions — by budget and by cognition.** A large
110
+ terminal is maybe 200×50, or 10,000 cells. A minimally *useful* pane is
111
+ around 20×5 — a hundred cells — so the hard ceiling is on the order of a
112
+ hundred regions, and in practice far fewer once you spend cells on
113
+ borders and breathing room. Compare a 1900×1200-pixel window with a
114
+ 40×20-pixel minimum widget: it budgets thousands. That's one to two
115
+ orders of magnitude more elements. And text is high-cognitive-load *per
116
+ cell* — you read it, you don't scan it the way you scan an icon grid —
117
+ so a person can't usefully attend to many text panes at once anyway. The
118
+ cell budget caps what the grid *can* hold; cognition caps what's *worth*
119
+ holding. Both point at the same answer: three to eight dense panes, not
120
+ two hundred nested boxes.
121
+
122
+ **"Enough" is validated by the ecosystem, not hoped for.** The genuinely
123
+ complex TUIs — tmux, neovim's splits, k9s, lazygit, htop — are all
124
+ **nested rectangular splits**: a tree of regions with sizes. None of
125
+ them needs flex grow/shrink/wrap/basis or a constraint solve. The
126
+ hardest real terminal UIs already live comfortably inside "simple."
127
+
128
+ It's stronger than "simple happens to work," though. Importing a CSS-like
129
+ system would be *actively worse* on a terminal, for three concrete
130
+ reasons:
131
+
132
+ 1. **It taxes the common case.** When ninety percent of your layouts are
133
+ an obvious `left 60% | right 40%`, a constraint system makes you
134
+ express even that in constraints — the inversion of what an
135
+ abstraction is supposed to buy you.
136
+ 2. **Solver non-determinism becomes a *visible* bug.** On a pixel canvas,
137
+ "which constraint gave a little?" hides in sub-pixel drift nobody
138
+ sees. On a character grid, one cell off is two percent of a small
139
+ pane and plainly visible — so an emergent solver result is a bug you
140
+ have to reverse-engineer. Explicit integer arithmetic is auditable;
141
+ a solver hides exactly the rounding you *can* see.
142
+ 3. **It breaks the audit-in-an-evening ceiling.** A flex engine or a
143
+ Cassowary solver is, by itself, more code than the rest of Tuile's
144
+ layout put together.
145
+
146
+ So staying simple isn't a compromise you're tolerating. On this medium
147
+ it is the *correct* fit, and the elaborate alternative would degrade the
148
+ common case, debuggability, and auditability all at once.
149
+
150
+ ## Placing children: `Layout::Absolute`
151
+
152
+ The place you actually write layout code is a `rect=` override. The base
153
+ class for this is `Tuile::Component::Layout::Absolute`: it inherits all
154
+ the focus and key-dispatch wiring, paints nothing itself, and asks only
155
+ that you position your children whenever your own rectangle is assigned —
156
+ which happens once at startup and again on every resize.
157
+
158
+ ```ruby
159
+ class SplitPane < Tuile::Component::Layout::Absolute
160
+ def initialize
161
+ super
162
+ @sidebar = Tuile::Component::List.new
163
+ @main = Tuile::Component::Window.new("Main")
164
+ add(@sidebar)
165
+ add(@main)
166
+ end
167
+
168
+ def rect=(new_rect)
169
+ super
170
+ # 40 / 60 split — resolved to exact integers, remainder assigned
171
+ # explicitly to the right pane so no column is ever lost.
172
+ left_w = rect.width * 4 / 10
173
+ @sidebar.rect = Tuile::Rect.new(rect.left, rect.top, left_w, rect.height)
174
+ @main.rect = Tuile::Rect.new(rect.left + left_w, rect.top,
175
+ rect.width - left_w, rect.height)
176
+ end
177
+ end
178
+ ```
179
+
180
+ Two things to note. First, a proportional split *does* use a ratio — but
181
+ on a character grid a ratio resolves to an exact integer (`width * 4 /
182
+ 10`), and you assign the remainder explicitly rather than letting two
183
+ rounded halves fail to add up. One deterministic line, no solve.
184
+
185
+ Second, because this runs on every resize (chapter 4 explains the
186
+ mechanism), responding to a narrower terminal is just an `if` in the same
187
+ method — collapse the sidebar below some width, give the main pane
188
+ everything:
189
+
190
+ ```ruby
191
+ def rect=(new_rect)
192
+ super
193
+ if rect.width < 60
194
+ @sidebar.rect = Tuile::Rect.new(0, 0, 0, 0) # hidden
195
+ @main.rect = rect
196
+ else
197
+ left_w = rect.width * 4 / 10
198
+ # …as above
199
+ end
200
+ end
201
+ ```
202
+
203
+ That's the whole "responsive" story: plain Ruby, recomputed on a
204
+ discrete resize event. No breakpoint DSL, no media queries — just the
205
+ arithmetic you'd write anyway.
206
+
207
+ ## Geometry: `Point`, `Size`, `Rect`
208
+
209
+ The values you compute with are three small frozen types
210
+ (`Data.define`, so they compare by structure and never mutate):
211
+
212
+ - `Tuile::Point.new(x, y)`
213
+ - `Tuile::Size.new(width, height)`
214
+ - `Tuile::Rect.new(left, top, width, height)`
215
+
216
+ `Rect` uses **half-open** edges: `rect.contains?(point)` is true when
217
+ `x >= left && x < left + width` (and likewise vertically), so the right
218
+ and bottom edges are exclusive. This is what makes adjacent rectangles
219
+ tile without overlap — a pane at `left=0, width=40` owns columns 0
220
+ through 39, and its neighbour starts cleanly at column 40. `Rect#empty?`
221
+ is true for a zero *or* negative width, which is why the "hidden"
222
+ sidebar above (`width 0`) simply paints nothing.
223
+
224
+ `Rect` also carries the helpers you reach for while placing children —
225
+ `centered` (used below to center a popup), `clamp_height`, `top_left`,
226
+ and friends. Reach for the rdoc for the full set.
227
+
228
+ ## Sizing a popup: `Fraction`
229
+
230
+ There is exactly one place in Tuile where a child is sized *against its
231
+ parent* rather than by hand: a popup against the screen. A popup has no
232
+ siblings to compete with and no natural rectangle in a tiled layout, so
233
+ "half the screen, centered" is the sensible default — and expressing
234
+ that wants a ratio, not a hard-coded cell count that would be wrong on
235
+ the next terminal size.
236
+
237
+ `Fraction` is that ratio, and it is deliberately the *only* relational
238
+ primitive in the framework:
239
+
240
+ ```ruby
241
+ class Fraction < Data.define(:width, :height) # each a float in 0.0..1.0
242
+ def resolve(reference) # reference: Size => Size
243
+ Tuile::Size.new((reference.width * width).round,
244
+ (reference.height * height).round)
245
+ end
246
+ end
247
+ ```
248
+
249
+ A popup's size is set with `Popup#size=`, which accepts either a
250
+ `Fraction` (resolved against the screen at layout time, so it tracks
251
+ resize) or an absolute `Size` (clamped to the screen):
252
+
253
+ ```ruby
254
+ popup = Tuile::Component::Popup.new(content: some_window)
255
+ popup.size = Tuile::Fraction::HALF # the default — half the screen, centered
256
+ popup.size = Tuile::Fraction::FULL # fullscreen
257
+ popup.size = Tuile::Fraction.new(0.8, 0.5) # 80% wide, half tall
258
+ popup.size = Tuile::Size.new(50, 12) # exact, clamped to screen
259
+ ```
260
+
261
+ The default is `Fraction::HALF`, resolved on every layout pass, so a
262
+ popup you never size at all is half-screen and follows the terminal as
263
+ it resizes. `Fraction::FULL` is the fullscreen shorthand.
264
+
265
+ A subtle but important point: `size=` is **authoritative, not a
266
+ preference**. The name is `size`, not `preferred_size`, on purpose.
267
+ There is no parent that might negotiate it downward — the screen simply
268
+ *applies* what you asked for (clamping an oversized absolute `Size` to
269
+ fit). Calling it a preference would invite a future "well, the parent
270
+ may override it" reading, and that's the first step back toward the
271
+ `min`/`preferred`/`max` world we just spent half a chapter declining.
272
+
273
+ Because the popup's box is fixed top-down, its **content fills the box
274
+ and handles its own overflow** — wrapping and scrolling. Use a component
275
+ that can do that: `TextView` (read-only prose) and `TextArea` (an
276
+ editor) both wrap and scroll. A `Label` only truncates, so a `Label` in
277
+ a popup shows just what fits on its lines — fine for a short message,
278
+ wrong for a paragraph.
279
+
280
+ > **Why not size the popup to its content?** It's tempting to want a
281
+ > popup that shrinks to wrap exactly around its text. Tuile deliberately
282
+ > doesn't, and not only for consistency: content-sizing is circular on
283
+ > wrapped text (the height depends on the width, which depends on the
284
+ > height) and it isn't even reliably pretty — a single long line
285
+ > collapses the popup to one row. Half-screen-and-wrap sidesteps all of
286
+ > it. When you genuinely know the right size — an autocomplete dropdown
287
+ > whose items you own — you set it yourself: `popup.size =
288
+ > Tuile::Size.new(longest_item, [items.size, 8].min)`. That's still
289
+ > caller-decides, top-down.
290
+
291
+ ## The window footer: chrome vs. slot
292
+
293
+ A `Window`'s bottom border can carry one of two things, and they are
294
+ genuinely different jobs, so they are two different members.
295
+
296
+ **`footer_text=`** is *border chrome* — a styled string (a `String` is
297
+ coerced) embedded into the bottom border line, mirroring the caption on
298
+ the top line. It draws at its own width with the border's dashes filling
299
+ the remainder, clipped to the inner width. Like the caption, it embeds
300
+ with **no added padding** — it butts straight against the left corner:
301
+
302
+ ```ruby
303
+ window.footer_text = "gpt-4 · 1.2k tok"
304
+ # └gpt-4 · 1.2k tok────────────────────────────────┘
305
+ ```
306
+
307
+ If you want the text to breathe against the corner and the dashes, pad
308
+ it yourself — a leading and trailing space is the usual choice:
309
+
310
+ ```ruby
311
+ window.footer_text = " gpt-4 · 1.2k tok "
312
+ # └ gpt-4 · 1.2k tok ──────────────────────────────┘
313
+ ```
314
+
315
+ It is not a component and not focusable — it's decoration the frame
316
+ draws, keeping its own styling (only the corners and dashes take the
317
+ active-border color when the window is focused). This is the right choice
318
+ for a status readout precisely *because* it preserves the border: a
319
+ full-width label would paint its background across the whole row and
320
+ erase the bottom edge, making the window look broken.
321
+
322
+ **`footer=`** is a *widget slot* — a focusable component occupying the
323
+ full inner width of the bottom row. The canonical use is an incremental
324
+ search field attached to a list window:
325
+
326
+ ```ruby
327
+ window.footer = search_field # a TextField; always spans the inner width
328
+ window.footer = nil # remove it, restoring the plain border
329
+ ```
330
+
331
+ A component in the footer slot always fills the width — there is no
332
+ sizing policy to configure, because the window already knows its inner
333
+ width and that's the only dimension a bottom-row widget needs.
334
+
335
+ The two are mutually exclusive by precedence: if a `footer=` component
336
+ is present it occupies the bottom row and `footer_text` is hidden;
337
+ otherwise `footer_text` embeds into the border. No window needs both at
338
+ once.
339
+
340
+ ## Responding to resize
341
+
342
+ You've already seen the mechanism without the plumbing: when the
343
+ terminal resizes, the framework reassigns rectangles from the root down,
344
+ and your `rect=` override recomputes its children. That's the *only*
345
+ thing you do to be resize-aware — recompute in `rect=`. Do **not**
346
+ install your own `SIGWINCH` handler; only one handler can win and the
347
+ framework owns it. Chapter 4 covers how the resize event travels through
348
+ the event queue and why it's handled there rather than off the signal.
349
+ Popups re-resolve their `Fraction` against the new screen size and
350
+ re-center automatically.
351
+
352
+ ## What Tuile deliberately doesn't have
353
+
354
+ It's worth stating the omissions plainly, so you don't go looking for
355
+ them:
356
+
357
+ - **No `min`/`preferred`/`max` size, and no `content_size` a parent
358
+ consults.** Sizing decisions live on the parent, expressed as
359
+ arithmetic, not on the child as advertised constraints.
360
+ - **No shrink-to-fit / intrinsic sizing.** A slot is sized top-down;
361
+ content fills or scrolls within it.
362
+ - **`Label` doesn't wrap.** It truncates long lines with an ellipsis by
363
+ design — it's the component for one-liners. For wrapping or scrolling
364
+ prose, reach for `TextView`.
365
+
366
+ If you ever *do* need to measure content in order to choose a size —
367
+ say, to size that autocomplete dropdown — measure it yourself in your
368
+ own code and set the size top-down. Keep measurement opt-in and
369
+ caller-side; the moment the framework starts consulting children for
370
+ sizes automatically, it's on the road back to the constraint solver.
371
+
372
+ And if you find yourself building many dynamic, user-draggable splits by
373
+ hand and wishing for a `Layout.vertical([Length(3), Fill(1), …])`
374
+ convenience — that's a known, *deliberately deferred* addition. It would
375
+ be pure sugar: a rect producer running a small greedy 1-D pass and
376
+ feeding results to the very same `rect=` setter you already use, with no
377
+ change to the foundation. Absolute-first is the base; a descriptive
378
+ split layer is an optional convenience on top, added if and when the
379
+ convenience pays for itself.
@@ -0,0 +1,209 @@
1
+ # 4. The event loop and background work
2
+
3
+ Chapter 2 said repaint happens "once per loop tick" and left the loop
4
+ itself a black box. This chapter opens it. The loop is where a Tuile app
5
+ spends all its time, and it runs on **one thread** — a rule that sounds
6
+ like a limitation and is actually the framework's central simplification.
7
+ The chapter has two jobs: convince you the single thread is the right
8
+ call, and show you how to do real background work — an HTTP poll, a file
9
+ watcher, a spinner — without breaking it.
10
+
11
+ ## The rule
12
+
13
+ > Every UI mutation runs on the event-loop thread. `rect=`, `content=`,
14
+ > `text=`, `invalidate`, `screen.focused=` — all of it, always, on the one
15
+ > thread that runs `run_event_loop`.
16
+
17
+ This isn't a guideline you can bend on a slow day. Most UI methods call
18
+ `screen.check_locked`, and if you mutate from the wrong thread it raises:
19
+
20
+ ```
21
+ UI lock not held: UI mutations must run on the event-loop thread;
22
+ marshal via screen.event_queue.submit { ... }
23
+ ```
24
+
25
+ The "lock" is really just an identity check — "am I the loop thread?" —
26
+ not a mutex you contend for. There's nothing to lock *against*, because
27
+ only one thread ever touches the UI.
28
+
29
+ (One wrinkle you'll never notice in practice: before `run_event_loop`
30
+ starts, the screen *pretends* the lock is held, so the setup code in
31
+ chapter 1 — building the tree, `screen.content=`, `window.focus` — runs
32
+ freely on your main thread. The rule only starts biting once the loop is
33
+ actually running and a *second* thread could race it.)
34
+
35
+ ## Why one thread
36
+
37
+ Reach for threads in a UI toolkit and you inherit its worst bugs: a
38
+ repaint reading a list while another thread mutates it, a half-applied
39
+ layout, focus moving out from under a keystroke. The usual fix is locks
40
+ everywhere, and locks bring their own tax — contention, deadlocks,
41
+ reentrancy, and the constant question "is this method safe to call from
42
+ here?"
43
+
44
+ Tuile declines the whole problem. A terminal UI is overwhelmingly
45
+ IO-bound: it sits waiting for a keystroke, wakes up, does a few
46
+ microseconds of work, repaints a handful of cells, and waits again. The
47
+ actual per-frame work is tiny. There is no throughput win from
48
+ parallelizing it — there's nothing to parallelize — and a large
49
+ correctness win from *not* trying. So every UI method gets to assume it's
50
+ the only code running. No locks, no races, no reentrancy, no thread-safety
51
+ annotations. That assumption is worth more than any concurrency the
52
+ single thread costs you, because the concurrency it costs you is
53
+ concurrency you didn't need.
54
+
55
+ What you *do* sometimes need is to **wait** for something slow — a network
56
+ call, a subprocess, a big file — without freezing the UI. That's what
57
+ background threads are for: they wait and compute off the loop, then hand
58
+ their results back to the one thread allowed to touch the UI. Waiting is
59
+ parallel; mutating is serial.
60
+
61
+ ## The loop, concretely
62
+
63
+ `run_event_loop` puts the terminal in raw mode and then does one thing
64
+ forever: pull the next event off a queue and act on it.
65
+
66
+ ```
67
+ key thread ──┐
68
+ WINCH signal ──┤ posts events ┌── EventQueue ──┐ consumed one at a
69
+ timer ticks ──┤ ────────────────▶│ (queue) │──▶ time on the loop
70
+ your submit ──┘ └────────────────┘ thread
71
+ ```
72
+
73
+ Input doesn't arrive on the loop thread directly. A dedicated **key
74
+ thread** blocks reading stdin, and each keystroke (or mouse event, or OS
75
+ light/dark flip) becomes an event *posted* to the {Tuile::EventQueue}. The
76
+ loop thread pops events one at a time and dispatches each: a key goes into
77
+ the focus chain (chapter 5), a resize re-lays-out the tree, and so on.
78
+
79
+ The queue is the seam between "many things can happen" and "one thing is
80
+ handled at a time." Everything funnels through it, which is exactly why a
81
+ single consumer thread is sufficient.
82
+
83
+ And repaint? When the queue runs dry — every pending event handled — the
84
+ loop emits one `EmptyQueueEvent`, and *that's* the repaint trigger
85
+ (chapter 2). So a burst of events that all invalidate components still
86
+ produces exactly one repaint, fired when the burst is done. The queue
87
+ draining is the "dust has settled" signal.
88
+
89
+ ## Background work: `submit`
90
+
91
+ Here's the pattern for doing something slow. Spawn an ordinary Ruby
92
+ thread, do the slow thing on it, and marshal the UI update back onto the
93
+ loop with `screen.event_queue.submit`:
94
+
95
+ ```ruby
96
+ Thread.new do
97
+ data = slow_http_fetch # off the loop — the UI stays responsive
98
+ screen.event_queue.submit do # back onto the loop thread
99
+ label.text = "#{data.size} results"
100
+ end
101
+ end
102
+ ```
103
+
104
+ `submit` pushes your block onto the same event queue the keystrokes flow
105
+ through. The loop pops it and runs it — on the loop thread, with the
106
+ "lock" held — so inside the block you may mutate the UI freely. The call
107
+ returns immediately; it doesn't wait for the block to run.
108
+
109
+ Reaching through `screen.event_queue` is deliberate, not a wart to route
110
+ around. The queue is a real object, and naming it says what's actually
111
+ happening: you are putting a piece of work *on the loop's queue*, to be
112
+ run in turn alongside every other event. Keep the block small — just the
113
+ UI mutation. Do the slow work *before* `submit`, on your thread; the block
114
+ should be the handful of assignments that reflect the result.
115
+
116
+ The tempting mistake is to skip the marshalling:
117
+
118
+ ```ruby
119
+ Thread.new do
120
+ data = slow_http_fetch
121
+ label.text = "#{data.size} results" # WRONG — off-thread UI mutation
122
+ end
123
+ ```
124
+
125
+ That's the exact call `check_locked` exists to catch. It raises "UI lock
126
+ not held," and rightly so: you'd be mutating component state while the
127
+ loop thread might be reading it mid-repaint.
128
+
129
+ Note the division of error handling. A block you `submit` runs *inside*
130
+ the loop, so if it raises, the exception flows through the loop's error
131
+ path — {Tuile::Screen#on_error}, which by default re-raises and tears the
132
+ app down loudly (unhandled exceptions are bugs; surface them). But a raise
133
+ in your background thread *before* `submit` — in the `slow_http_fetch`
134
+ itself — is yours to catch; it's your thread, and Tuile never sees it.
135
+ Wrap the slow work in your own `rescue` and `submit` an error display if
136
+ you want one.
137
+
138
+ ## Periodic work: `tick`
139
+
140
+ For anything that fires on a schedule — polling a value, redrawing a
141
+ clock — don't spin up a thread with a `sleep` loop. The queue has
142
+ {Tuile::EventQueue#tick}, and its argument is an **interval in seconds**,
143
+ the same unit as `sleep` and every other scheduler you've used:
144
+
145
+ ```ruby
146
+ ticker = screen.event_queue.tick(0.5) do |n| # every half-second
147
+ clock.text = Time.now.strftime("%H:%M:%S")
148
+ end
149
+ # later, when you're done:
150
+ ticker.cancel
151
+ ```
152
+
153
+ `tick(seconds)` calls your block on the loop thread (so it, too, may
154
+ mutate the UI freely), passing a monotonically increasing tick count. It
155
+ returns a {Tuile::EventQueue::Ticker}; call `cancel` to stop it. Tickers
156
+ share one background timer thread no matter how many you create, and if
157
+ your block raises, the ticker cancels itself so a broken block doesn't
158
+ spam errors at the tick rate.
159
+
160
+ For animation, where you think in frames per second rather than intervals,
161
+ {Tuile::EventQueue#tick_fps} reads more naturally — it's just
162
+ `tick(1.0 / fps)`:
163
+
164
+ ```ruby
165
+ FRAMES = %w[/ - \\ |]
166
+ ticker = screen.event_queue.tick_fps(8) do |n| # 8 frames a second
167
+ spinner.text = FRAMES[n % FRAMES.size]
168
+ end
169
+ ```
170
+
171
+ Reach for `tick` when you're pacing work ("check every two seconds"),
172
+ `tick_fps` when you're driving an animation ("spin at 8 fps"). Same
173
+ machinery underneath; pick the unit that matches how you're thinking.
174
+
175
+ ## Resize is just another event
176
+
177
+ A terminal resize could have been handled off the `SIGWINCH` signal
178
+ handler directly — and that would have been a bug factory, because a
179
+ signal handler can fire at any instant, including mid-repaint, on any
180
+ thread. Tuile doesn't. The `SIGWINCH` handler does the one thing that's
181
+ safe from a signal: it *posts* a `TTYSizeEvent` onto the queue and
182
+ returns. The resize is then handled like every other event, in turn, on
183
+ the loop thread — where re-laying-out the tree (chapter 3) is safe.
184
+
185
+ This is why chapter 3 told you never to install your own `SIGWINCH`
186
+ handler: only one handler can win, and the framework's owns it. You react
187
+ to resize the normal way — recompute your children's rectangles in your
188
+ `rect=` override — and the framework calls it for you when the resize
189
+ event is processed.
190
+
191
+ One consequence worth knowing: the screen's size is valid *before* the
192
+ first resize ever happens. `Screen.instance.size` is seeded at
193
+ construction from the current terminal dimensions, so a component that
194
+ needs the viewport size while it's being built (before any `SIGWINCH`
195
+ fires) can just read it. You don't have to wait for a resize to learn how
196
+ big the screen is.
197
+
198
+ ## The shape of it
199
+
200
+ The whole runtime is one thread pulling events off one queue: keys and
201
+ mouse from the key thread, resizes from the signal, timer firings from
202
+ `tick`, and your own work from `submit` — all serialized, all handled on
203
+ the thread that's allowed to touch the UI, with a repaint at the end of
204
+ each burst. Background threads exist only to wait and compute; they never
205
+ touch the UI, they hand results back through the queue.
206
+
207
+ That single thread is what lets the next chapter describe focus and
208
+ keyboard dispatch without a single caveat about concurrency: when a key
209
+ is dispatched, nothing else is happening. Chapter 5 is that dispatch.