@uniflowed/tui 0.0.0-alpha.18
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.
- package/capability.js +366 -0
- package/cells.js +332 -0
- package/components.js +602 -0
- package/diff.js +286 -0
- package/index.js +206 -0
- package/internal/hits.js +156 -0
- package/internal/host.js +928 -0
- package/internal/paint.js +664 -0
- package/internal/tree.js +373 -0
- package/keys.js +585 -0
- package/layout.js +833 -0
- package/mouse.js +333 -0
- package/package.json +36 -0
- package/selection.js +101 -0
- package/terminal.js +457 -0
- package/widths.js +201 -0
package/layout.js
ADDED
|
@@ -0,0 +1,833 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Flexbox, in whole cells.
|
|
4
|
+
//
|
|
5
|
+
// OpenTUI lays a terminal out with Yoga, and its documented defaults are
|
|
6
|
+
// flexbox's with one change: `flexDirection` starts at `"column"`, because a
|
|
7
|
+
// terminal is a stack of lines and a row of them is the special case. Those
|
|
8
|
+
// defaults are reproduced here exactly — `justifyContent: "flex-start"`,
|
|
9
|
+
// `alignItems: "stretch"`, `flexGrow: 0`, `flexBasis: "auto"`, and a
|
|
10
|
+
// `flexShrink` that is `0` when a dimension was given as a number and `1`
|
|
11
|
+
// otherwise — so a tree written against OpenTUI's documentation lays out the
|
|
12
|
+
// same way here.
|
|
13
|
+
//
|
|
14
|
+
// # Why not Yoga
|
|
15
|
+
//
|
|
16
|
+
// Yoga is a C++ library compiled to WebAssembly. Loading it costs a WASM
|
|
17
|
+
// instantiation before the first frame, which is the cost a terminal program
|
|
18
|
+
// can least afford: the whole visible difference between a fast CLI and a slow
|
|
19
|
+
// one is what happens before the first paint. It also solves a problem this
|
|
20
|
+
// does not have. Yoga resolves fractional CSS pixels, percentages of
|
|
21
|
+
// percentages, aspect ratios, and writing directions; a terminal has integer
|
|
22
|
+
// columns, one writing direction, and a tree whose depth is bounded by what
|
|
23
|
+
// fits on a screen.
|
|
24
|
+
//
|
|
25
|
+
// So this implements the subset a terminal uses, in whole cells. What it does
|
|
26
|
+
// *not* implement is written down at the bottom of this file rather than
|
|
27
|
+
// discovered: no wrapping, no absolute positioning, no aspect ratio, no `auto`
|
|
28
|
+
// margins. Those are ubugeeei-prod/uf#314.
|
|
29
|
+
//
|
|
30
|
+
// # Whole cells, and where the remainder goes
|
|
31
|
+
//
|
|
32
|
+
// Distributing 10 free columns between three growing children gives each of
|
|
33
|
+
// them 3⅓, and a terminal has no third of a column. Rounding each child
|
|
34
|
+
// independently loses a column when three of them round down, and a row that
|
|
35
|
+
// is one column short of its container is exactly the artefact that makes a
|
|
36
|
+
// hand-written terminal UI look broken. So the remainder is *carried*: each
|
|
37
|
+
// child gets the floor of its running exact total minus what has already been
|
|
38
|
+
// handed out, which means the sum of the parts is always the whole, and the
|
|
39
|
+
// column that could not be divided lands on one specific child rather than
|
|
40
|
+
// vanishing.
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A length: a number of cells, a percentage of the containing block, or
|
|
44
|
+
* `"auto"` for "as large as the content needs".
|
|
45
|
+
*/
|
|
46
|
+
export type Dimension = number | string;
|
|
47
|
+
|
|
48
|
+
/** Main-axis direction. `"column"` is the default, as in OpenTUI. */
|
|
49
|
+
export type FlexDirection = "row" | "row-reverse" | "column" | "column-reverse";
|
|
50
|
+
|
|
51
|
+
/** Main-axis distribution. */
|
|
52
|
+
export type JustifyContent =
|
|
53
|
+
| "flex-start"
|
|
54
|
+
| "center"
|
|
55
|
+
| "flex-end"
|
|
56
|
+
| "space-between"
|
|
57
|
+
| "space-around"
|
|
58
|
+
| "space-evenly";
|
|
59
|
+
|
|
60
|
+
/** Cross-axis alignment of every child. */
|
|
61
|
+
export type AlignItems = "flex-start" | "center" | "flex-end" | "stretch";
|
|
62
|
+
|
|
63
|
+
/** Cross-axis alignment of one child, or `"auto"` to follow the parent. */
|
|
64
|
+
export type AlignSelf = "auto" | AlignItems;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* What happens to content larger than its box.
|
|
68
|
+
*
|
|
69
|
+
* `"scroll"` is `"hidden"` plus an offset: the children are stacked at their
|
|
70
|
+
* own heights, the box shows a window onto them, and `scrollTop` says which
|
|
71
|
+
* rows. It is the only value that changes how children are *placed* rather
|
|
72
|
+
* than only what is drawn, which is why the scroll layout is a branch of
|
|
73
|
+
* {@link layout} rather than a flag the painter reads.
|
|
74
|
+
*/
|
|
75
|
+
export type Overflow = "visible" | "hidden" | "scroll";
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Everything layout reads off a node.
|
|
79
|
+
*
|
|
80
|
+
* Every field is optional and every default is flexbox's, so a node with no
|
|
81
|
+
* style at all is a column that grows to fit its content — which is what a
|
|
82
|
+
* caller who wrote `<Box>` meant.
|
|
83
|
+
*/
|
|
84
|
+
export type LayoutStyle = {
|
|
85
|
+
readonly flexDirection?: FlexDirection,
|
|
86
|
+
readonly justifyContent?: JustifyContent,
|
|
87
|
+
readonly alignItems?: AlignItems,
|
|
88
|
+
readonly alignSelf?: AlignSelf,
|
|
89
|
+
readonly flexGrow?: number,
|
|
90
|
+
readonly flexShrink?: number,
|
|
91
|
+
readonly flexBasis?: Dimension,
|
|
92
|
+
readonly width?: Dimension,
|
|
93
|
+
readonly height?: Dimension,
|
|
94
|
+
readonly minWidth?: Dimension,
|
|
95
|
+
readonly minHeight?: Dimension,
|
|
96
|
+
readonly maxWidth?: Dimension,
|
|
97
|
+
readonly maxHeight?: Dimension,
|
|
98
|
+
readonly padding?: number,
|
|
99
|
+
readonly paddingTop?: number,
|
|
100
|
+
readonly paddingRight?: number,
|
|
101
|
+
readonly paddingBottom?: number,
|
|
102
|
+
readonly paddingLeft?: number,
|
|
103
|
+
readonly margin?: number,
|
|
104
|
+
readonly marginTop?: number,
|
|
105
|
+
readonly marginRight?: number,
|
|
106
|
+
readonly marginBottom?: number,
|
|
107
|
+
readonly marginLeft?: number,
|
|
108
|
+
readonly gap?: number,
|
|
109
|
+
readonly rowGap?: number,
|
|
110
|
+
readonly columnGap?: number,
|
|
111
|
+
readonly overflow?: Overflow,
|
|
112
|
+
/**
|
|
113
|
+
* The first content row a scrolling box shows.
|
|
114
|
+
*
|
|
115
|
+
* Read only when `overflow` is `"scroll"`, and clamped by layout to the
|
|
116
|
+
* range the content actually has — so `Number.MAX_SAFE_INTEGER` means "the
|
|
117
|
+
* bottom" and needs no separate prop, and a caller that has just appended a
|
|
118
|
+
* line to a log does not have to know how long the log is to follow it.
|
|
119
|
+
*/
|
|
120
|
+
readonly scrollTop?: number,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* A node laid out by this module.
|
|
125
|
+
*
|
|
126
|
+
* `measure` is how a leaf that knows its own size — a run of text, whose
|
|
127
|
+
* height depends on the width it is given — participates without layout
|
|
128
|
+
* knowing what text is. It is Yoga's measure callback under a shorter name.
|
|
129
|
+
*
|
|
130
|
+
* The four geometry fields are written *by* layout and read by the painter.
|
|
131
|
+
* They are the node's border box in absolute frame coordinates.
|
|
132
|
+
*/
|
|
133
|
+
export type LayoutNode = {
|
|
134
|
+
style: LayoutStyle,
|
|
135
|
+
children: Array<LayoutNode>,
|
|
136
|
+
/** Cells the node's own frame occupies on each edge; a border is 1. */
|
|
137
|
+
borderWidth: number,
|
|
138
|
+
measure: ((availableWidth: number, availableHeight: number) => Size) | null,
|
|
139
|
+
x: number,
|
|
140
|
+
y: number,
|
|
141
|
+
width: number,
|
|
142
|
+
height: number,
|
|
143
|
+
/**
|
|
144
|
+
* The index of this node's first child that is inside a scrolling window,
|
|
145
|
+
* and how many of them are. Written by layout, read by the painter.
|
|
146
|
+
*
|
|
147
|
+
* Zero and zero for everything that does not scroll, and for a scrolling box
|
|
148
|
+
* whose window has reached past the end of its content. The painter walks
|
|
149
|
+
* this range instead of the whole child list, which is the half of "only the
|
|
150
|
+
* visible window" that paint is responsible for: a child outside the range
|
|
151
|
+
* has geometry from whichever frame last showed it, and drawing that would
|
|
152
|
+
* put last frame's rows on top of this one's.
|
|
153
|
+
*/
|
|
154
|
+
scrollFirst: number,
|
|
155
|
+
scrollCount: number,
|
|
156
|
+
/** Rows of content a scrolling box holds. Written by layout. */
|
|
157
|
+
scrollHeight: number,
|
|
158
|
+
/** The first row it is actually showing, after clamping. Written by layout. */
|
|
159
|
+
scrollOffset: number,
|
|
160
|
+
/**
|
|
161
|
+
* Where the window is, in frame coordinates: its first row, how many rows it
|
|
162
|
+
* has, and the column the bar goes in.
|
|
163
|
+
*
|
|
164
|
+
* Written by layout because layout is what resolved the padding, and the
|
|
165
|
+
* painter must not resolve it a second time — a bar drawn against the border
|
|
166
|
+
* box rather than the content box is a bar over the content on any box with
|
|
167
|
+
* padding on it.
|
|
168
|
+
*/
|
|
169
|
+
scrollViewTop: number,
|
|
170
|
+
scrollViewRows: number,
|
|
171
|
+
scrollBarColumn: number,
|
|
172
|
+
/**
|
|
173
|
+
* The last intrinsic size this node reported, and what was offered for it.
|
|
174
|
+
*
|
|
175
|
+
* `measuredFor*` is `-1` when there is nothing cached, which is what
|
|
176
|
+
* `invalidate` in `internal/tree.js` writes when anything under the node
|
|
177
|
+
* changes. Layout never invalidates this itself: a cache that layout could
|
|
178
|
+
* clear would be cleared on the frame that most needs it.
|
|
179
|
+
*/
|
|
180
|
+
measuredForWidth: number,
|
|
181
|
+
measuredForHeight: number,
|
|
182
|
+
measuredWidth: number,
|
|
183
|
+
measuredHeight: number,
|
|
184
|
+
/**
|
|
185
|
+
* The first child index whose height may have changed since the last frame,
|
|
186
|
+
* or `-1` when none has.
|
|
187
|
+
*
|
|
188
|
+
* Written by whoever changes the tree — `internal/tree.js`, which is to say
|
|
189
|
+
* React — and cleared by layout once it has acted on it. It is a number
|
|
190
|
+
* rather than a call into this module because the three participants named
|
|
191
|
+
* at the top of `internal/tree.js` do not import each other; a node's fields
|
|
192
|
+
* are the whole of what they say to one another.
|
|
193
|
+
*/
|
|
194
|
+
scrollDirtyFrom: number,
|
|
195
|
+
/** A scrolling box's stack of child heights. Owned by {@link layout}. */
|
|
196
|
+
scrollIndex: ScrollIndex | null,
|
|
197
|
+
...
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Where each child of a scrolling box sits in its content, kept between frames.
|
|
202
|
+
*
|
|
203
|
+
* This is what makes scrolling cost the window rather than the content. The
|
|
204
|
+
* stack of child heights does not change when the offset does, so rebuilding
|
|
205
|
+
* it on every frame would be recomputing the answer to a question nobody
|
|
206
|
+
* asked — and it is the only part of a scrolling box that is proportional to
|
|
207
|
+
* how many children it has.
|
|
208
|
+
*
|
|
209
|
+
* `from` is the first index that has to be rebuilt, and it is written from
|
|
210
|
+
* outside layout: `internal/tree.js` sets it when React mutates the tree, and
|
|
211
|
+
* sets it to the old child count when the mutation was an append, which is the
|
|
212
|
+
* shape a log has. A frame that changed nothing leaves it at the child count
|
|
213
|
+
* and rebuilds none of it.
|
|
214
|
+
*
|
|
215
|
+
* `tops[i]` is where child `i`'s border box starts, measured from the top of
|
|
216
|
+
* the content and including every margin and gap above it; `heights[i]` is how
|
|
217
|
+
* tall it is.
|
|
218
|
+
*/
|
|
219
|
+
export type ScrollIndex = {
|
|
220
|
+
/** The content width, viewport height and gap the stack was built for. */
|
|
221
|
+
width: number,
|
|
222
|
+
view: number,
|
|
223
|
+
gap: number,
|
|
224
|
+
/** The first index whose height is not known to be current. */
|
|
225
|
+
from: number,
|
|
226
|
+
tops: Array<number>,
|
|
227
|
+
heights: Array<number>,
|
|
228
|
+
/** Rows of content the whole stack adds up to. */
|
|
229
|
+
content: number,
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
/** A resolved size, in whole cells. */
|
|
233
|
+
export type Size = { readonly width: number, readonly height: number };
|
|
234
|
+
|
|
235
|
+
const clamp = (value: number, low: number, high: number): number =>
|
|
236
|
+
Math.min(Math.max(value, low), high);
|
|
237
|
+
|
|
238
|
+
/** Whether the main axis is horizontal. */
|
|
239
|
+
const isRow = (direction: FlexDirection): boolean =>
|
|
240
|
+
direction === "row" || direction === "row-reverse";
|
|
241
|
+
|
|
242
|
+
const direction = (style: LayoutStyle): FlexDirection => style.flexDirection ?? "column";
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Resolve a length against the space its containing block offers.
|
|
246
|
+
*
|
|
247
|
+
* Returns `null` for `"auto"` and for anything unrecognised, which is layout's
|
|
248
|
+
* word for "ask the content". Percentages round to the nearest cell: half a
|
|
249
|
+
* column does not exist, and the alternative — truncating — makes `50%` of an
|
|
250
|
+
* odd width lose a column that `50%` on the other side does not gain.
|
|
251
|
+
*/
|
|
252
|
+
function resolve(value: Dimension | void, basis: number): number | null {
|
|
253
|
+
if (typeof value === "number") {
|
|
254
|
+
return Math.max(0, Math.round(value));
|
|
255
|
+
}
|
|
256
|
+
if (typeof value === "string" && value.endsWith("%")) {
|
|
257
|
+
const percent = Number.parseFloat(value.slice(0, -1));
|
|
258
|
+
return Number.isFinite(percent) ? Math.max(0, Math.round((basis * percent) / 100)) : null;
|
|
259
|
+
}
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Padding on each edge, with the shorthand applied first. */
|
|
264
|
+
function padding(style: LayoutStyle): [number, number, number, number] {
|
|
265
|
+
const all = style.padding ?? 0;
|
|
266
|
+
return [
|
|
267
|
+
style.paddingTop ?? all,
|
|
268
|
+
style.paddingRight ?? all,
|
|
269
|
+
style.paddingBottom ?? all,
|
|
270
|
+
style.paddingLeft ?? all,
|
|
271
|
+
];
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Margin on each edge, with the shorthand applied first. */
|
|
275
|
+
function margin(style: LayoutStyle): [number, number, number, number] {
|
|
276
|
+
const all = style.margin ?? 0;
|
|
277
|
+
return [
|
|
278
|
+
style.marginTop ?? all,
|
|
279
|
+
style.marginRight ?? all,
|
|
280
|
+
style.marginBottom ?? all,
|
|
281
|
+
style.marginLeft ?? all,
|
|
282
|
+
];
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** The cells between two children along the main axis. */
|
|
286
|
+
function gapOf(style: LayoutStyle, row: boolean): number {
|
|
287
|
+
const specific = row ? style.columnGap : style.rowGap;
|
|
288
|
+
return specific ?? style.gap ?? 0;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The cells a node's own frame and padding take out of its border box.
|
|
293
|
+
*
|
|
294
|
+
* Returned as `[top, right, bottom, left]`, border and padding summed,
|
|
295
|
+
* because every caller wants the total and none of them wants to add it up
|
|
296
|
+
* again.
|
|
297
|
+
*/
|
|
298
|
+
function insets(node: LayoutNode): [number, number, number, number] {
|
|
299
|
+
const [top, right, bottom, left] = padding(node.style);
|
|
300
|
+
const border = node.borderWidth;
|
|
301
|
+
return [top + border, right + border, bottom + border, left + border];
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* `flexShrink`'s default, which is not a constant.
|
|
306
|
+
*
|
|
307
|
+
* CSS says `1`; OpenTUI says `0` for a child whose main-axis dimension was
|
|
308
|
+
* given as a number, and it is right to. A caller who wrote `width: 40` on a
|
|
309
|
+
* terminal box meant forty columns, and a default that quietly narrows it when
|
|
310
|
+
* the row is full produces a box whose width is 40 on one terminal and 37 on
|
|
311
|
+
* another. A caller who wrote nothing has expressed no such intent.
|
|
312
|
+
*/
|
|
313
|
+
function shrinkOf(style: LayoutStyle, row: boolean): number {
|
|
314
|
+
if (style.flexShrink != null) {
|
|
315
|
+
return style.flexShrink;
|
|
316
|
+
}
|
|
317
|
+
const main = row ? style.width : style.height;
|
|
318
|
+
return typeof main === "number" ? 0 : 1;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* The size a node wants when nothing constrains it.
|
|
323
|
+
*
|
|
324
|
+
* `available` is what the parent can offer, and it is passed down rather than
|
|
325
|
+
* ignored because a text leaf's height is a function of the width it is given:
|
|
326
|
+
* the same paragraph is one line at 80 columns and four at 20. This is the
|
|
327
|
+
* pass Yoga calls "measure", and it is separate from layout because a parent
|
|
328
|
+
* has to know how large its children want to be before it can decide how large
|
|
329
|
+
* they get to be.
|
|
330
|
+
*/
|
|
331
|
+
export function intrinsicSize(
|
|
332
|
+
node: LayoutNode,
|
|
333
|
+
availableWidth: number,
|
|
334
|
+
availableHeight: number,
|
|
335
|
+
): Size {
|
|
336
|
+
// The same offer twice is the same answer twice, and the answer is only
|
|
337
|
+
// stale when something under the node changed — which is a fact React knows
|
|
338
|
+
// and layout does not, so `internal/tree.js` is what clears this. Without
|
|
339
|
+
// it, a scrolling box that has not changed re-measures every child on every
|
|
340
|
+
// frame, and measuring a line of text means walking it grapheme by grapheme.
|
|
341
|
+
if (node.measuredForWidth === availableWidth && node.measuredForHeight === availableHeight) {
|
|
342
|
+
return { width: node.measuredWidth, height: node.measuredHeight };
|
|
343
|
+
}
|
|
344
|
+
const size = measureIntrinsic(node, availableWidth, availableHeight);
|
|
345
|
+
node.measuredForWidth = availableWidth;
|
|
346
|
+
node.measuredForHeight = availableHeight;
|
|
347
|
+
node.measuredWidth = size.width;
|
|
348
|
+
node.measuredHeight = size.height;
|
|
349
|
+
return size;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function measureIntrinsic(node: LayoutNode, availableWidth: number, availableHeight: number): Size {
|
|
353
|
+
const style = node.style;
|
|
354
|
+
const [insetTop, insetRight, insetBottom, insetLeft] = insets(node);
|
|
355
|
+
const innerAvailableWidth = Math.max(0, availableWidth - insetLeft - insetRight);
|
|
356
|
+
const innerAvailableHeight = Math.max(0, availableHeight - insetTop - insetBottom);
|
|
357
|
+
|
|
358
|
+
const fixedWidth = resolve(style.width, availableWidth);
|
|
359
|
+
const fixedHeight = resolve(style.height, availableHeight);
|
|
360
|
+
|
|
361
|
+
let contentWidth = 0;
|
|
362
|
+
let contentHeight = 0;
|
|
363
|
+
|
|
364
|
+
if (node.measure != null) {
|
|
365
|
+
const measured = node.measure(
|
|
366
|
+
fixedWidth != null ? Math.max(0, fixedWidth - insetLeft - insetRight) : innerAvailableWidth,
|
|
367
|
+
innerAvailableHeight,
|
|
368
|
+
);
|
|
369
|
+
contentWidth = measured.width;
|
|
370
|
+
contentHeight = measured.height;
|
|
371
|
+
} else if (style.overflow === "scroll") {
|
|
372
|
+
// A scrolling box's height is its content's, which the stack already
|
|
373
|
+
// knows; and its width is whatever it was offered, because there is no
|
|
374
|
+
// horizontal scrolling and so nothing about a child can widen it. Asking
|
|
375
|
+
// the children for a width would also be asking ten thousand of them, and
|
|
376
|
+
// the answer would be the width of a row that may be nowhere near the
|
|
377
|
+
// window — which is the coupling this whole component exists to break.
|
|
378
|
+
contentWidth = innerAvailableWidth;
|
|
379
|
+
// A box that was given a height has already answered this, and the answer
|
|
380
|
+
// below is thrown away — so the stack is not consulted for it. That also
|
|
381
|
+
// avoids keying one on a viewport this pass can only guess at, which
|
|
382
|
+
// `layout` would then have to rebuild whole at the height it settles on.
|
|
383
|
+
contentHeight =
|
|
384
|
+
fixedHeight != null
|
|
385
|
+
? 0
|
|
386
|
+
: scrollStack(node, innerAvailableWidth, innerAvailableHeight).content;
|
|
387
|
+
} else {
|
|
388
|
+
const row = isRow(direction(node.style));
|
|
389
|
+
const gap = gapOf(style, row);
|
|
390
|
+
let main = 0;
|
|
391
|
+
let cross = 0;
|
|
392
|
+
let counted = 0;
|
|
393
|
+
for (const child of node.children) {
|
|
394
|
+
const [marginTop, marginRight, marginBottom, marginLeft] = margin(child.style);
|
|
395
|
+
const size = intrinsicSize(
|
|
396
|
+
child,
|
|
397
|
+
Math.max(0, innerAvailableWidth - marginLeft - marginRight),
|
|
398
|
+
Math.max(0, innerAvailableHeight - marginTop - marginBottom),
|
|
399
|
+
);
|
|
400
|
+
const outerWidth = size.width + marginLeft + marginRight;
|
|
401
|
+
const outerHeight = size.height + marginTop + marginBottom;
|
|
402
|
+
main += row ? outerWidth : outerHeight;
|
|
403
|
+
cross = Math.max(cross, row ? outerHeight : outerWidth);
|
|
404
|
+
counted += 1;
|
|
405
|
+
}
|
|
406
|
+
main += Math.max(0, counted - 1) * gap;
|
|
407
|
+
contentWidth = row ? main : cross;
|
|
408
|
+
contentHeight = row ? cross : main;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const width = fixedWidth ?? contentWidth + insetLeft + insetRight;
|
|
412
|
+
const height = fixedHeight ?? contentHeight + insetTop + insetBottom;
|
|
413
|
+
return {
|
|
414
|
+
width: clampDimension(width, style.minWidth, style.maxWidth, availableWidth),
|
|
415
|
+
height: clampDimension(height, style.minHeight, style.maxHeight, availableHeight),
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Apply `min*`/`max*` to a resolved length. */
|
|
420
|
+
function clampDimension(
|
|
421
|
+
value: number,
|
|
422
|
+
min: Dimension | void,
|
|
423
|
+
max: Dimension | void,
|
|
424
|
+
basis: number,
|
|
425
|
+
): number {
|
|
426
|
+
const low = resolve(min, basis) ?? 0;
|
|
427
|
+
const high = resolve(max, basis) ?? Number.MAX_SAFE_INTEGER;
|
|
428
|
+
return clamp(Math.max(0, Math.round(value)), low, high);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Hand out `total` cells in the proportions `weights` asks for, losing none.
|
|
433
|
+
*
|
|
434
|
+
* The carry is the whole point. Each recipient gets the floor of the running
|
|
435
|
+
* exact total minus everything already handed out, so the parts sum to the
|
|
436
|
+
* whole for every input rather than for the inputs that happen to divide.
|
|
437
|
+
*/
|
|
438
|
+
function distribute(total: number, weights: $ReadOnlyArray<number>): Array<number> {
|
|
439
|
+
const sum = weights.reduce((a, b) => a + b, 0);
|
|
440
|
+
const out = new Array(weights.length).fill(0);
|
|
441
|
+
if (sum <= 0 || total === 0) {
|
|
442
|
+
return out;
|
|
443
|
+
}
|
|
444
|
+
let exact = 0;
|
|
445
|
+
let handed = 0;
|
|
446
|
+
for (let i = 0; i < weights.length; i += 1) {
|
|
447
|
+
exact += (total * weights[i]) / sum;
|
|
448
|
+
const next = Math.round(exact);
|
|
449
|
+
out[i] = next - handed;
|
|
450
|
+
handed = next;
|
|
451
|
+
}
|
|
452
|
+
return out;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Lay `node` out into the border box at `x`, `y`, `width` by `height`.
|
|
457
|
+
*
|
|
458
|
+
* Writes `x`, `y`, `width` and `height` onto every node in the subtree. The
|
|
459
|
+
* caller decides the root's box, which for a terminal is the whole screen.
|
|
460
|
+
*/
|
|
461
|
+
export function layout(
|
|
462
|
+
node: LayoutNode,
|
|
463
|
+
x: number,
|
|
464
|
+
y: number,
|
|
465
|
+
width: number,
|
|
466
|
+
height: number,
|
|
467
|
+
): void {
|
|
468
|
+
node.x = x;
|
|
469
|
+
node.y = y;
|
|
470
|
+
node.width = width;
|
|
471
|
+
node.height = height;
|
|
472
|
+
// Cleared before the branch that may set it, so that a box which has run out
|
|
473
|
+
// of children does not leave the painter a range into a list that no longer
|
|
474
|
+
// has those rows in it.
|
|
475
|
+
node.scrollFirst = 0;
|
|
476
|
+
node.scrollCount = 0;
|
|
477
|
+
|
|
478
|
+
if (node.children.length === 0) {
|
|
479
|
+
// A scrolling box that has lost its content has nothing to say about where
|
|
480
|
+
// in it the window is, and a bar drawn from what it said last frame is a
|
|
481
|
+
// control pointing into rows that are gone.
|
|
482
|
+
node.scrollHeight = 0;
|
|
483
|
+
node.scrollOffset = 0;
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const style = node.style;
|
|
488
|
+
const [insetTop, insetRight, insetBottom, insetLeft] = insets(node);
|
|
489
|
+
const contentX = x + insetLeft;
|
|
490
|
+
const contentY = y + insetTop;
|
|
491
|
+
const contentWidth = Math.max(0, width - insetLeft - insetRight);
|
|
492
|
+
const contentHeight = Math.max(0, height - insetTop - insetBottom);
|
|
493
|
+
|
|
494
|
+
if (style.overflow === "scroll") {
|
|
495
|
+
layoutScroll(node, contentX, contentY, contentWidth, contentHeight);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const flexDirection = direction(style);
|
|
500
|
+
const row = isRow(flexDirection);
|
|
501
|
+
const reverse = flexDirection === "row-reverse" || flexDirection === "column-reverse";
|
|
502
|
+
const mainSpace = row ? contentWidth : contentHeight;
|
|
503
|
+
const crossSpace = row ? contentHeight : contentWidth;
|
|
504
|
+
const gap = gapOf(style, row);
|
|
505
|
+
const children = node.children;
|
|
506
|
+
|
|
507
|
+
// Pass one: every child's base main size, and the outer margins around it.
|
|
508
|
+
const margins = children.map((child) => margin(child.style));
|
|
509
|
+
const bases = children.map((child, index) => {
|
|
510
|
+
const [marginTop, marginRight, marginBottom, marginLeft] = margins[index];
|
|
511
|
+
const availableWidth = Math.max(0, contentWidth - marginLeft - marginRight);
|
|
512
|
+
const availableHeight = Math.max(0, contentHeight - marginTop - marginBottom);
|
|
513
|
+
const basis = resolve(child.style.flexBasis, mainSpace);
|
|
514
|
+
if (basis != null) {
|
|
515
|
+
return basis;
|
|
516
|
+
}
|
|
517
|
+
const fixed = resolve(row ? child.style.width : child.style.height, mainSpace);
|
|
518
|
+
if (fixed != null) {
|
|
519
|
+
return fixed;
|
|
520
|
+
}
|
|
521
|
+
const size = intrinsicSize(child, availableWidth, availableHeight);
|
|
522
|
+
return row ? size.width : size.height;
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
const outerMain = (index: number): number => {
|
|
526
|
+
const [marginTop, marginRight, marginBottom, marginLeft] = margins[index];
|
|
527
|
+
return bases[index] + (row ? marginLeft + marginRight : marginTop + marginBottom);
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
const used =
|
|
531
|
+
children.reduce((total, _, index) => total + outerMain(index), 0) +
|
|
532
|
+
Math.max(0, children.length - 1) * gap;
|
|
533
|
+
const free = mainSpace - used;
|
|
534
|
+
|
|
535
|
+
// Pass two: grow into the space left over, or shrink to fit into what there
|
|
536
|
+
// is. Shrinking is weighted by the base size as CSS specifies, so a wide
|
|
537
|
+
// child gives up more columns than a narrow one with the same `flexShrink`.
|
|
538
|
+
const mainSizes = bases.slice();
|
|
539
|
+
if (free > 0) {
|
|
540
|
+
const grow = children.map((child) => Math.max(0, child.style.flexGrow ?? 0));
|
|
541
|
+
const shares = distribute(free, grow);
|
|
542
|
+
for (let i = 0; i < children.length; i += 1) {
|
|
543
|
+
mainSizes[i] += shares[i];
|
|
544
|
+
}
|
|
545
|
+
} else if (free < 0) {
|
|
546
|
+
const weights = children.map((child, index) => shrinkOf(child.style, row) * bases[index]);
|
|
547
|
+
const shares = distribute(-free, weights);
|
|
548
|
+
for (let i = 0; i < children.length; i += 1) {
|
|
549
|
+
mainSizes[i] = Math.max(0, mainSizes[i] - shares[i]);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Whatever main-axis space the children did not take, `justifyContent`
|
|
554
|
+
// decides what to do with.
|
|
555
|
+
const consumed =
|
|
556
|
+
mainSizes.reduce(
|
|
557
|
+
(total, size, index) =>
|
|
558
|
+
total +
|
|
559
|
+
size +
|
|
560
|
+
(row ? margins[index][3] + margins[index][1] : margins[index][0] + margins[index][2]),
|
|
561
|
+
0,
|
|
562
|
+
) +
|
|
563
|
+
Math.max(0, children.length - 1) * gap;
|
|
564
|
+
const slack = Math.max(0, mainSpace - consumed);
|
|
565
|
+
const justify = style.justifyContent ?? "flex-start";
|
|
566
|
+
let cursor = 0;
|
|
567
|
+
let between = gap;
|
|
568
|
+
if (justify === "center") {
|
|
569
|
+
cursor = Math.floor(slack / 2);
|
|
570
|
+
} else if (justify === "flex-end") {
|
|
571
|
+
cursor = slack;
|
|
572
|
+
} else if (justify === "space-between" && children.length > 1) {
|
|
573
|
+
between = gap + Math.floor(slack / (children.length - 1));
|
|
574
|
+
} else if (justify === "space-around" && children.length > 0) {
|
|
575
|
+
const each = Math.floor(slack / children.length);
|
|
576
|
+
cursor = Math.floor(each / 2);
|
|
577
|
+
between = gap + each;
|
|
578
|
+
} else if (justify === "space-evenly" && children.length > 0) {
|
|
579
|
+
const each = Math.floor(slack / (children.length + 1));
|
|
580
|
+
cursor = each;
|
|
581
|
+
between = gap + each;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const order = reverse ? children.map((_, i) => i).reverse() : children.map((_, i) => i);
|
|
585
|
+
const parentAlign = style.alignItems ?? "stretch";
|
|
586
|
+
|
|
587
|
+
for (const index of order) {
|
|
588
|
+
const child = children[index];
|
|
589
|
+
const [marginTop, marginRight, marginBottom, marginLeft] = margins[index];
|
|
590
|
+
const mainMarginStart = row ? marginLeft : marginTop;
|
|
591
|
+
const mainMarginEnd = row ? marginRight : marginBottom;
|
|
592
|
+
const crossMarginStart = row ? marginTop : marginLeft;
|
|
593
|
+
const crossMarginEnd = row ? marginBottom : marginRight;
|
|
594
|
+
|
|
595
|
+
const align = (() => {
|
|
596
|
+
const own = child.style.alignSelf ?? "auto";
|
|
597
|
+
return own === "auto" ? parentAlign : own;
|
|
598
|
+
})();
|
|
599
|
+
|
|
600
|
+
const crossAvailable = Math.max(0, crossSpace - crossMarginStart - crossMarginEnd);
|
|
601
|
+
const fixedCross = resolve(row ? child.style.height : child.style.width, crossSpace);
|
|
602
|
+
let crossSize: number;
|
|
603
|
+
if (fixedCross != null) {
|
|
604
|
+
crossSize = fixedCross;
|
|
605
|
+
} else if (align === "stretch") {
|
|
606
|
+
crossSize = crossAvailable;
|
|
607
|
+
} else {
|
|
608
|
+
const size = intrinsicSize(
|
|
609
|
+
child,
|
|
610
|
+
row ? mainSizes[index] : crossAvailable,
|
|
611
|
+
row ? crossAvailable : mainSizes[index],
|
|
612
|
+
);
|
|
613
|
+
crossSize = row ? size.height : size.width;
|
|
614
|
+
}
|
|
615
|
+
crossSize = Math.min(crossSize, crossAvailable);
|
|
616
|
+
|
|
617
|
+
let crossOffset = crossMarginStart;
|
|
618
|
+
if (align === "center") {
|
|
619
|
+
crossOffset += Math.floor((crossAvailable - crossSize) / 2);
|
|
620
|
+
} else if (align === "flex-end") {
|
|
621
|
+
crossOffset += crossAvailable - crossSize;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const mainStart = cursor + mainMarginStart;
|
|
625
|
+
const childWidth = row ? mainSizes[index] : crossSize;
|
|
626
|
+
const childHeight = row ? crossSize : mainSizes[index];
|
|
627
|
+
const childX = row ? contentX + mainStart : contentX + crossOffset;
|
|
628
|
+
const childY = row ? contentY + crossOffset : contentY + mainStart;
|
|
629
|
+
|
|
630
|
+
layout(
|
|
631
|
+
child,
|
|
632
|
+
childX,
|
|
633
|
+
childY,
|
|
634
|
+
clampDimension(childWidth, child.style.minWidth, child.style.maxWidth, contentWidth),
|
|
635
|
+
clampDimension(childHeight, child.style.minHeight, child.style.maxHeight, contentHeight),
|
|
636
|
+
);
|
|
637
|
+
|
|
638
|
+
cursor = mainStart + mainSizes[index] + mainMarginEnd + between;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Lay a scrolling box's children out, and skip the ones nobody can see.
|
|
644
|
+
*
|
|
645
|
+
* A scrolling box is a column, always. `flexDirection`, `justifyContent` and
|
|
646
|
+
* growth do not apply inside one and are ignored rather than half-honoured: a
|
|
647
|
+
* child that grew to fill a viewport it is meant to scroll past is a child
|
|
648
|
+
* whose height depends on where it has been scrolled to. Margins do apply, and
|
|
649
|
+
* for the opposite reason — they are a fixed number of cells around a child,
|
|
650
|
+
* so they say the same thing at every offset.
|
|
651
|
+
*
|
|
652
|
+
* # What this costs, exactly
|
|
653
|
+
*
|
|
654
|
+
* A child is **measured** once — its height at this width — and the height is
|
|
655
|
+
* kept on the child until something under it changes. The total is what
|
|
656
|
+
* `scrollTop` is clamped against, so a box that guessed at its own content
|
|
657
|
+
* height would let `Number.MAX_SAFE_INTEGER` scroll into empty space; keeping
|
|
658
|
+
* the answer is how that total stays exact without being recomputed.
|
|
659
|
+
*
|
|
660
|
+
* Only the children that intersect the window are **laid out**, and the
|
|
661
|
+
* painter is given their range rather than a flag per child, so the rest get
|
|
662
|
+
* no position, no size, no walk into their subtrees and no visit at all. What
|
|
663
|
+
* remains proportional to the number of children is the stack of heights, and
|
|
664
|
+
* {@link ScrollIndex} rebuilds only the part of it that changed.
|
|
665
|
+
*
|
|
666
|
+
* So moving the window over ten thousand rows touches the rows in the window
|
|
667
|
+
* and nothing else, whatever the other nine thousand nine hundred and
|
|
668
|
+
* seventy-six are — which is the property the whole component exists for, and
|
|
669
|
+
* the reason this is not `overflow: "hidden"` with a margin on top.
|
|
670
|
+
*/
|
|
671
|
+
function layoutScroll(node: LayoutNode, x: number, y: number, width: number, height: number): void {
|
|
672
|
+
const children = node.children;
|
|
673
|
+
const stack = scrollStack(node, width, height);
|
|
674
|
+
const content = stack.content;
|
|
675
|
+
|
|
676
|
+
const offset = clamp(Math.floor(node.style.scrollTop ?? 0), 0, Math.max(0, content - height));
|
|
677
|
+
node.scrollHeight = content;
|
|
678
|
+
node.scrollOffset = offset;
|
|
679
|
+
node.scrollViewTop = y;
|
|
680
|
+
node.scrollViewRows = height;
|
|
681
|
+
// The column immediately right of the content, which is the one `ScrollBox`
|
|
682
|
+
// reserved by adding one to its right padding.
|
|
683
|
+
node.scrollBarColumn = x + width;
|
|
684
|
+
|
|
685
|
+
// The bottom of a child's border box only ever moves down the list, so the
|
|
686
|
+
// first child the window reaches can be found without looking at the ones
|
|
687
|
+
// above it. This is the step that would otherwise make the offset — the one
|
|
688
|
+
// thing about a scrolling box that changes every frame — cost the content.
|
|
689
|
+
const first = firstVisible(stack, offset);
|
|
690
|
+
let count = 0;
|
|
691
|
+
for (let index = first; index < children.length; index += 1) {
|
|
692
|
+
const top = stack.tops[index];
|
|
693
|
+
// The border box, not the outer one: a margin draws nothing, so a child
|
|
694
|
+
// whose own box has left the window has left it.
|
|
695
|
+
if (top - offset >= height) {
|
|
696
|
+
break;
|
|
697
|
+
}
|
|
698
|
+
const child = children[index];
|
|
699
|
+
const [, marginRight, , marginLeft] = margin(child.style);
|
|
700
|
+
const available = Math.max(0, width - marginLeft - marginRight);
|
|
701
|
+
const childWidth = Math.min(available, resolve(child.style.width, width) ?? available);
|
|
702
|
+
layout(child, x + marginLeft, y + top - offset, childWidth, stack.heights[index]);
|
|
703
|
+
count += 1;
|
|
704
|
+
}
|
|
705
|
+
node.scrollFirst = first;
|
|
706
|
+
node.scrollCount = count;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* The first child whose border box has not finished above `offset`.
|
|
711
|
+
*
|
|
712
|
+
* A binary search rather than a scan, because a scan is the thing this is
|
|
713
|
+
* replacing. `tops[i] + heights[i]` never decreases as `i` grows — every term
|
|
714
|
+
* between two children is a height, a margin or a gap, and none of those is
|
|
715
|
+
* negative — which is exactly the ordering a binary search needs.
|
|
716
|
+
*/
|
|
717
|
+
function firstVisible(stack: ScrollIndex, offset: number): number {
|
|
718
|
+
let low = 0;
|
|
719
|
+
let high = stack.heights.length;
|
|
720
|
+
while (low < high) {
|
|
721
|
+
const middle = (low + high) >> 1;
|
|
722
|
+
if (stack.tops[middle] + stack.heights[middle] > offset) {
|
|
723
|
+
high = middle;
|
|
724
|
+
} else {
|
|
725
|
+
low = middle + 1;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
return low;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Where each child of a scrolling box sits, rebuilding only what moved.
|
|
733
|
+
*
|
|
734
|
+
* Margins are part of the stack, exactly as they are in the flex path and in
|
|
735
|
+
* {@link intrinsicSize}: a child's outer height is what the next one starts
|
|
736
|
+
* after and what the scroll range is made of. Leaving them out of any one of
|
|
737
|
+
* those makes the content shorter than it is drawn, which is a bottom the
|
|
738
|
+
* offset clamps to too early and a last row nobody can scroll to.
|
|
739
|
+
*
|
|
740
|
+
* The width, the viewport height and the gap are part of the key rather than
|
|
741
|
+
* inputs to a patch: all three change every height in the stack at once, so
|
|
742
|
+
* there is nothing to salvage. The viewport is in there because a child may
|
|
743
|
+
* give its height as a percentage, and the percentage is of the window.
|
|
744
|
+
*/
|
|
745
|
+
function scrollStack(node: LayoutNode, width: number, height: number): ScrollIndex {
|
|
746
|
+
const children = node.children;
|
|
747
|
+
const gap = gapOf(node.style, false);
|
|
748
|
+
let stack = node.scrollIndex;
|
|
749
|
+
if (stack == null || stack.width !== width || stack.view !== height || stack.gap !== gap) {
|
|
750
|
+
stack = {
|
|
751
|
+
width,
|
|
752
|
+
view: height,
|
|
753
|
+
gap,
|
|
754
|
+
from: 0,
|
|
755
|
+
tops: [],
|
|
756
|
+
heights: [],
|
|
757
|
+
content: 0,
|
|
758
|
+
};
|
|
759
|
+
node.scrollIndex = stack;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// What React changed since the last frame, taken rather than read: acting on
|
|
763
|
+
// it twice would be harmless and leaving it set would make every later frame
|
|
764
|
+
// rebuild from the same index forever.
|
|
765
|
+
if (node.scrollDirtyFrom >= 0) {
|
|
766
|
+
stack.from = Math.min(stack.from, node.scrollDirtyFrom);
|
|
767
|
+
node.scrollDirtyFrom = -1;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// Children the tree no longer has. `from` is clamped rather than reset: a
|
|
771
|
+
// removal has already put it at zero, and an unmount during a Suspense
|
|
772
|
+
// fallback must not be able to leave an index pointing past the end.
|
|
773
|
+
if (stack.heights.length > children.length) {
|
|
774
|
+
stack.heights.length = children.length;
|
|
775
|
+
stack.tops.length = children.length;
|
|
776
|
+
stack.from = Math.min(stack.from, children.length);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
if (children.length === 0) {
|
|
780
|
+
stack.content = 0;
|
|
781
|
+
stack.from = 0;
|
|
782
|
+
return stack;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
if (stack.from < children.length) {
|
|
786
|
+
// What a scrolling box offers a child that has not been given a height:
|
|
787
|
+
// nothing in particular. That is what scrolling means — a child is as tall
|
|
788
|
+
// as its content and the window decides how much is seen — and the only
|
|
789
|
+
// thing this basis is read for is a percentage `minHeight` or `maxHeight`
|
|
790
|
+
// on the child, which inside a scroll region is a question with no good
|
|
791
|
+
// answer.
|
|
792
|
+
const unbounded = Number.MAX_SAFE_INTEGER;
|
|
793
|
+
// Where the child before the first stale one ended. Read back out of the
|
|
794
|
+
// stack rather than carried, because a rebuild may start anywhere and only
|
|
795
|
+
// the entries below it are known to be current.
|
|
796
|
+
let cursor = 0;
|
|
797
|
+
if (stack.from > 0) {
|
|
798
|
+
const previous = children[stack.from - 1];
|
|
799
|
+
const [, , previousBottom] = margin(previous.style);
|
|
800
|
+
cursor = stack.tops[stack.from - 1] + stack.heights[stack.from - 1] + previousBottom + gap;
|
|
801
|
+
}
|
|
802
|
+
for (let index = stack.from; index < children.length; index += 1) {
|
|
803
|
+
const child = children[index];
|
|
804
|
+
const [marginTop, marginRight, marginBottom, marginLeft] = margin(child.style);
|
|
805
|
+
const available = Math.max(0, width - marginLeft - marginRight);
|
|
806
|
+
const fixed = resolve(child.style.height, height);
|
|
807
|
+
const own = fixed ?? intrinsicSize(child, available, unbounded).height;
|
|
808
|
+
stack.tops[index] = cursor + marginTop;
|
|
809
|
+
stack.heights[index] = own;
|
|
810
|
+
cursor += marginTop + own + marginBottom + gap;
|
|
811
|
+
}
|
|
812
|
+
// `cursor` counts a gap after the last child, which the content does not
|
|
813
|
+
// have. Subtracting it at the end rather than skipping the first one is
|
|
814
|
+
// what makes the running total patchable from any index.
|
|
815
|
+
stack.content = cursor - gap;
|
|
816
|
+
stack.from = children.length;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
return stack;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// Not implemented here, on purpose, and tracked rather than discovered:
|
|
823
|
+
//
|
|
824
|
+
// - `flexWrap`. OpenTUI's default is `"no-wrap"` and a terminal layout that
|
|
825
|
+
// wraps its flex line is rare enough that guessing at the semantics would be
|
|
826
|
+
// worse than not having them.
|
|
827
|
+
// - `position: "absolute"`. It needs a containing-block concept that nothing
|
|
828
|
+
// in this package has yet, and every use of it so far has been better served
|
|
829
|
+
// by a box that grows.
|
|
830
|
+
// - `auto` margins, which are how CSS centres a single child, and which
|
|
831
|
+
// `justifyContent: "center"` already covers here.
|
|
832
|
+
//
|
|
833
|
+
// All three are ubugeeei-prod/uf#314.
|