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 +4 -4
- data/CHANGELOG.md +15 -0
- data/README.md +51 -24
- data/book/01-first-app.md +186 -0
- data/book/02-repaint.md +177 -0
- data/book/03-layout.md +379 -0
- data/book/04-event-loop.md +209 -0
- data/book/05-focus.md +188 -0
- data/book/06-theming.md +250 -0
- data/book/07-components.md +231 -0
- data/book/08-testing.md +186 -0
- data/book/README.md +79 -0
- data/examples/hello_world.rb +1 -2
- data/lib/tuile/buffer.rb +100 -31
- data/lib/tuile/component/button.rb +1 -9
- data/lib/tuile/component/info_window.rb +4 -2
- data/lib/tuile/component/label.rb +7 -15
- data/lib/tuile/component/layout.rb +0 -9
- data/lib/tuile/component/list.rb +0 -26
- data/lib/tuile/component/log_window.rb +0 -14
- data/lib/tuile/component/popup.rb +70 -75
- data/lib/tuile/component/text_view.rb +0 -23
- data/lib/tuile/component/window.rb +57 -75
- data/lib/tuile/component.rb +1 -60
- data/lib/tuile/event_queue.rb +31 -10
- data/lib/tuile/fake_event_queue.rb +21 -5
- data/lib/tuile/fraction.rb +46 -0
- data/lib/tuile/screen.rb +8 -5
- data/lib/tuile/screen_pane.rb +5 -3
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +195 -223
- metadata +12 -4
- data/ideas/back-buffer.md +0 -217
- data/lib/tuile/sizing.rb +0 -59
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.
|