tuile 0.10.0 → 0.11.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 +77 -64
- data/DECISIONS.md +619 -14
- data/README.md +6 -0
- data/book/03-layout.md +153 -8
- data/book/05-focus.md +2 -0
- data/book/07-components.md +105 -10
- data/book/README.md +3 -1
- data/examples/sampler.rb +282 -132
- data/ideas/arrow-key-navigation.md +205 -0
- data/ideas/new-components.md +17 -8
- data/lib/tuile/component/big_decimal_field.rb +199 -0
- data/lib/tuile/component/checkbox.rb +10 -9
- data/lib/tuile/component/combo_box.rb +8 -26
- data/lib/tuile/component/float_field.rb +161 -0
- data/lib/tuile/component/layout/box.rb +316 -0
- data/lib/tuile/component/layout/horizontal.rb +40 -0
- data/lib/tuile/component/layout/vertical.rb +41 -0
- data/lib/tuile/component/layout.rb +149 -1
- data/lib/tuile/component/list_dropdown.rb +69 -18
- data/lib/tuile/component/select.rb +251 -0
- data/lib/tuile/styled_string.rb +13 -3
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +4 -0
- data/sig/tuile.rbs +882 -29
- metadata +8 -1
data/README.md
CHANGED
|
@@ -49,6 +49,12 @@ gem "tuile", git: "https://github.com/mvysny/tuile.git"
|
|
|
49
49
|
|
|
50
50
|
Tuile requires Ruby 3.3+.
|
|
51
51
|
|
|
52
|
+
One component — `Component::BigDecimalField` — additionally needs the
|
|
53
|
+
`bigdecimal` gem, which Tuile deliberately does *not* depend on (it has been a
|
|
54
|
+
bundled gem since Ruby 3.4, so Bundler no longer puts it on the load path for
|
|
55
|
+
free). Add `gem "bigdecimal"` to your Gemfile if you use that field; nothing
|
|
56
|
+
else in Tuile loads it.
|
|
57
|
+
|
|
52
58
|
## Documentation
|
|
53
59
|
|
|
54
60
|
- **[The Tuile guide](book/README.md)** teaches Tuile cover to cover — the
|
data/book/03-layout.md
CHANGED
|
@@ -125,6 +125,15 @@ complex TUIs — tmux, neovim's splits, k9s, lazygit, htop — are all
|
|
|
125
125
|
them needs flex grow/shrink/wrap/basis or a constraint solve. The
|
|
126
126
|
hardest real terminal UIs already live comfortably inside "simple."
|
|
127
127
|
|
|
128
|
+
Be precise about what that validates, though: it's TUI *app architecture*,
|
|
129
|
+
not TUI *framework feature lists*. Several terminal frameworks do ship a
|
|
130
|
+
full engine — Textual has CSS, Ink embeds Yoga (the flexbox engine React
|
|
131
|
+
Native uses), ratatui runs a real Cassowary solver. The reason isn't that
|
|
132
|
+
terminals need one; it's that those frameworks never hand you a rectangle,
|
|
133
|
+
so an engine is the only way their users can lay anything out. Tuile hands
|
|
134
|
+
you coordinates, which is what makes richer layout *optional* here —
|
|
135
|
+
available where it helps, declinable everywhere else.
|
|
136
|
+
|
|
128
137
|
It's stronger than "simple happens to work," though. Importing a CSS-like
|
|
129
138
|
system would be *actively worse* on a terminal, for three concrete
|
|
130
139
|
reasons:
|
|
@@ -204,6 +213,146 @@ That's the whole "responsive" story: plain Ruby, recomputed on a
|
|
|
204
213
|
discrete resize event. No breakpoint DSL, no media queries — just the
|
|
205
214
|
arithmetic you'd write anyway.
|
|
206
215
|
|
|
216
|
+
## Stacks without the arithmetic: `Vertical` and `Horizontal`
|
|
217
|
+
|
|
218
|
+
`Absolute` is the right tool for genuinely two-dimensional geometry, and
|
|
219
|
+
tedious for the most common shape in any app: a stack. So Tuile ships two
|
|
220
|
+
*box* layouts that do that arithmetic for you. You declare what extent each
|
|
221
|
+
child should get, and the box hands down rectangles through the very same
|
|
222
|
+
`rect=`:
|
|
223
|
+
|
|
224
|
+
```ruby
|
|
225
|
+
form = Tuile::Component::Layout::Vertical.new(spacing: 1)
|
|
226
|
+
form.add(prompt, Tuile::Component::Layout::Fixed[3]) # 3 rows
|
|
227
|
+
form.add(field, Tuile::Component::Layout::Fixed[1]) # 1 row
|
|
228
|
+
form.add(log, Tuile::Component::Layout::Expand[1]) # …all that's left
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
`Horizontal` is the same with the axes swapped — the constraint is a width,
|
|
232
|
+
and `Expand` claims the rest of the row:
|
|
233
|
+
|
|
234
|
+
```ruby
|
|
235
|
+
split = Tuile::Component::Layout::Horizontal.new
|
|
236
|
+
split.add(sidebar, Tuile::Component::Layout::Fixed[30])
|
|
237
|
+
split.add(main, Tuile::Component::Layout::Expand[1])
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Inside a subclass the constraint names need no prefix, since they live on
|
|
241
|
+
`Layout`, an ancestor:
|
|
242
|
+
|
|
243
|
+
```ruby
|
|
244
|
+
class LoginForm < Tuile::Component::Layout::Vertical
|
|
245
|
+
def initialize
|
|
246
|
+
super(spacing: 1, padding: Insets[top: 1])
|
|
247
|
+
add(@user = Tuile::Component::TextField.new, Fixed[1], cross: Fixed[30])
|
|
248
|
+
add(@log = Tuile::Component::TextView.new, Expand[1])
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
### The three constraints
|
|
254
|
+
|
|
255
|
+
- **`Fixed[n]`** — exactly `n` cells, clamped to what's still unassigned.
|
|
256
|
+
- **`Percent[n]`** — `n`% of the space *available*, measured after padding
|
|
257
|
+
and the gaps between children come off. So two `Percent[50]` children fit
|
|
258
|
+
exactly instead of overflowing by the gap between them.
|
|
259
|
+
- **`Expand[weight]`** — a share of whatever is left once the `Fixed` and
|
|
260
|
+
`Percent` children have taken theirs, split in proportion to the weights.
|
|
261
|
+
|
|
262
|
+
That's the entire vocabulary, and the omission is the point: **there is no
|
|
263
|
+
`Auto`.** Nothing asks a child how big it would like to be. This is the same
|
|
264
|
+
rule as the rest of the chapter, wearing a friendlier face.
|
|
265
|
+
|
|
266
|
+
Two more knobs, both on the box rather than on each child: `spacing:` (blank
|
|
267
|
+
cells between adjacent children) and `padding:` (an inset from the box's own
|
|
268
|
+
rect — `Insets[top: 1, left: 2]`, or a plain integer for all four edges).
|
|
269
|
+
|
|
270
|
+
### The cross axis, and alignment
|
|
271
|
+
|
|
272
|
+
Each child also gets a `cross:` constraint — its width in a `Vertical`, its
|
|
273
|
+
height in a `Horizontal`. It defaults to `Percent[100]`, so children fill the
|
|
274
|
+
box across the axis, which is usually what you want. Narrow one when it isn't:
|
|
275
|
+
|
|
276
|
+
```ruby
|
|
277
|
+
form.add(field, Fixed[1], cross: Fixed[30]) # 30 columns
|
|
278
|
+
form.add(title, Fixed[1], cross: Percent[50], align: :center)
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
`align:` is `:start`, `:center` or `:end` — axis-agnostic on purpose, since
|
|
282
|
+
`:start` means the left edge in a `Vertical` and the top edge in a
|
|
283
|
+
`Horizontal`. It does something only when the child is narrower than the
|
|
284
|
+
space available.
|
|
285
|
+
|
|
286
|
+
Alignment might look like it contradicts the top-down rule — surely centering
|
|
287
|
+
needs to know how wide the child is? It doesn't. It needs *a* width, and the
|
|
288
|
+
`cross:` constraint is where that width came from. Nothing gets measured.
|
|
289
|
+
(`Expand` is main-axis only for a related reason: across the axis a child has
|
|
290
|
+
no siblings to compete with, so a weight would have nothing to mean. Passing
|
|
291
|
+
one as `cross:` raises.)
|
|
292
|
+
|
|
293
|
+
### Packing, starving, and remainders
|
|
294
|
+
|
|
295
|
+
Three behaviours worth knowing, because they are what you get *instead of* a
|
|
296
|
+
solver:
|
|
297
|
+
|
|
298
|
+
**Children pack from the start edge.** With no `Expand` among them the slack
|
|
299
|
+
is simply left at the end — there's no invisible filler to add, the way
|
|
300
|
+
Swing's `BoxLayout` needs glue.
|
|
301
|
+
|
|
302
|
+
**Over-subscription starves rather than raising.** If the children ask for
|
|
303
|
+
more than there is, they're satisfied in declaration order and whoever is
|
|
304
|
+
left over gets an empty rect — which, as chapter 2 established, paints
|
|
305
|
+
nothing. A pane too short for its content degrades quietly instead of
|
|
306
|
+
throwing or spilling outside its rect.
|
|
307
|
+
|
|
308
|
+
**A remainder goes to the earliest `Expand` children, one cell each.** Five
|
|
309
|
+
equal `Expand`s in 12 rows get `3, 3, 2, 2, 2` — never `2, 2, 2, 2, 4`, which
|
|
310
|
+
is what "give the leftover to the last one" produces. On a character grid a
|
|
311
|
+
doubled pane is plainly visible, so spare cells are spread rather than dumped.
|
|
312
|
+
One wrinkle, since this chapter showed you the hand-written version first: the
|
|
313
|
+
two-pane `Absolute` example above gives the odd column to the *right* pane,
|
|
314
|
+
while two `Expand[1]` children give it to the *left*. Both are deterministic;
|
|
315
|
+
they're just different code.
|
|
316
|
+
|
|
317
|
+
### Varying the gap: nest, don't configure
|
|
318
|
+
|
|
319
|
+
`spacing` belongs to the box rather than to individual children, deliberately.
|
|
320
|
+
A gap sits *between* two children, so "whose gap is it?" has no good answer —
|
|
321
|
+
and both possible conventions confuse readers.
|
|
322
|
+
|
|
323
|
+
When you want tighter grouping, nest a box. A `spacing: 0` stack inside a
|
|
324
|
+
`spacing: 1` stack keeps two rows flush while the rest of the form breathes:
|
|
325
|
+
|
|
326
|
+
```ruby
|
|
327
|
+
pair = Tuile::Component::Layout::Vertical.new # spacing: 0
|
|
328
|
+
pair.add(bar, Fixed[1])
|
|
329
|
+
pair.add(caption, Fixed[1]) # flush under the bar
|
|
330
|
+
|
|
331
|
+
form = Tuile::Component::Layout::Vertical.new(spacing: 1)
|
|
332
|
+
form.add(prompt, Fixed[4])
|
|
333
|
+
form.add(pair, Fixed[2]) # blank row around the pair
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
That *states* the grouping instead of faking it with a per-child gap — boxes
|
|
337
|
+
within boxes, which is how the rest of Tuile composes anyway.
|
|
338
|
+
|
|
339
|
+
### When to stay with `Absolute`
|
|
340
|
+
|
|
341
|
+
The boxes are sugar, not a replacement, and they can't say everything. A **cap
|
|
342
|
+
on a proportion** is the case to recognise:
|
|
343
|
+
|
|
344
|
+
```ruby
|
|
345
|
+
list_width = (rect.width / 3).clamp(20, 40) # a third, but never <20 or >40
|
|
346
|
+
group_width = [16, rect.width / 3].min # a third, but never more than 16
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
Both of these are in `examples/sampler.rb`, and both keep a `rect=`
|
|
350
|
+
override. That's the intended division of labour rather than a gap to work
|
|
351
|
+
around: use a box for the stack, drop to `Absolute` for the region that
|
|
352
|
+
genuinely needs arithmetic — usually nesting one inside the other, so only the
|
|
353
|
+
awkward part carries any. The sampler does exactly that, and porting it to
|
|
354
|
+
these layouts took it from 59 hand-written rectangles down to 7.
|
|
355
|
+
|
|
207
356
|
## Geometry: `Point`, `Size`, `Rect`
|
|
208
357
|
|
|
209
358
|
The values you compute with are three small frozen types
|
|
@@ -369,11 +518,7 @@ own code and set the size top-down. Keep measurement opt-in and
|
|
|
369
518
|
caller-side; the moment the framework starts consulting children for
|
|
370
519
|
sizes automatically, it's on the road back to the constraint solver.
|
|
371
520
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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.
|
|
521
|
+
Note that the box layouts above are not an exception to any of this. They
|
|
522
|
+
compute rectangles *for* you, but they compute them from constraints you
|
|
523
|
+
supplied, and they hand them down through the same `rect=`. No child is ever
|
|
524
|
+
consulted.
|
data/book/05-focus.md
CHANGED
|
@@ -159,6 +159,8 @@ The same mechanism gives you a form's default button, one form per popup:
|
|
|
159
159
|
| `TextField` with an `on_enter` | consumes it | no double-submit |
|
|
160
160
|
| `TextField` without one | declines | bubbles up → submit |
|
|
161
161
|
| `Button` | consumes it | activates *itself*, not the default |
|
|
162
|
+
| `Checkbox` | consumes it (toggles) | the form never sees it |
|
|
163
|
+
| `Select` | consumes it (opens, then commits) | the form never sees it |
|
|
162
164
|
|
|
163
165
|
Because bubbling stops at the scope root, two forms in two popups each get
|
|
164
166
|
their own Enter — something a global registry structurally cannot do. This
|
data/book/07-components.md
CHANGED
|
@@ -175,7 +175,25 @@ qty.on_value_change = ->(n) { recompute(n) } # n is an Integer, or nil
|
|
|
175
175
|
qty.value = 3
|
|
176
176
|
```
|
|
177
177
|
|
|
178
|
-
|
|
178
|
+
{Tuile::Component::FloatField} is the same field one type over — it also
|
|
179
|
+
accepts a single decimal point, and its value is a `Float`. That naming is
|
|
180
|
+
a small rule worth knowing, because it tells you what you're getting: a
|
|
181
|
+
typed field is named after the Ruby class of its value, so `IntegerField`
|
|
182
|
+
hands back an `Integer` and `FloatField` a `Float` — a binary double, which
|
|
183
|
+
makes it exactly the wrong field for money. It parses generously while you
|
|
184
|
+
type: a buffer of `1.` already reads as `1.0`, so reaching for the decimal
|
|
185
|
+
point doesn't blink the value to `nil` and back in the listener you wired.
|
|
186
|
+
|
|
187
|
+
Money gets {Tuile::Component::BigDecimalField}, the same field once more with
|
|
188
|
+
an exact decimal inside — type `0.1` and it is `0.1`, not the `0.1000…0055`
|
|
189
|
+
a binary double stores. It is strict about how that exactness is preserved:
|
|
190
|
+
assigning a `Float` raises rather than quietly converting, because by the time
|
|
191
|
+
`19.99` reaches the setter it is already not `19.99`. This is the one
|
|
192
|
+
component with a dependency Tuile itself doesn't carry — `bigdecimal` has
|
|
193
|
+
been a *bundled* gem since Ruby 3.4, so an app that uses this field names it
|
|
194
|
+
in its own `Gemfile`, and an app that doesn't never loads it.
|
|
195
|
+
|
|
196
|
+
The combo box and the two numeric fields are built the same way, and it's
|
|
179
197
|
worth seeing why: each *wraps* a text field rather than *being* one. A
|
|
180
198
|
subclass would inherit the text field's `String`-typed value and wear it
|
|
181
199
|
on its face right next to the real typed one — two conflicting answers to
|
|
@@ -272,15 +290,22 @@ cb.toggle # unchecks it, firing the listener with false
|
|
|
272
290
|
|
|
273
291
|
Two of its choices are worth understanding, because they're really
|
|
274
292
|
statements about how Tuile widgets behave in general. The first: **Enter
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
293
|
+
toggles it, just as Space does.** Space is the native gesture for flipping
|
|
294
|
+
a checkbox, and for a while Enter was deliberately left alone — a checkbox
|
|
295
|
+
has no action to confirm. What settled it is the checkbox group further
|
|
296
|
+
down this chapter: a checkable row inside a list flips on Enter, because
|
|
297
|
+
Enter is how a list chooses the row under its cursor. Had the standalone
|
|
298
|
+
widget stayed silent, the same `[ ] Verbose` would have responded to Enter
|
|
299
|
+
in a group and ignored it in a form, which is a distinction the person at
|
|
300
|
+
the keyboard has no way to see.
|
|
301
|
+
|
|
302
|
+
The consequence is worth stating plainly, because it's the general rule
|
|
303
|
+
hiding behind the specific choice: a focused checkbox *consumes* Enter, so
|
|
304
|
+
a form's submit button on an ancestor won't see it. That's not a
|
|
305
|
+
regression from some guarantee — the framework never kept Enter clear, and
|
|
306
|
+
chapter 5's table shows why it can't: a text area claims Enter for a
|
|
307
|
+
newline, a button claims it to activate itself. Whether Enter reaches your
|
|
308
|
+
form always depends on the widget that has focus.
|
|
284
309
|
|
|
285
310
|
The second is about *where the widget actually is*. A form column will
|
|
286
311
|
happily hand a checkbox forty columns for a caption that needs twenty-two,
|
|
@@ -412,6 +437,76 @@ one cell, and keeps the set of characters riding on that bet small enough
|
|
|
412
437
|
to enumerate, so a new widget reaches for ASCII and offers the pretty
|
|
413
438
|
glyph only where someone can opt in knowing their terminal.
|
|
414
439
|
|
|
440
|
+
A radio group spends a row per option, permanently. When the form has six
|
|
441
|
+
of these and a terminal has twenty-four rows, that arithmetic stops
|
|
442
|
+
working, and {Tuile::Component::Select} is the same single answer on *one*
|
|
443
|
+
row: the selected label plus a `▾`, with the options appearing only while
|
|
444
|
+
you're choosing between them.
|
|
445
|
+
|
|
446
|
+
```ruby
|
|
447
|
+
level = Component::Select.new(items: %w[debug info warn error], value: "warn")
|
|
448
|
+
level.on_value_change = ->(l) { logger.level = l }
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
Enter, Space or Down opens the dropdown, the arrows move the highlight,
|
|
452
|
+
Enter or Space commits, ESC closes it having changed nothing. `value` is
|
|
453
|
+
the selected item as always, `nil` while nothing is selected — and that
|
|
454
|
+
`nil` is a perfectly ordinary state here, which is why there's no
|
|
455
|
+
placeholder text: an optional enum field simply shows a blank face.
|
|
456
|
+
|
|
457
|
+
So when do you reach for which? The temptation is to decide by item count,
|
|
458
|
+
and that's the wrong axis. Ask instead **who wrote the labels**:
|
|
459
|
+
|
|
460
|
+
| The options are… | Widget | Why |
|
|
461
|
+
|---|---|---|
|
|
462
|
+
| a developer-authored enum, on one form row | `Select` | one row; borrows *n* transiently |
|
|
463
|
+
| the same enum, worth comparing side by side | `RadioGroup` | spends *n* rows permanently |
|
|
464
|
+
| supplied by the app, open-ended, labels you don't control | `ComboBox` | filtering *is* the navigation |
|
|
465
|
+
| an enum, several of which apply | `CheckboxGroup` | a frozen `Set` value |
|
|
466
|
+
|
|
467
|
+
A select is for a closed set you knew when you wrote the code — log level,
|
|
468
|
+
sort order, line endings, Yes/No/Ask. A combo box is for countries, users,
|
|
469
|
+
branches: data. A twelve-value enum is still a select, and a three-row
|
|
470
|
+
country list loaded from a database is still a combo box, because next
|
|
471
|
+
release it's two hundred rows and the widget you chose shouldn't have to
|
|
472
|
+
change. Count is a symptom; authorship is the criterion.
|
|
473
|
+
|
|
474
|
+
Which brings up the property that really separates the two, and it's not
|
|
475
|
+
the filtering. **A select claims no printable key but Space.** Every other
|
|
476
|
+
letter and digit bubbles straight past it, up the focus chain, to your
|
|
477
|
+
application — so a form's `s`-to-save, or a layout's `1`/`2`/`3` jumps
|
|
478
|
+
between panes, keep working while focus sits in a select. A combo box can
|
|
479
|
+
never offer that: its field must eat every printable, because every
|
|
480
|
+
printable is potentially part of the query. Add the fact that a select has
|
|
481
|
+
no caret, and the two together are the whole case for the component. A
|
|
482
|
+
caret is the strongest promise a terminal can make about what a widget
|
|
483
|
+
does, and spending it on "you may type free text here" over a four-value
|
|
484
|
+
enum is a lie the user then has to discover.
|
|
485
|
+
|
|
486
|
+
Space is the one exception, and it's a safe one precisely because Space was
|
|
487
|
+
never yours to begin with: every activatable widget in Tuile already claims
|
|
488
|
+
it — a button, a checkbox, a radio group. Home and End, by contrast, are
|
|
489
|
+
declined, so they stay available for you to bind app-wide.
|
|
490
|
+
|
|
491
|
+
You may be waiting for type-ahead — press `f` and jump to the first item
|
|
492
|
+
starting with `f`, the way desktop lists do. It isn't there, deliberately.
|
|
493
|
+
The single-key version is silently wrong: with Finland, Fiji and Jamaica in
|
|
494
|
+
the list, typing `fij` selects *Jamaica*, because each key is a fresh
|
|
495
|
+
one-character match. The fix everyone reaches for next is a small
|
|
496
|
+
accumulating buffer that clears after a second of idleness — and that
|
|
497
|
+
buffer *is* the combo box's query with the display removed. If you're
|
|
498
|
+
holding query state, showing it is strictly better than hiding it, and
|
|
499
|
+
showing it is a combo box. On a terminal it's worse still: the timer leans
|
|
500
|
+
on inter-keystroke gaps, and gaps are exactly what a laggy SSH link or a
|
|
501
|
+
paste destroys.
|
|
502
|
+
|
|
503
|
+
One small nicety worth noticing: the dropdown is never narrower than the
|
|
504
|
+
select itself, and grows past it when a label needs the room — so its edges
|
|
505
|
+
line up with the face you clicked, and the labels are never the thing that
|
|
506
|
+
gets ellipsized. It opens below the select, flips above near the bottom of
|
|
507
|
+
the screen, slides left rather than running off the right edge, and grows a
|
|
508
|
+
scrollbar when there are more options than it can show.
|
|
509
|
+
|
|
415
510
|
For a discrete action rather than a selection, {Tuile::Component::Button}
|
|
416
511
|
is a one-row `[ caption ]` that fires `on_click` on Enter, Space, or a
|
|
417
512
|
left-click, highlighting its background while focused. It's a tab stop, so
|
data/book/README.md
CHANGED
|
@@ -52,7 +52,9 @@ one, not to fill an outline.
|
|
|
52
52
|
the design. Top-down, absolute, integer coordinates; a parent
|
|
53
53
|
assigns its children's `rect` and components never negotiate a size.
|
|
54
54
|
The C64 argument for *why simple layouting is enough* on a character
|
|
55
|
-
grid, `Layout::Absolute` and the `rect=` override, `
|
|
55
|
+
grid, `Layout::Absolute` and the `rect=` override, the `Vertical` /
|
|
56
|
+
`Horizontal` box layouts and their three constraints (`Fixed` /
|
|
57
|
+
`Percent` / `Expand`) as sugar over that same rule, `Fraction` for
|
|
56
58
|
sizing a popup against the screen, and resize as a discrete
|
|
57
59
|
recompute. Geometry primitives (`Point` / `Size` / `Rect`) live here.
|
|
58
60
|
4. **[The event loop and background work](04-event-loop.md).** The
|