File size: 5,216 Bytes
4e23b01 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 | import { LAYOUT_NODE, type LayoutViewport, type StackLayoutEntry, type StackLayoutNode } from "../layout-node.ts";
import { type Component, Container } from "../tui.ts";
export interface StackEntryOptions {
basis?: number | "auto";
grow?: number;
shrink?: number;
minSize?: number;
maxSize?: number;
visible?: (viewport: LayoutViewport) => boolean;
}
export interface StackEntry extends StackEntryOptions {
component: Component;
}
export type StackChild = Component | StackEntry;
export interface StackOptions {
gap?: number;
align?: "stretch" | "start" | "center" | "end";
}
function isStackEntry(child: StackChild): child is StackEntry {
return !("render" in child);
}
function normalizeSize(value: number | undefined, fallback: number): number {
return value === undefined || !Number.isFinite(value) ? fallback : Math.max(0, Math.floor(value));
}
export abstract class Stack extends Container {
protected readonly entries: StackLayoutEntry[] = [];
protected readonly gap: number;
protected readonly align: "stretch" | "start" | "center" | "end";
protected abstract readonly layoutType: "vstack" | "hstack";
constructor(children: StackChild[] = [], options: StackOptions = {}) {
super();
this.gap = normalizeSize(options.gap, 0);
this.align = options.align ?? "stretch";
for (const child of children) {
if (isStackEntry(child)) this.addChild(child.component, child);
else this.addChild(child);
}
}
override addChild(component: Component, options: StackEntryOptions = {}): void {
super.addChild(component);
this.entries.push({
component,
...(options.basis === undefined ? {} : { basis: options.basis }),
...(options.grow === undefined ? {} : { grow: normalizeSize(options.grow, 0) }),
...(options.shrink === undefined ? {} : { shrink: normalizeSize(options.shrink, 1) }),
...(options.minSize === undefined ? {} : { minSize: normalizeSize(options.minSize, 0) }),
...(options.maxSize === undefined ? {} : { maxSize: normalizeSize(options.maxSize, Number.MAX_SAFE_INTEGER) }),
...(options.visible === undefined ? {} : { visible: options.visible }),
});
}
override removeChild(component: Component): void {
super.removeChild(component);
const index = this.entries.findIndex((entry) => entry.component === component);
if (index !== -1) this.entries.splice(index, 1);
}
override clear(): void {
super.clear();
this.entries.length = 0;
}
[LAYOUT_NODE](): StackLayoutNode {
return {
type: this.layoutType,
entries: this.entries,
gap: this.gap,
align: this.align,
};
}
}
export function visibleStackEntries(
entries: readonly StackLayoutEntry[],
viewport: LayoutViewport,
): StackLayoutEntry[] {
return entries.filter((entry) => entry.visible?.(viewport) ?? true);
}
function clampSize(size: number, entry: StackLayoutEntry): number {
const min = Math.max(0, Math.floor(entry.minSize ?? 0));
const max = Math.max(min, Math.floor(entry.maxSize ?? Number.MAX_SAFE_INTEGER));
return Math.max(min, Math.min(max, Math.max(0, Math.floor(size))));
}
function distribute(
sizes: number[],
entries: readonly StackLayoutEntry[],
amount: number,
mode: "grow" | "shrink",
): void {
let remaining = amount;
while (remaining > 0) {
const candidates = entries
.map((entry, index) => ({ entry, index }))
.filter(({ entry, index }) => {
if (mode === "grow") {
return (entry.grow ?? 0) > 0 && sizes[index]! < (entry.maxSize ?? Number.MAX_SAFE_INTEGER);
}
return (entry.shrink ?? 1) > 0 && sizes[index]! > (entry.minSize ?? 0);
});
if (candidates.length === 0) return;
const totalWeight = candidates.reduce((sum, { entry, index }) => {
return sum + (mode === "grow" ? (entry.grow ?? 0) : (entry.shrink ?? 1) * Math.max(1, sizes[index]!));
}, 0);
let distributed = 0;
for (const { entry, index } of candidates) {
if (remaining <= 0) break;
const weight = mode === "grow" ? (entry.grow ?? 0) : (entry.shrink ?? 1) * Math.max(1, sizes[index]!);
const proposed = Math.max(1, Math.floor((remaining * weight) / totalWeight));
const capacity =
mode === "grow"
? (entry.maxSize ?? Number.MAX_SAFE_INTEGER) - sizes[index]!
: sizes[index]! - (entry.minSize ?? 0);
const delta = Math.min(remaining, proposed, capacity);
if (delta <= 0) continue;
sizes[index] = sizes[index]! + (mode === "grow" ? delta : -delta);
remaining -= delta;
distributed += delta;
}
if (distributed === 0) return;
}
}
export function allocateStackSizes(
entries: readonly StackLayoutEntry[],
intrinsicSizes: readonly number[],
availableSize: number | undefined,
gap: number,
): number[] {
const sizes = entries.map((entry, index) =>
clampSize(
entry.basis === undefined || entry.basis === "auto" ? (intrinsicSizes[index] ?? 0) : entry.basis,
entry,
),
);
if (availableSize === undefined) return sizes;
const contentSize = Math.max(0, Math.floor(availableSize) - Math.max(0, entries.length - 1) * gap);
const total = sizes.reduce((sum, size) => sum + size, 0);
if (total < contentSize) distribute(sizes, entries, contentSize - total, "grow");
else if (total > contentSize) distribute(sizes, entries, total - contentSize, "shrink");
return sizes;
}
|