@uniflowed/tui 0.0.0-alpha.18 → 0.14.1

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 CHANGED
@@ -24,8 +24,8 @@
24
24
  //
25
25
  // So this implements the subset a terminal uses, in whole cells. What it does
26
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.
27
+ // discovered: no aspect ratio, and no wrapping or positioning inside a
28
+ // scrolling box. Those are ubugeeei-prod/uf#314.
29
29
  //
30
30
  // # Whole cells, and where the remainder goes
31
31
  //
@@ -45,6 +45,9 @@
45
45
  */
46
46
  export type Dimension = number | string;
47
47
 
48
+ /** A margin: a number of cells, or `"auto"` inside a flex container. */
49
+ export type Margin = number | "auto";
50
+
48
51
  /** Main-axis direction. `"column"` is the default, as in OpenTUI. */
49
52
  export type FlexDirection = "row" | "row-reverse" | "column" | "column-reverse";
50
53
 
@@ -63,6 +66,45 @@ export type AlignItems = "flex-start" | "center" | "flex-end" | "stretch";
63
66
  /** Cross-axis alignment of one child, or `"auto"` to follow the parent. */
64
67
  export type AlignSelf = "auto" | AlignItems;
65
68
 
69
+ /**
70
+ * Whether children that do not fit on one line go on to another.
71
+ *
72
+ * `"no-wrap"` — the default, OpenTUI's and Yoga's — keeps them on one and
73
+ * shrinks them. `"wrap"` starts a new line below (or, in a column, to the
74
+ * right), and `"wrap-reverse"` stacks the lines from the other side.
75
+ */
76
+ export type FlexWrap = "no-wrap" | "wrap" | "wrap-reverse";
77
+
78
+ /** Where the lines of a wrapping box go in the space they leave over. */
79
+ export type AlignContent =
80
+ | "flex-start"
81
+ | "center"
82
+ | "flex-end"
83
+ | "stretch"
84
+ | "space-between"
85
+ | "space-around"
86
+ | "space-evenly";
87
+
88
+ /**
89
+ * Whether a node takes part in its parent's flex line, and whether it is a
90
+ * containing block for the absolutely positioned boxes under it.
91
+ *
92
+ * `"relative"` — the default, as it is in OpenTUI and Yoga — takes part, and
93
+ * its `top`/`right`/`bottom`/`left` then nudge where it is drawn without
94
+ * moving anything around it. `"absolute"` does not: its parent lays out as if
95
+ * it were not there, and it is placed by those four offsets against the inside
96
+ * of its containing block's border. Both are *positioned*, which is what makes
97
+ * a box a containing block.
98
+ *
99
+ * `"static"` takes part in the line like `"relative"`, ignores the four
100
+ * offsets, and is not a containing block: an absolutely positioned box under
101
+ * it looks past it to the nearest ancestor that is positioned, or to the root.
102
+ * That is CSS's rule and Yoga 3's, and it is the only way for a box to be
103
+ * placed against something other than its parent — which is what a tooltip
104
+ * inside a row of a panel needs to sit against the panel.
105
+ */
106
+ export type Position = "static" | "relative" | "absolute";
107
+
66
108
  /**
67
109
  * What happens to content larger than its box.
68
110
  *
@@ -83,8 +125,10 @@ export type Overflow = "visible" | "hidden" | "scroll";
83
125
  */
84
126
  export type LayoutStyle = {
85
127
  readonly flexDirection?: FlexDirection,
128
+ readonly flexWrap?: FlexWrap,
86
129
  readonly justifyContent?: JustifyContent,
87
130
  readonly alignItems?: AlignItems,
131
+ readonly alignContent?: AlignContent,
88
132
  readonly alignSelf?: AlignSelf,
89
133
  readonly flexGrow?: number,
90
134
  readonly flexShrink?: number,
@@ -100,15 +144,21 @@ export type LayoutStyle = {
100
144
  readonly paddingRight?: number,
101
145
  readonly paddingBottom?: number,
102
146
  readonly paddingLeft?: number,
103
- readonly margin?: number,
104
- readonly marginTop?: number,
105
- readonly marginRight?: number,
106
- readonly marginBottom?: number,
107
- readonly marginLeft?: number,
147
+ readonly margin?: Margin,
148
+ readonly marginTop?: Margin,
149
+ readonly marginRight?: Margin,
150
+ readonly marginBottom?: Margin,
151
+ readonly marginLeft?: Margin,
108
152
  readonly gap?: number,
109
153
  readonly rowGap?: number,
110
154
  readonly columnGap?: number,
111
155
  readonly overflow?: Overflow,
156
+ readonly position?: Position,
157
+ /** Offsets: cells, or a percentage of the parent's inside. Negative is allowed. */
158
+ readonly top?: Dimension,
159
+ readonly right?: Dimension,
160
+ readonly bottom?: Dimension,
161
+ readonly left?: Dimension,
112
162
  /**
113
163
  * The first content row a scrolling box shows.
114
164
  *
@@ -132,7 +182,12 @@ export type LayoutStyle = {
132
182
  */
133
183
  export type LayoutNode = {
134
184
  style: LayoutStyle,
135
- children: Array<LayoutNode>,
185
+ /**
186
+ * Read-only here because layout never adds or removes a child. That is also
187
+ * what lets the renderer's `TuiNode`, whose children are `TuiNode`s, be laid
188
+ * out as a `LayoutNode`: a mutable array would be invariant in its element.
189
+ */
190
+ readonly children: $ReadOnlyArray<LayoutNode>,
136
191
  /** Cells the node's own frame occupies on each edge; a border is 1. */
137
192
  borderWidth: number,
138
193
  measure: ((availableWidth: number, availableHeight: number) => Size) | null,
@@ -235,6 +290,8 @@ export type Size = { readonly width: number, readonly height: number };
235
290
  const clamp = (value: number, low: number, high: number): number =>
236
291
  Math.min(Math.max(value, low), high);
237
292
 
293
+ type Edges<T> = [T, T, T, T];
294
+
238
295
  /** Whether the main axis is horizontal. */
239
296
  const isRow = (direction: FlexDirection): boolean =>
240
297
  direction === "row" || direction === "row-reverse";
@@ -260,8 +317,73 @@ function resolve(value: Dimension | void, basis: number): number | null {
260
317
  return null;
261
318
  }
262
319
 
320
+ /**
321
+ * Resolve an offset, which unlike a length may be negative.
322
+ *
323
+ * `top: -1` on a badge is how it sits on its parent's border, so the clamp
324
+ * {@link resolve} applies to a size would move it back inside.
325
+ */
326
+ function resolveOffset(value: Dimension | void, basis: number): number | null {
327
+ if (typeof value === "number") {
328
+ return Number.isFinite(value) ? Math.round(value) : null;
329
+ }
330
+ if (typeof value === "string" && value.endsWith("%")) {
331
+ const percent = Number.parseFloat(value.slice(0, -1));
332
+ return Number.isFinite(percent) ? Math.round((basis * percent) / 100) : null;
333
+ }
334
+ return null;
335
+ }
336
+
337
+ /** Whether a child is out of its parent's flex line. */
338
+ const isAbsolute = (node: LayoutNode): boolean => node.style.position === "absolute";
339
+
340
+ /** Whether a node is a containing block for the absolute boxes under it. */
341
+ const isPositioned = (node: LayoutNode): boolean => node.style.position !== "static";
342
+
343
+ /**
344
+ * The box an absolutely positioned descendant is placed against: the padding
345
+ * box of its nearest positioned ancestor, in frame coordinates.
346
+ */
347
+ type ContainingBlock = {
348
+ readonly x: number,
349
+ readonly y: number,
350
+ readonly width: number,
351
+ readonly height: number,
352
+ };
353
+
354
+ /** A node's padding box — inside its border, not inside its padding. */
355
+ function paddingBox(node: LayoutNode): ContainingBlock {
356
+ const border = node.borderWidth;
357
+ return {
358
+ x: node.x + border,
359
+ y: node.y + border,
360
+ width: Math.max(0, node.width - border * 2),
361
+ height: Math.max(0, node.height - border * 2),
362
+ };
363
+ }
364
+
365
+ /**
366
+ * How far a relatively positioned child is drawn from where the line put it.
367
+ *
368
+ * `left` wins over `right` and `top` over `bottom` when both are given, which
369
+ * is CSS's rule for a box whose width is already decided.
370
+ */
371
+ function relativeShift(style: LayoutStyle, width: number, height: number): [number, number] {
372
+ // A static box is exactly where its line put it. The offsets are not an
373
+ // error on one, only unread — which is what lets a caller flip `position`
374
+ // without also deleting the props it had.
375
+ if (style.position === "static") {
376
+ return [0, 0];
377
+ }
378
+ const left = resolveOffset(style.left, width);
379
+ const right = resolveOffset(style.right, width);
380
+ const top = resolveOffset(style.top, height);
381
+ const bottom = resolveOffset(style.bottom, height);
382
+ return [left ?? (right != null ? -right : 0), top ?? (bottom != null ? -bottom : 0)];
383
+ }
384
+
263
385
  /** Padding on each edge, with the shorthand applied first. */
264
- function padding(style: LayoutStyle): [number, number, number, number] {
386
+ function padding(style: LayoutStyle): Edges<number> {
265
387
  const all = style.padding ?? 0;
266
388
  return [
267
389
  style.paddingTop ?? all,
@@ -272,7 +394,7 @@ function padding(style: LayoutStyle): [number, number, number, number] {
272
394
  }
273
395
 
274
396
  /** Margin on each edge, with the shorthand applied first. */
275
- function margin(style: LayoutStyle): [number, number, number, number] {
397
+ function margin(style: LayoutStyle): Edges<Margin> {
276
398
  const all = style.margin ?? 0;
277
399
  return [
278
400
  style.marginTop ?? all,
@@ -282,6 +404,21 @@ function margin(style: LayoutStyle): [number, number, number, number] {
282
404
  ];
283
405
  }
284
406
 
407
+ /** The fixed part of a margin. `auto` is resolved only by the flex parent. */
408
+ function marginCells(value: Margin): number {
409
+ return value === "auto" ? 0 : value;
410
+ }
411
+
412
+ /** A margin tuple with every `auto` edge treated as zero cells. */
413
+ function fixedMargins(edges: Edges<Margin>): Edges<number> {
414
+ return [
415
+ marginCells(edges[0]),
416
+ marginCells(edges[1]),
417
+ marginCells(edges[2]),
418
+ marginCells(edges[3]),
419
+ ];
420
+ }
421
+
285
422
  /** The cells between two children along the main axis. */
286
423
  function gapOf(style: LayoutStyle, row: boolean): number {
287
424
  const specific = row ? style.columnGap : style.rowGap;
@@ -384,6 +521,16 @@ function measureIntrinsic(node: LayoutNode, availableWidth: number, availableHei
384
521
  fixedHeight != null
385
522
  ? 0
386
523
  : scrollStack(node, innerAvailableWidth, innerAvailableHeight).content;
524
+ } else if (wraps(style)) {
525
+ const size = wrappedSize(
526
+ node,
527
+ fixedWidth != null ? Math.max(0, fixedWidth - insetLeft - insetRight) : innerAvailableWidth,
528
+ fixedHeight != null
529
+ ? Math.max(0, fixedHeight - insetTop - insetBottom)
530
+ : innerAvailableHeight,
531
+ );
532
+ contentWidth = size.width;
533
+ contentHeight = size.height;
387
534
  } else {
388
535
  const row = isRow(direction(node.style));
389
536
  const gap = gapOf(style, row);
@@ -391,7 +538,12 @@ function measureIntrinsic(node: LayoutNode, availableWidth: number, availableHei
391
538
  let cross = 0;
392
539
  let counted = 0;
393
540
  for (const child of node.children) {
394
- const [marginTop, marginRight, marginBottom, marginLeft] = margin(child.style);
541
+ // A child out of the line takes no room in it, so it cannot make its
542
+ // parent any larger — which is the whole of what `absolute` means.
543
+ if (isAbsolute(child)) {
544
+ continue;
545
+ }
546
+ const [marginTop, marginRight, marginBottom, marginLeft] = fixedMargins(margin(child.style));
395
547
  const size = intrinsicSize(
396
548
  child,
397
549
  Math.max(0, innerAvailableWidth - marginLeft - marginRight),
@@ -416,6 +568,55 @@ function measureIntrinsic(node: LayoutNode, availableWidth: number, availableHei
416
568
  };
417
569
  }
418
570
 
571
+ /**
572
+ * The content size of a box that wraps, in the space it is offered.
573
+ *
574
+ * The lines it would break into there, each as long as its children and as
575
+ * deep as its deepest, stacked with the gap between lines. Which is why a
576
+ * wrapping row offered forty columns can come back narrower than forty and
577
+ * taller than one line: it is as wide as its widest line.
578
+ */
579
+ function wrappedSize(node: LayoutNode, width: number, height: number): Size {
580
+ const row = isRow(direction(node.style));
581
+ const gap = gapOf(node.style, row);
582
+ const outer: Array<{ main: number, cross: number }> = [];
583
+ for (const child of node.children) {
584
+ if (isAbsolute(child)) {
585
+ continue;
586
+ }
587
+ const [marginTop, marginRight, marginBottom, marginLeft] = fixedMargins(margin(child.style));
588
+ const size = intrinsicSize(
589
+ child,
590
+ Math.max(0, width - marginLeft - marginRight),
591
+ Math.max(0, height - marginTop - marginBottom),
592
+ );
593
+ const outerWidth = size.width + marginLeft + marginRight;
594
+ const outerHeight = size.height + marginTop + marginBottom;
595
+ outer.push(
596
+ row ? { main: outerWidth, cross: outerHeight } : { main: outerHeight, cross: outerWidth },
597
+ );
598
+ }
599
+ const lines = breakLines(
600
+ outer.map((each) => each.main),
601
+ row ? width : height,
602
+ gap,
603
+ );
604
+ let main = 0;
605
+ let cross = 0;
606
+ for (const line of lines) {
607
+ let length = Math.max(0, line.length - 1) * gap;
608
+ let depth = 0;
609
+ for (const index of line) {
610
+ length += outer[index].main;
611
+ depth = Math.max(depth, outer[index].cross);
612
+ }
613
+ main = Math.max(main, length);
614
+ cross += depth;
615
+ }
616
+ cross += Math.max(0, lines.length - 1) * gapOf(node.style, !row);
617
+ return row ? { width: main, height: cross } : { width: cross, height: main };
618
+ }
619
+
419
620
  /** Apply `min*`/`max*` to a resolved length. */
420
621
  function clampDimension(
421
622
  value: number,
@@ -437,7 +638,7 @@ function clampDimension(
437
638
  */
438
639
  function distribute(total: number, weights: $ReadOnlyArray<number>): Array<number> {
439
640
  const sum = weights.reduce((a, b) => a + b, 0);
440
- const out = new Array(weights.length).fill(0);
641
+ const out = new Array<number>(weights.length).fill(0);
441
642
  if (sum <= 0 || total === 0) {
442
643
  return out;
443
644
  }
@@ -452,11 +653,88 @@ function distribute(total: number, weights: $ReadOnlyArray<number>): Array<numbe
452
653
  return out;
453
654
  }
454
655
 
656
+ /** Whether a box breaks its children onto more than one line. */
657
+ const wraps = (style: LayoutStyle): boolean =>
658
+ style.flexWrap === "wrap" || style.flexWrap === "wrap-reverse";
659
+
660
+ /**
661
+ * Break children, given as their outer main sizes, into lines of `space`.
662
+ *
663
+ * Greedy, which is what flexbox specifies: a line takes children until the
664
+ * next would overflow it. A child larger than a whole line still starts one,
665
+ * so no child is ever left without a line to be on.
666
+ */
667
+ function breakLines(
668
+ sizes: $ReadOnlyArray<number>,
669
+ space: number,
670
+ gap: number,
671
+ ): Array<Array<number>> {
672
+ const lines: Array<Array<number>> = [];
673
+ let line: Array<number> = [];
674
+ let used = 0;
675
+ for (let index = 0; index < sizes.length; index += 1) {
676
+ const size = sizes[index];
677
+ if (line.length > 0 && used + gap + size > space) {
678
+ lines.push(line);
679
+ line = [];
680
+ used = 0;
681
+ }
682
+ used += (line.length > 0 ? gap : 0) + size;
683
+ line.push(index);
684
+ }
685
+ if (line.length > 0) {
686
+ lines.push(line);
687
+ }
688
+ return lines;
689
+ }
690
+
691
+ /**
692
+ * Where `alignContent` puts the lines of a wrapping box, given the cross-axis
693
+ * space they leave: the first line's offset, the extra space between two
694
+ * lines, and the space `"stretch"` hands out to the lines themselves.
695
+ *
696
+ * `"flex-start"` is the default, which is Yoga's and so OpenTUI's; CSS's is
697
+ * `"stretch"`, and a box copied from a stylesheet with no `alignContent` will
698
+ * pack its lines at the top here where a browser would spread them.
699
+ */
700
+ function alignLines(
701
+ alignment: AlignContent | void,
702
+ spare: number,
703
+ count: number,
704
+ ): [number, number, number] {
705
+ const free = Math.max(0, spare);
706
+ switch (alignment) {
707
+ case "center":
708
+ return [Math.floor(free / 2), 0, 0];
709
+ case "flex-end":
710
+ return [free, 0, 0];
711
+ case "stretch":
712
+ return [0, 0, free];
713
+ case "space-between":
714
+ return count > 1 ? [0, Math.floor(free / (count - 1)), 0] : [0, 0, 0];
715
+ case "space-around": {
716
+ const each = count > 0 ? Math.floor(free / count) : 0;
717
+ return [Math.floor(each / 2), each, 0];
718
+ }
719
+ case "space-evenly": {
720
+ const each = Math.floor(free / (count + 1));
721
+ return [each, each, 0];
722
+ }
723
+ default:
724
+ return [0, 0, 0];
725
+ }
726
+ }
727
+
455
728
  /**
456
729
  * Lay `node` out into the border box at `x`, `y`, `width` by `height`.
457
730
  *
458
731
  * Writes `x`, `y`, `width` and `height` onto every node in the subtree. The
459
732
  * caller decides the root's box, which for a terminal is the whole screen.
733
+ *
734
+ * `containing` is the containing block the nearest positioned ancestor offers,
735
+ * and only this module passes it: a caller laying out a root has no ancestor,
736
+ * and a root that is `"static"` is then its own containing block — the screen,
737
+ * which is where a box with no positioned ancestor is placed in CSS as well.
460
738
  */
461
739
  export function layout(
462
740
  node: LayoutNode,
@@ -464,6 +742,7 @@ export function layout(
464
742
  y: number,
465
743
  width: number,
466
744
  height: number,
745
+ containing: ContainingBlock | null = null,
467
746
  ): void {
468
747
  node.x = x;
469
748
  node.y = y;
@@ -485,6 +764,10 @@ export function layout(
485
764
  }
486
765
 
487
766
  const style = node.style;
767
+ // What an absolute box anywhere under this one is placed against, until a
768
+ // positioned descendant offers another. Decided here, after this node's own
769
+ // box is, because a containing block is a box and not a promise of one.
770
+ const block = isPositioned(node) || containing == null ? paddingBox(node) : containing;
488
771
  const [insetTop, insetRight, insetBottom, insetLeft] = insets(node);
489
772
  const contentX = x + insetLeft;
490
773
  const contentY = y + insetTop;
@@ -492,7 +775,7 @@ export function layout(
492
775
  const contentHeight = Math.max(0, height - insetTop - insetBottom);
493
776
 
494
777
  if (style.overflow === "scroll") {
495
- layoutScroll(node, contentX, contentY, contentWidth, contentHeight);
778
+ layoutScroll(node, contentX, contentY, contentWidth, contentHeight, block);
496
779
  return;
497
780
  }
498
781
 
@@ -502,141 +785,360 @@ export function layout(
502
785
  const mainSpace = row ? contentWidth : contentHeight;
503
786
  const crossSpace = row ? contentHeight : contentWidth;
504
787
  const gap = gapOf(style, row);
505
- const children = node.children;
788
+ // The flex line is the children that are in it. The filter is skipped when
789
+ // there is nothing to filter, which is every box that has no absolutely
790
+ // positioned child: a list rebuilt per frame per box would be the cost of a
791
+ // feature most trees do not use.
792
+ const children = node.children.some(isAbsolute)
793
+ ? node.children.filter((child) => !isAbsolute(child))
794
+ : node.children;
506
795
 
507
796
  // Pass one: every child's base main size, and the outer margins around it.
508
797
  const margins = children.map((child) => margin(child.style));
798
+ const resolvedMargins = margins.map((edges) => fixedMargins(edges));
509
799
  const bases = children.map((child, index) => {
510
- const [marginTop, marginRight, marginBottom, marginLeft] = margins[index];
800
+ const [marginTop, marginRight, marginBottom, marginLeft] = resolvedMargins[index];
511
801
  const availableWidth = Math.max(0, contentWidth - marginLeft - marginRight);
512
802
  const availableHeight = Math.max(0, contentHeight - marginTop - marginBottom);
513
803
  const basis = resolve(child.style.flexBasis, mainSpace);
514
804
  if (basis != null) {
515
805
  return basis;
516
806
  }
517
- const fixed = resolve(row ? child.style.width : child.style.height, mainSpace);
518
- if (fixed != null) {
519
- return fixed;
807
+ const fixedMain = resolve(row ? child.style.width : child.style.height, mainSpace);
808
+ if (fixedMain != null) {
809
+ return fixedMain;
520
810
  }
521
811
  const size = intrinsicSize(child, availableWidth, availableHeight);
522
812
  return row ? size.width : size.height;
523
813
  });
524
814
 
525
815
  const outerMain = (index: number): number => {
526
- const [marginTop, marginRight, marginBottom, marginLeft] = margins[index];
816
+ const [marginTop, marginRight, marginBottom, marginLeft] = resolvedMargins[index];
527
817
  return bases[index] + (row ? marginLeft + marginRight : marginTop + marginBottom);
528
818
  };
529
819
 
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];
820
+ const justify = style.justifyContent ?? "flex-start";
821
+ const parentAlign = style.alignItems ?? "stretch";
822
+
823
+ // Lay one flex line out: grow or shrink it into the main axis, distribute
824
+ // what is left, and align each child within the line's share of the cross
825
+ // axis, which starts `crossOrigin` cells in and is `lineCross` deep. A box
826
+ // that does not wrap has one line, all of it.
827
+ const placeLine = (line: $ReadOnlyArray<number>, crossOrigin: number, lineCross: number) => {
828
+ const used =
829
+ line.reduce((total, index) => total + outerMain(index), 0) +
830
+ Math.max(0, line.length - 1) * gap;
831
+ const free = mainSpace - used;
832
+
833
+ // Pass two: grow into the space left over, or shrink to fit into what there
834
+ // is. Shrinking is weighted by the base size as CSS specifies, so a wide
835
+ // child gives up more columns than a narrow one with the same `flexShrink`.
836
+ const mainSizes = bases.slice();
837
+ if (free > 0) {
838
+ const grow = line.map((index) => Math.max(0, children[index].style.flexGrow ?? 0));
839
+ const shares = distribute(free, grow);
840
+ for (let i = 0; i < line.length; i += 1) {
841
+ mainSizes[line[i]] += shares[i];
842
+ }
843
+ } else if (free < 0) {
844
+ const weights = line.map((index) => shrinkOf(children[index].style, row) * bases[index]);
845
+ const shares = distribute(-free, weights);
846
+ for (let i = 0; i < line.length; i += 1) {
847
+ mainSizes[line[i]] = Math.max(0, mainSizes[line[i]] - shares[i]);
848
+ }
849
+ }
850
+
851
+ // Whatever main-axis space the children did not take, `justifyContent`
852
+ // decides what to do with.
853
+ const consumed =
854
+ line.reduce(
855
+ (total, index) =>
856
+ total +
857
+ mainSizes[index] +
858
+ (row
859
+ ? resolvedMargins[index][3] + resolvedMargins[index][1]
860
+ : resolvedMargins[index][0] + resolvedMargins[index][2]),
861
+ 0,
862
+ ) +
863
+ Math.max(0, line.length - 1) * gap;
864
+ const slack = Math.max(0, mainSpace - consumed);
865
+
866
+ const placed = resolvedMargins.map((edges) => edges.slice());
867
+ const autoMain: Array<[number, number]> = [];
868
+ if (slack > 0) {
869
+ for (const index of line) {
870
+ const edges = margins[index];
871
+ if (row) {
872
+ if (edges[3] === "auto") autoMain.push([index, 3]);
873
+ if (edges[1] === "auto") autoMain.push([index, 1]);
874
+ } else {
875
+ if (edges[0] === "auto") autoMain.push([index, 0]);
876
+ if (edges[2] === "auto") autoMain.push([index, 2]);
877
+ }
878
+ }
544
879
  }
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]);
880
+ const justifySlack = autoMain.length === 0 ? slack : 0;
881
+ const autoMainShares = distribute(
882
+ slack,
883
+ autoMain.map(() => 1),
884
+ );
885
+ for (let index = 0; index < autoMain.length; index += 1) {
886
+ const [childIndex, edge] = autoMain[index];
887
+ placed[childIndex][edge] += autoMainShares[index];
550
888
  }
551
- }
552
889
 
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
- }
890
+ let cursor = 0;
891
+ let between = gap;
892
+ if (justify === "center") {
893
+ cursor = Math.floor(justifySlack / 2);
894
+ } else if (justify === "flex-end") {
895
+ cursor = justifySlack;
896
+ } else if (justify === "space-between" && line.length > 1) {
897
+ between = gap + Math.floor(justifySlack / (line.length - 1));
898
+ } else if (justify === "space-around" && line.length > 0) {
899
+ const each = Math.floor(justifySlack / line.length);
900
+ cursor = Math.floor(each / 2);
901
+ between = gap + each;
902
+ } else if (justify === "space-evenly" && line.length > 0) {
903
+ const each = Math.floor(justifySlack / (line.length + 1));
904
+ cursor = each;
905
+ between = gap + each;
906
+ }
583
907
 
584
- const order = reverse ? children.map((_, i) => i).reverse() : children.map((_, i) => i);
585
- const parentAlign = style.alignItems ?? "stretch";
908
+ const order = reverse ? line.slice().reverse() : line;
586
909
 
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(
910
+ for (const index of order) {
911
+ const child = children[index];
912
+ const [marginTop, marginRight, marginBottom, marginLeft] = placed[index];
913
+ const mainMarginStart = row ? marginLeft : marginTop;
914
+ const mainMarginEnd = row ? marginRight : marginBottom;
915
+ const crossMarginStart = row ? marginTop : marginLeft;
916
+ const crossMarginEnd = row ? marginBottom : marginRight;
917
+ const crossStartEdge = row ? 0 : 3;
918
+ const crossEndEdge = row ? 2 : 1;
919
+ const autoCrossStart = margins[index][crossStartEdge] === "auto";
920
+ const autoCrossEnd = margins[index][crossEndEdge] === "auto";
921
+ const hasAutoCross = autoCrossStart || autoCrossEnd;
922
+
923
+ const align = (() => {
924
+ const own = child.style.alignSelf ?? "auto";
925
+ return own === "auto" ? parentAlign : own;
926
+ })();
927
+
928
+ const crossAvailable = Math.max(0, lineCross - crossMarginStart - crossMarginEnd);
929
+ const fixedCross = resolve(row ? child.style.height : child.style.width, crossSpace);
930
+ let crossSize: number;
931
+ if (fixedCross != null) {
932
+ crossSize = fixedCross;
933
+ } else if (align === "stretch" && !hasAutoCross) {
934
+ crossSize = crossAvailable;
935
+ } else {
936
+ const size = intrinsicSize(
937
+ child,
938
+ row ? mainSizes[index] : crossAvailable,
939
+ row ? crossAvailable : mainSizes[index],
940
+ );
941
+ crossSize = row ? size.height : size.width;
942
+ }
943
+ crossSize = Math.min(crossSize, crossAvailable);
944
+
945
+ let crossOffset = crossOrigin + crossMarginStart;
946
+ if (hasAutoCross) {
947
+ const remaining = Math.max(0, lineCross - crossSize - crossMarginStart - crossMarginEnd);
948
+ const autoCrossShares = distribute(remaining, [
949
+ autoCrossStart ? 1 : 0,
950
+ autoCrossEnd ? 1 : 0,
951
+ ]);
952
+ crossOffset += autoCrossShares[0];
953
+ } else if (align === "center") {
954
+ crossOffset += Math.floor((crossAvailable - crossSize) / 2);
955
+ } else if (align === "flex-end") {
956
+ crossOffset += crossAvailable - crossSize;
957
+ }
958
+
959
+ const mainStart = cursor + mainMarginStart;
960
+ const childWidth = row ? mainSizes[index] : crossSize;
961
+ const childHeight = row ? crossSize : mainSizes[index];
962
+ const [shiftX, shiftY] = relativeShift(child.style, contentWidth, contentHeight);
963
+ const childX = (row ? contentX + mainStart : contentX + crossOffset) + shiftX;
964
+ const childY = (row ? contentY + crossOffset : contentY + mainStart) + shiftY;
965
+
966
+ layout(
609
967
  child,
610
- row ? mainSizes[index] : crossAvailable,
611
- row ? crossAvailable : mainSizes[index],
968
+ childX,
969
+ childY,
970
+ clampDimension(childWidth, child.style.minWidth, child.style.maxWidth, contentWidth),
971
+ clampDimension(childHeight, child.style.minHeight, child.style.maxHeight, contentHeight),
972
+ block,
612
973
  );
613
- crossSize = row ? size.height : size.width;
974
+
975
+ cursor = mainStart + mainSizes[index] + mainMarginEnd + between;
614
976
  }
615
- crossSize = Math.min(crossSize, crossAvailable);
977
+ };
616
978
 
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;
979
+ if (!wraps(style)) {
980
+ placeLine(
981
+ children.map((_, index) => index),
982
+ 0,
983
+ crossSpace,
984
+ );
985
+ } else {
986
+ // Lines are filled in order until the next child's outer size would not
987
+ // fit, and a child wider than the whole line gets a line of its own
988
+ // rather than none. Each line is as deep as its deepest child asks to be,
989
+ // measured at the main size it started with.
990
+ const lines = breakLines(
991
+ bases.map((_, index) => outerMain(index)),
992
+ mainSpace,
993
+ gap,
994
+ );
995
+ const depths = lines.map((line) =>
996
+ line.reduce((deepest, index) => {
997
+ const child = children[index];
998
+ const [marginTop, marginRight, marginBottom, marginLeft] = resolvedMargins[index];
999
+ const fixedCross = resolve(row ? child.style.height : child.style.width, crossSpace);
1000
+ const cross =
1001
+ fixedCross ??
1002
+ (row
1003
+ ? intrinsicSize(child, bases[index], Math.max(0, crossSpace - marginTop - marginBottom))
1004
+ .height
1005
+ : intrinsicSize(child, Math.max(0, crossSpace - marginLeft - marginRight), bases[index])
1006
+ .width);
1007
+ return Math.max(
1008
+ deepest,
1009
+ cross + (row ? marginTop + marginBottom : marginLeft + marginRight),
1010
+ );
1011
+ }, 0),
1012
+ );
1013
+ const lineGap = gapOf(style, !row);
1014
+ const spare =
1015
+ crossSpace -
1016
+ depths.reduce((total, depth) => total + depth, 0) -
1017
+ Math.max(0, lines.length - 1) * lineGap;
1018
+ const [first, between, stretch] = alignLines(style.alignContent, spare, lines.length);
1019
+ const extra = distribute(
1020
+ stretch,
1021
+ lines.map(() => 1),
1022
+ );
1023
+ let origin = first;
1024
+ for (let index = 0; index < lines.length; index += 1) {
1025
+ const depth = depths[index] + (extra[index] ?? 0);
1026
+ // `wrap-reverse` stacks the lines from the far edge of the cross axis,
1027
+ // and keeps each line's own contents the right way round.
1028
+ const at = style.flexWrap === "wrap-reverse" ? crossSpace - origin - depth : origin;
1029
+ placeLine(lines[index], at, depth);
1030
+ origin += depth + lineGap + between;
622
1031
  }
1032
+ }
623
1033
 
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;
1034
+ if (children !== node.children) {
1035
+ for (const child of node.children) {
1036
+ if (isAbsolute(child)) {
1037
+ layoutAbsolute(node, child, block);
1038
+ }
1039
+ }
1040
+ }
1041
+ }
629
1042
 
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
- );
1043
+ /**
1044
+ * Place a child that is out of its parent's line.
1045
+ *
1046
+ * Its containing block is the *padding* box — inside the border, not inside
1047
+ * the padding — of its nearest positioned ancestor, which is CSS's rule and
1048
+ * Yoga's, and the reason `top: 0` puts it on the first row inside a frame
1049
+ * rather than on the frame. Every node is positioned unless it says
1050
+ * `position: "static"`, as in OpenTUI, so that ancestor is the parent unless a
1051
+ * static box is in the way.
1052
+ *
1053
+ * Each axis is decided the same way. A size given is that size, and a
1054
+ * percentage is of the containing block. Otherwise both offsets on an axis
1055
+ * stretch it between them, and one or none leaves it the size of its content.
1056
+ * The start offset places it, or failing that the end one; with neither, it
1057
+ * goes where its *parent's* `justifyContent` and `alignItems` would have put a
1058
+ * lone child — Yoga's static position, which is about the line the box was
1059
+ * taken out of and so is the parent's even when the containing block is
1060
+ * further up — and not at the corner, which is what makes `position:
1061
+ * "absolute"` with no offsets an overlay centred by the same props that
1062
+ * centre anything else.
1063
+ */
1064
+ function layoutAbsolute(parent: LayoutNode, child: LayoutNode, block: ContainingBlock): void {
1065
+ const boxX = block.x;
1066
+ const boxY = block.y;
1067
+ const boxWidth = block.width;
1068
+ const boxHeight = block.height;
1069
+ const style = child.style;
1070
+ const [marginTop, marginRight, marginBottom, marginLeft] = fixedMargins(margin(style));
1071
+ const left = resolveOffset(style.left, boxWidth);
1072
+ const right = resolveOffset(style.right, boxWidth);
1073
+ const top = resolveOffset(style.top, boxHeight);
1074
+ const bottom = resolveOffset(style.bottom, boxHeight);
1075
+
1076
+ const spanWidth = Math.max(0, boxWidth - (left ?? 0) - (right ?? 0) - marginLeft - marginRight);
1077
+ const spanHeight = Math.max(0, boxHeight - (top ?? 0) - (bottom ?? 0) - marginTop - marginBottom);
1078
+ let childWidth = resolve(style.width, boxWidth);
1079
+ let childHeight = resolve(style.height, boxHeight);
1080
+ if (childWidth == null && left != null && right != null) {
1081
+ childWidth = spanWidth;
1082
+ }
1083
+ if (childHeight == null && top != null && bottom != null) {
1084
+ childHeight = spanHeight;
1085
+ }
1086
+ if (childWidth == null || childHeight == null) {
1087
+ const size = intrinsicSize(child, childWidth ?? spanWidth, childHeight ?? spanHeight);
1088
+ childWidth = childWidth ?? Math.min(size.width, spanWidth);
1089
+ childHeight = childHeight ?? size.height;
1090
+ }
1091
+ childWidth = clampDimension(childWidth, style.minWidth, style.maxWidth, boxWidth);
1092
+ childHeight = clampDimension(childHeight, style.minHeight, style.maxHeight, boxHeight);
1093
+
1094
+ // The static position, for an axis with no offset on it: where the parent's
1095
+ // own alignment would put a child of this size inside its padding.
1096
+ const own = paddingBox(parent);
1097
+ const [padTop, padRight, padBottom, padLeft] = padding(parent.style);
1098
+ const flexDirection = direction(parent.style);
1099
+ const row = isRow(flexDirection);
1100
+ const reverse = flexDirection === "row-reverse" || flexDirection === "column-reverse";
1101
+ const place = (
1102
+ alignment: string,
1103
+ flipped: boolean,
1104
+ start: number,
1105
+ space: number,
1106
+ size: number,
1107
+ marginStart: number,
1108
+ marginEnd: number,
1109
+ ): number => {
1110
+ const free = space - size - marginStart - marginEnd;
1111
+ const toEnd = alignment === "flex-end" ? !flipped : alignment !== "center" && flipped;
1112
+ if (alignment === "center") {
1113
+ return start + marginStart + Math.floor(free / 2);
1114
+ }
1115
+ return toEnd ? start + marginStart + free : start + marginStart;
1116
+ };
1117
+ const justify = parent.style.justifyContent ?? "flex-start";
1118
+ const ownAlign = style.alignSelf ?? "auto";
1119
+ const align = ownAlign === "auto" ? (parent.style.alignItems ?? "stretch") : ownAlign;
1120
+ const innerWidth = Math.max(0, own.width - padLeft - padRight);
1121
+ const innerHeight = Math.max(0, own.height - padTop - padBottom);
1122
+ const staticX = row
1123
+ ? place(justify, reverse, own.x + padLeft, innerWidth, childWidth, marginLeft, marginRight)
1124
+ : place(align, false, own.x + padLeft, innerWidth, childWidth, marginLeft, marginRight);
1125
+ const staticY = row
1126
+ ? place(align, false, own.y + padTop, innerHeight, childHeight, marginTop, marginBottom)
1127
+ : place(justify, reverse, own.y + padTop, innerHeight, childHeight, marginTop, marginBottom);
637
1128
 
638
- cursor = mainStart + mainSizes[index] + mainMarginEnd + between;
1129
+ let childX = staticX;
1130
+ if (left != null) {
1131
+ childX = boxX + left + marginLeft;
1132
+ } else if (right != null) {
1133
+ childX = boxX + boxWidth - right - marginRight - childWidth;
1134
+ }
1135
+ let childY = staticY;
1136
+ if (top != null) {
1137
+ childY = boxY + top + marginTop;
1138
+ } else if (bottom != null) {
1139
+ childY = boxY + boxHeight - bottom - marginBottom - childHeight;
639
1140
  }
1141
+ layout(child, childX, childY, childWidth, childHeight, block);
640
1142
  }
641
1143
 
642
1144
  /**
@@ -668,7 +1170,14 @@ export function layout(
668
1170
  * seventy-six are — which is the property the whole component exists for, and
669
1171
  * the reason this is not `overflow: "hidden"` with a margin on top.
670
1172
  */
671
- function layoutScroll(node: LayoutNode, x: number, y: number, width: number, height: number): void {
1173
+ function layoutScroll(
1174
+ node: LayoutNode,
1175
+ x: number,
1176
+ y: number,
1177
+ width: number,
1178
+ height: number,
1179
+ block: ContainingBlock,
1180
+ ): void {
672
1181
  const children = node.children;
673
1182
  const stack = scrollStack(node, width, height);
674
1183
  const content = stack.content;
@@ -696,10 +1205,10 @@ function layoutScroll(node: LayoutNode, x: number, y: number, width: number, hei
696
1205
  break;
697
1206
  }
698
1207
  const child = children[index];
699
- const [, marginRight, , marginLeft] = margin(child.style);
1208
+ const [, marginRight, , marginLeft] = fixedMargins(margin(child.style));
700
1209
  const available = Math.max(0, width - marginLeft - marginRight);
701
1210
  const childWidth = Math.min(available, resolve(child.style.width, width) ?? available);
702
- layout(child, x + marginLeft, y + top - offset, childWidth, stack.heights[index]);
1211
+ layout(child, x + marginLeft, y + top - offset, childWidth, stack.heights[index], block);
703
1212
  count += 1;
704
1213
  }
705
1214
  node.scrollFirst = first;
@@ -747,7 +1256,9 @@ function scrollStack(node: LayoutNode, width: number, height: number): ScrollInd
747
1256
  const gap = gapOf(node.style, false);
748
1257
  let stack = node.scrollIndex;
749
1258
  if (stack == null || stack.width !== width || stack.view !== height || stack.gap !== gap) {
750
- stack = {
1259
+ // Annotated rather than inferred: a literal's `from: 0` would otherwise
1260
+ // be typed as the number zero for the rest of this function.
1261
+ const fresh: ScrollIndex = {
751
1262
  width,
752
1263
  view: height,
753
1264
  gap,
@@ -756,6 +1267,7 @@ function scrollStack(node: LayoutNode, width: number, height: number): ScrollInd
756
1267
  heights: [],
757
1268
  content: 0,
758
1269
  };
1270
+ stack = fresh;
759
1271
  node.scrollIndex = stack;
760
1272
  }
761
1273
 
@@ -796,15 +1308,15 @@ function scrollStack(node: LayoutNode, width: number, height: number): ScrollInd
796
1308
  let cursor = 0;
797
1309
  if (stack.from > 0) {
798
1310
  const previous = children[stack.from - 1];
799
- const [, , previousBottom] = margin(previous.style);
1311
+ const [, , previousBottom] = fixedMargins(margin(previous.style));
800
1312
  cursor = stack.tops[stack.from - 1] + stack.heights[stack.from - 1] + previousBottom + gap;
801
1313
  }
802
1314
  for (let index = stack.from; index < children.length; index += 1) {
803
1315
  const child = children[index];
804
- const [marginTop, marginRight, marginBottom, marginLeft] = margin(child.style);
1316
+ const [marginTop, marginRight, marginBottom, marginLeft] = fixedMargins(margin(child.style));
805
1317
  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;
1318
+ const fixedHeight = resolve(child.style.height, height);
1319
+ const own = fixedHeight ?? intrinsicSize(child, available, unbounded).height;
808
1320
  stack.tops[index] = cursor + marginTop;
809
1321
  stack.heights[index] = own;
810
1322
  cursor += marginTop + own + marginBottom + gap;
@@ -819,15 +1331,24 @@ function scrollStack(node: LayoutNode, width: number, height: number): ScrollInd
819
1331
  return stack;
820
1332
  }
821
1333
 
1334
+ // Implemented, and asserted as cells in `npm/tui/tui.test.js`: `flexWrap`
1335
+ // with `alignContent`; `auto` margins on both axes; and `position` in all
1336
+ // three of Yoga 3's values, with an absolute box placed against the padding
1337
+ // box of its nearest ancestor that is not `"static"` (or the root). Paint, and
1338
+ // so the hit grid, still follow the *tree*: an absolute box is drawn when its
1339
+ // parent is, under its parent's `zIndex` order and inside its parent's clip —
1340
+ // which is OpenTUI's rule, and CSS's is different: there, `overflow: hidden`
1341
+ // on a static box between an absolute one and its containing block does not
1342
+ // clip it. Here it does.
1343
+ //
822
1344
  // Not implemented here, on purpose, and tracked rather than discovered:
823
1345
  //
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.
1346
+ // - `aspectRatio`. A terminal resolves whole cells, and nothing here has a
1347
+ // fractional layout pass to lean on.
1348
+ // - `position` and `flexWrap` inside a scrolling box. Its children are one
1349
+ // column whose heights are kept between frames (see {@link ScrollIndex}),
1350
+ // and a child that is out of it, drawn somewhere other than where it says,
1351
+ // or sharing a row with another, is a second answer to "which rows are in
1352
+ // the window". Both are read there as if they were not given.
832
1353
  //
833
- // All three are ubugeeei-prod/uf#314.
1354
+ // Both are ubugeeei-prod/uf#314.