@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/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.