From 6b3f4deb9da6e72eac190b4ff892f4986558aa8a Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Tue, 17 Feb 2026 18:54:42 +0400 Subject: [PATCH 1/7] fix(layout): handle sparse stack children safely Guard stack child constraint probes against non-VNode entries and skip sparse children in non-constraint measure loops. This prevents row/column measurement from throwing when children arrays contain undefined holes and adds a regression case in layout.edgecases. --- .../layout/__tests__/layout.edgecases.test.ts | 26 +++++++++++++++++++ packages/core/src/layout/engine/guards.ts | 10 ++++--- packages/core/src/layout/kinds/stack.ts | 2 ++ 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/packages/core/src/layout/__tests__/layout.edgecases.test.ts b/packages/core/src/layout/__tests__/layout.edgecases.test.ts index 669488a7..a4f3f97d 100644 --- a/packages/core/src/layout/__tests__/layout.edgecases.test.ts +++ b/packages/core/src/layout/__tests__/layout.edgecases.test.ts @@ -125,6 +125,32 @@ describe("layout edge cases", () => { assert.deepEqual(laidOut.value.rect, { x: 0, y: 0, w: 0, h: 2 }); }); + test("row/column tolerate sparse children arrays without throwing", () => { + const sparseRow = { + kind: "row", + props: {}, + children: Object.freeze([{ kind: "text", text: "A", props: {} }, undefined]), + } as unknown as VNode; + const rowLayout = layout(sparseRow, 0, 0, 10, 5, "row"); + assert.equal(rowLayout.ok, true, "row with sparse children should not throw"); + if (rowLayout.ok) { + assert.equal(rowLayout.value.children.length, 1); + assert.equal(rowLayout.value.children[0]?.rect.w, 1); + } + + const sparseColumn = { + kind: "column", + props: {}, + children: Object.freeze([{ kind: "text", text: "B", props: {} }, undefined]), + } as unknown as VNode; + const columnLayout = layout(sparseColumn, 0, 0, 10, 5, "column"); + assert.equal(columnLayout.ok, true, "column with sparse children should not throw"); + if (columnLayout.ok) { + assert.equal(columnLayout.value.children.length, 1); + assert.equal(columnLayout.value.children[0]?.rect.h, 1); + } + }); + test("hit testing respects ancestor clip bounds", () => { const child: VNode = { kind: "button", props: { id: "clipped-btn", label: "Click" } }; const root: VNode = { diff --git a/packages/core/src/layout/engine/guards.ts b/packages/core/src/layout/engine/guards.ts index 2b7b64e2..5cb25ab0 100644 --- a/packages/core/src/layout/engine/guards.ts +++ b/packages/core/src/layout/engine/guards.ts @@ -8,14 +8,16 @@ export function isVNode(v: unknown): v is VNode { return typeof v === "object" && v !== null && "kind" in v; } -export function getConstraintProps(vnode: VNode): ConstraintPropsBag | null { +export function getConstraintProps(vnode: unknown): ConstraintPropsBag | null { + if (!isVNode(vnode)) return null; if (vnode.kind === "box" || vnode.kind === "row" || vnode.kind === "column") { return vnode.props as ConstraintPropsBag; } return null; } -export function childHasFlexInMainAxis(vnode: VNode, axis: Axis): boolean { +export function childHasFlexInMainAxis(vnode: unknown, axis: Axis): boolean { + if (!isVNode(vnode)) return false; if (vnode.kind === "spacer") { const flex = (vnode.props as { flex?: unknown }).flex; return typeof flex === "number" && Number.isFinite(flex) && flex > 0; @@ -26,14 +28,14 @@ export function childHasFlexInMainAxis(vnode: VNode, axis: Axis): boolean { return typeof flex === "number" && Number.isFinite(flex) && flex > 0; } -export function childHasPercentInMainAxis(vnode: VNode, axis: Axis): boolean { +export function childHasPercentInMainAxis(vnode: unknown, axis: Axis): boolean { const p = getConstraintProps(vnode); if (!p) return false; const main = axis === "row" ? p.width : p.height; return isPercentString(main); } -export function childHasPercentInCrossAxis(vnode: VNode, axis: Axis): boolean { +export function childHasPercentInCrossAxis(vnode: unknown, axis: Axis): boolean { const p = getConstraintProps(vnode); if (!p) return false; const cross = axis === "row" ? p.height : p.width; diff --git a/packages/core/src/layout/kinds/stack.ts b/packages/core/src/layout/kinds/stack.ts index cd9969d8..cf55039f 100644 --- a/packages/core/src/layout/kinds/stack.ts +++ b/packages/core/src/layout/kinds/stack.ts @@ -206,6 +206,7 @@ export function measureStackKinds( let laidOutCount = 0; for (const child of vnode.children) { + if (!child) continue; if (remainingWidth === 0) { // Still validate subtree deterministically, even if it gets assigned {w:0,h:0}. const zeroRes = measureNode(child, 0, 0, "row"); @@ -410,6 +411,7 @@ export function measureStackKinds( let laidOutCount = 0; for (const child of vnode.children) { + if (!child) continue; if (remainingHeight === 0) { // Still validate subtree deterministically, even if it gets assigned {w:0,h:0}. const zeroRes = measureNode(child, 0, 0, "column"); From db491aee5846cea7243b42abd2e0cf27821d3f88 Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Tue, 17 Feb 2026 18:54:52 +0400 Subject: [PATCH 2/7] test(layout): add exhaustive edge-case coverage across A-I Add deterministic layout suites for flex distribution, percentage sizing, auto sizing, margin interactions, aspect ratio, zero/empty edges, stability signatures, measure cache behavior, and hit-test clip boundaries. --- .../__tests__/layout.aspect-ratio.test.ts | 151 ++++++++ .../__tests__/layout.auto-sizing.test.ts | 231 ++++++++++++ .../layout.flex-distribution.test.ts | 309 ++++++++++++++++ .../__tests__/layout.hit-test-edge.test.ts | 202 +++++++++++ .../layout/__tests__/layout.margin.test.ts | 237 ++++++++++++ .../__tests__/layout.measure-cache.test.ts | 186 ++++++++++ .../__tests__/layout.percentage.test.ts | 342 ++++++++++++++++++ .../layout.stability-signature.test.ts | 213 +++++++++++ .../layout/__tests__/layout.zero-edge.test.ts | 249 +++++++++++++ 9 files changed, 2120 insertions(+) create mode 100644 packages/core/src/layout/__tests__/layout.aspect-ratio.test.ts create mode 100644 packages/core/src/layout/__tests__/layout.auto-sizing.test.ts create mode 100644 packages/core/src/layout/__tests__/layout.flex-distribution.test.ts create mode 100644 packages/core/src/layout/__tests__/layout.hit-test-edge.test.ts create mode 100644 packages/core/src/layout/__tests__/layout.margin.test.ts create mode 100644 packages/core/src/layout/__tests__/layout.measure-cache.test.ts create mode 100644 packages/core/src/layout/__tests__/layout.percentage.test.ts create mode 100644 packages/core/src/layout/__tests__/layout.stability-signature.test.ts create mode 100644 packages/core/src/layout/__tests__/layout.zero-edge.test.ts diff --git a/packages/core/src/layout/__tests__/layout.aspect-ratio.test.ts b/packages/core/src/layout/__tests__/layout.aspect-ratio.test.ts new file mode 100644 index 00000000..2552c20a --- /dev/null +++ b/packages/core/src/layout/__tests__/layout.aspect-ratio.test.ts @@ -0,0 +1,151 @@ +import { assert, describe, test } from "@rezi-ui/testkit"; +import type { VNode } from "../../index.js"; +import type { LayoutTree } from "../layout.js"; +import { layout } from "../layout.js"; +import type { Axis } from "../types.js"; + +function mustLayout( + node: VNode, + maxW: number, + maxH: number, + axis: Axis = "column", +): LayoutTree { + const res = layout(node, 0, 0, maxW, maxH, axis); + assert.equal(res.ok, true, "layout should succeed"); + if (!res.ok) { + throw new Error("layout failed"); + } + return res.value; +} + +describe("layout aspect-ratio (deterministic)", () => { + test("width -> height derives via aspectRatio", () => { + const node: VNode = { + kind: "box", + props: { border: "none", width: 8, aspectRatio: 2 }, + children: Object.freeze([]), + }; + const out = mustLayout(node, 30, 20); + assert.deepEqual(out.rect, { x: 0, y: 0, w: 8, h: 4 }); + }); + + test("height -> width derives via aspectRatio", () => { + const node: VNode = { + kind: "box", + props: { border: "none", height: 6, aspectRatio: 2 }, + children: Object.freeze([]), + }; + const out = mustLayout(node, 30, 20); + assert.deepEqual(out.rect, { x: 0, y: 0, w: 12, h: 6 }); + }); + + test("both width and height take precedence over aspectRatio", () => { + const node: VNode = { + kind: "box", + props: { border: "none", width: 10, height: 3, aspectRatio: 2 }, + children: Object.freeze([]), + }; + const out = mustLayout(node, 30, 20); + assert.deepEqual(out.rect, { x: 0, y: 0, w: 10, h: 3 }); + }); + + test("derived height uses floor rounding", () => { + const node: VNode = { + kind: "box", + props: { border: "none", width: 7, aspectRatio: 2 }, + children: Object.freeze([]), + }; + const out = mustLayout(node, 30, 20); + assert.deepEqual(out.rect, { x: 0, y: 0, w: 7, h: 3 }); + }); + + test('percent width with aspectRatio resolves against parent width', () => { + const node: VNode = { + kind: "box", + props: { border: "none", width: "50%", aspectRatio: 2 }, + children: Object.freeze([]), + }; + const out = mustLayout(node, 15, 20); + assert.deepEqual(out.rect, { x: 0, y: 0, w: 7, h: 3 }); + }); + + test('percent height with aspectRatio resolves against parent height', () => { + const node: VNode = { + kind: "box", + props: { border: "none", height: "25%", aspectRatio: 2 }, + children: Object.freeze([]), + }; + const out = mustLayout(node, 20, 13); + assert.deepEqual(out.rect, { x: 0, y: 0, w: 6, h: 3 }); + }); + + test("minHeight clamp applies after width -> height derivation", () => { + const node: VNode = { + kind: "box", + props: { border: "none", width: 8, aspectRatio: 2, minHeight: 5 }, + children: Object.freeze([]), + }; + const out = mustLayout(node, 30, 20); + assert.deepEqual(out.rect, { x: 0, y: 0, w: 8, h: 5 }); + }); + + test("maxHeight clamp applies after width -> height derivation", () => { + const node: VNode = { + kind: "box", + props: { border: "none", width: 20, aspectRatio: 2, maxHeight: 6 }, + children: Object.freeze([]), + }; + const out = mustLayout(node, 30, 20); + assert.deepEqual(out.rect, { x: 0, y: 0, w: 20, h: 6 }); + }); + + test("minWidth clamp applies after height -> width derivation", () => { + const node: VNode = { + kind: "box", + props: { border: "none", height: 4, aspectRatio: 2, minWidth: 10 }, + children: Object.freeze([]), + }; + const out = mustLayout(node, 30, 20); + assert.deepEqual(out.rect, { x: 0, y: 0, w: 10, h: 4 }); + }); + + test("maxWidth clamp applies after height -> width derivation", () => { + const node: VNode = { + kind: "box", + props: { border: "none", height: 10, aspectRatio: 2, maxWidth: 15 }, + children: Object.freeze([]), + }; + const out = mustLayout(node, 30, 20); + assert.deepEqual(out.rect, { x: 0, y: 0, w: 15, h: 10 }); + }); + + test("row flex context respects aspect-derived fixed width", () => { + const row: VNode = { + kind: "row", + props: {}, + children: Object.freeze([ + { kind: "box", props: { border: "none", height: 4, aspectRatio: 2 }, children: Object.freeze([]) }, + { kind: "box", props: { border: "none", flex: 1 }, children: Object.freeze([]) }, + ]), + }; + const out = mustLayout(row, 20, 6, "row"); + assert.deepEqual(out.rect, { x: 0, y: 0, w: 20, h: 4 }); + assert.deepEqual(out.children[0]?.rect, { x: 0, y: 0, w: 8, h: 4 }); + assert.deepEqual(out.children[1]?.rect, { x: 8, y: 0, w: 12, h: 0 }); + }); + + test("column flex context respects aspect-derived fixed height", () => { + const column: VNode = { + kind: "column", + props: {}, + children: Object.freeze([ + { kind: "box", props: { border: "none", width: 6, aspectRatio: 2 }, children: Object.freeze([]) }, + { kind: "box", props: { border: "none", flex: 1 }, children: Object.freeze([]) }, + ]), + }; + const out = mustLayout(column, 8, 12, "column"); + assert.deepEqual(out.rect, { x: 0, y: 0, w: 6, h: 12 }); + assert.deepEqual(out.children[0]?.rect, { x: 0, y: 0, w: 6, h: 3 }); + assert.deepEqual(out.children[1]?.rect, { x: 0, y: 3, w: 0, h: 9 }); + }); +}); diff --git a/packages/core/src/layout/__tests__/layout.auto-sizing.test.ts b/packages/core/src/layout/__tests__/layout.auto-sizing.test.ts new file mode 100644 index 00000000..428bc0d0 --- /dev/null +++ b/packages/core/src/layout/__tests__/layout.auto-sizing.test.ts @@ -0,0 +1,231 @@ +import { assert, describe, test } from "@rezi-ui/testkit"; +import { type VNode, ui } from "../../index.js"; +import { type LayoutTree, layout } from "../layout.js"; + +type Axis = "row" | "column"; +type Rect = Readonly<{ x: number; y: number; w: number; h: number }>; + +type AutoCase = Readonly<{ + name: string; + vnode: VNode; + axis: Axis; + maxW: number; + maxH: number; + expectedRoot: Rect; + expectedChildren: readonly Rect[]; + expectedChild0Children?: readonly Rect[]; +}>; + +function mustLayout(node: VNode, maxW: number, maxH: number, axis: Axis): LayoutTree { + const res = layout(node, 0, 0, maxW, maxH, axis); + if (!res.ok) { + assert.fail(`layout failed: ${res.fatal.code}: ${res.fatal.detail}`); + } + return res.value; +} + +const CASES: readonly AutoCase[] = [ + { + name: 'row width:"auto" shrinks to text content + gap', + vnode: ui.row({ width: "auto", gap: 1 }, [ui.text("abc"), ui.text("de")]), + axis: "row", + maxW: 50, + maxH: 10, + expectedRoot: { x: 0, y: 0, w: 6, h: 1 }, + expectedChildren: [ + { x: 0, y: 0, w: 3, h: 1 }, + { x: 4, y: 0, w: 2, h: 1 }, + ], + }, + { + name: 'column height:"auto" shrinks to text content + gap', + vnode: ui.column({ height: "auto", gap: 1 }, [ui.text("abc"), ui.text("de")]), + axis: "column", + maxW: 50, + maxH: 10, + expectedRoot: { x: 0, y: 0, w: 3, h: 3 }, + expectedChildren: [ + { x: 0, y: 0, w: 3, h: 1 }, + { x: 0, y: 2, w: 2, h: 1 }, + ], + }, + { + name: 'box width/height:"auto" sizes to child content', + vnode: ui.box({ border: "none", width: "auto", height: "auto" }, [ui.text("hello")]), + axis: "column", + maxW: 50, + maxH: 10, + expectedRoot: { x: 0, y: 0, w: 5, h: 1 }, + expectedChildren: [{ x: 0, y: 0, w: 5, h: 1 }], + }, + { + name: 'auto box includes padding in intrinsic size', + vnode: ui.box({ border: "none", width: "auto", height: "auto", p: 2 }, [ui.text("hello")]), + axis: "column", + maxW: 50, + maxH: 10, + expectedRoot: { x: 0, y: 0, w: 9, h: 5 }, + expectedChildren: [{ x: 2, y: 2, w: 5, h: 1 }], + }, + { + name: 'row auto child without flex does not join flex distribution', + vnode: ui.row({ width: 20, height: 4, gap: 1 }, [ + ui.box({ border: "none", width: "auto" }, [ui.text("abcd")]), + ui.box({ border: "none", flex: 1 }, []), + ]), + axis: "row", + maxW: 20, + maxH: 4, + expectedRoot: { x: 0, y: 0, w: 20, h: 4 }, + expectedChildren: [ + { x: 0, y: 0, w: 4, h: 1 }, + { x: 5, y: 0, w: 15, h: 0 }, + ], + expectedChild0Children: [{ x: 0, y: 0, w: 4, h: 1 }], + }, + { + name: "row auto child with flex participates in distribution", + vnode: ui.row({ width: 20, height: 4, gap: 1 }, [ + ui.box({ border: "none", width: "auto", flex: 1 }, [ui.text("abcd")]), + ui.box({ border: "none", flex: 1 }, []), + ]), + axis: "row", + maxW: 20, + maxH: 4, + expectedRoot: { x: 0, y: 0, w: 20, h: 4 }, + expectedChildren: [ + { x: 0, y: 0, w: 10, h: 1 }, + { x: 11, y: 0, w: 9, h: 0 }, + ], + expectedChild0Children: [{ x: 0, y: 0, w: 4, h: 1 }], + }, + { + name: "row auto child respects maxWidth before flex remainder", + vnode: ui.row({ width: 20, height: 4, gap: 1 }, [ + ui.box({ border: "none", width: "auto", maxWidth: 2 }, [ui.text("abcdef")]), + ui.box({ border: "none", flex: 1 }, []), + ]), + axis: "row", + maxW: 20, + maxH: 4, + expectedRoot: { x: 0, y: 0, w: 20, h: 4 }, + expectedChildren: [ + { x: 0, y: 0, w: 2, h: 1 }, + { x: 3, y: 0, w: 17, h: 0 }, + ], + expectedChild0Children: [{ x: 0, y: 0, w: 2, h: 1 }], + }, + { + name: "row auto child respects minWidth before flex remainder", + vnode: ui.row({ width: 20, height: 4, gap: 1 }, [ + ui.box({ border: "none", width: "auto", minWidth: 6 }, [ui.text("ab")]), + ui.box({ border: "none", flex: 1 }, []), + ]), + axis: "row", + maxW: 20, + maxH: 4, + expectedRoot: { x: 0, y: 0, w: 20, h: 4 }, + expectedChildren: [ + { x: 0, y: 0, w: 6, h: 1 }, + { x: 7, y: 0, w: 13, h: 0 }, + ], + expectedChild0Children: [{ x: 0, y: 0, w: 2, h: 1 }], + }, + { + name: 'column auto child without flex does not join flex distribution', + vnode: ui.column({ width: 12, height: 12, gap: 1 }, [ + ui.box({ border: "none", height: "auto" }, [ui.text("x")]), + ui.box({ border: "none", flex: 1 }, []), + ]), + axis: "column", + maxW: 12, + maxH: 12, + expectedRoot: { x: 0, y: 0, w: 12, h: 12 }, + expectedChildren: [ + { x: 0, y: 0, w: 1, h: 1 }, + { x: 0, y: 2, w: 0, h: 10 }, + ], + expectedChild0Children: [{ x: 0, y: 0, w: 1, h: 1 }], + }, + { + name: "column auto child with flex participates in distribution", + vnode: ui.column({ width: 12, height: 12, gap: 1 }, [ + ui.box({ border: "none", height: "auto", flex: 1 }, [ui.text("x")]), + ui.box({ border: "none", flex: 1 }, []), + ]), + axis: "column", + maxW: 12, + maxH: 12, + expectedRoot: { x: 0, y: 0, w: 12, h: 12 }, + expectedChildren: [ + { x: 0, y: 0, w: 1, h: 6 }, + { x: 0, y: 7, w: 0, h: 5 }, + ], + expectedChild0Children: [{ x: 0, y: 0, w: 1, h: 1 }], + }, + { + name: "auto row without cross-axis percent keeps intrinsic height", + vnode: ui.row({ width: "auto" }, [ui.box({ border: "none", width: "auto" }, [ui.text("xx")])]), + axis: "row", + maxW: 10, + maxH: 10, + expectedRoot: { x: 0, y: 0, w: 2, h: 1 }, + expectedChildren: [{ x: 0, y: 0, w: 2, h: 1 }], + expectedChild0Children: [{ x: 0, y: 0, w: 2, h: 1 }], + }, + { + name: "auto row with cross-axis percent fills available height", + vnode: ui.row({ width: "auto" }, [ + ui.box({ border: "none", width: "auto", height: "50%" }, [ui.text("xx")]), + ]), + axis: "row", + maxW: 10, + maxH: 10, + expectedRoot: { x: 0, y: 0, w: 2, h: 10 }, + expectedChildren: [{ x: 0, y: 0, w: 2, h: 5 }], + expectedChild0Children: [{ x: 0, y: 0, w: 2, h: 1 }], + }, + { + name: "auto column without cross-axis percent keeps intrinsic width", + vnode: ui.column({ height: "auto" }, [ + ui.box({ border: "none", height: "auto" }, [ui.text("xx")]), + ]), + axis: "column", + maxW: 10, + maxH: 10, + expectedRoot: { x: 0, y: 0, w: 2, h: 1 }, + expectedChildren: [{ x: 0, y: 0, w: 2, h: 1 }], + expectedChild0Children: [{ x: 0, y: 0, w: 2, h: 1 }], + }, + { + name: "auto column with cross-axis percent fills available width", + vnode: ui.column({ height: "auto" }, [ + ui.box({ border: "none", height: "auto", width: "50%" }, [ui.text("xx")]), + ]), + axis: "column", + maxW: 10, + maxH: 10, + expectedRoot: { x: 0, y: 0, w: 10, h: 1 }, + expectedChildren: [{ x: 0, y: 0, w: 5, h: 1 }], + expectedChild0Children: [{ x: 0, y: 0, w: 2, h: 1 }], + }, +] as const; + +describe("layout auto sizing (deterministic)", () => { + for (const c of CASES) { + test(c.name, () => { + const tree = mustLayout(c.vnode, c.maxW, c.maxH, c.axis); + assert.deepEqual(tree.rect, c.expectedRoot); + assert.deepEqual( + tree.children.map((child) => child.rect), + c.expectedChildren, + ); + if (c.expectedChild0Children !== undefined) { + assert.deepEqual( + tree.children[0]?.children.map((child) => child.rect), + c.expectedChild0Children, + ); + } + }); + } +}); diff --git a/packages/core/src/layout/__tests__/layout.flex-distribution.test.ts b/packages/core/src/layout/__tests__/layout.flex-distribution.test.ts new file mode 100644 index 00000000..f21ca56e --- /dev/null +++ b/packages/core/src/layout/__tests__/layout.flex-distribution.test.ts @@ -0,0 +1,309 @@ +import { assert, describe, test } from "@rezi-ui/testkit"; +import { type VNode, ui } from "../../index.js"; +import { type LayoutTree, layout } from "../layout.js"; + +type Axis = "row" | "column"; + +type ChildSpec = Readonly<{ + flex?: number; + main?: number | `${number}%`; + min?: number; + max?: number; +}>; + +type Rect = Readonly<{ x: number; y: number; w: number; h: number }>; + +type FlexCase = Readonly<{ + name: string; + main: number; + cross: number; + gap?: number; + children: readonly ChildSpec[]; + expectedRowChildren: readonly Rect[]; + expectedColumnChildren: readonly Rect[]; +}>; + +function mustLayout(node: VNode, maxW: number, maxH: number, axis: Axis): LayoutTree { + const res = layout(node, 0, 0, maxW, maxH, axis); + if (!res.ok) { + assert.fail(`layout failed: ${res.fatal.code}: ${res.fatal.detail}`); + } + return res.value; +} + +function buildChild(axis: Axis, spec: ChildSpec): VNode { + const props: { + border: "none"; + flex?: number; + width?: number | `${number}%`; + height?: number | `${number}%`; + minWidth?: number; + minHeight?: number; + maxWidth?: number; + maxHeight?: number; + } = { border: "none" }; + + if (spec.flex !== undefined) props.flex = spec.flex; + + if (axis === "row") { + if (spec.main !== undefined) props.width = spec.main; + if (spec.min !== undefined) props.minWidth = spec.min; + if (spec.max !== undefined) props.maxWidth = spec.max; + } else { + if (spec.main !== undefined) props.height = spec.main; + if (spec.min !== undefined) props.minHeight = spec.min; + if (spec.max !== undefined) props.maxHeight = spec.max; + } + + return ui.box(props, []); +} + +function buildStack(axis: Axis, c: FlexCase): VNode { + const props: { width?: number; height?: number; gap?: number } = {}; + if (axis === "row") { + props.width = c.main; + props.height = c.cross; + } else { + props.width = c.cross; + props.height = c.main; + } + if (c.gap !== undefined) props.gap = c.gap; + + const children = c.children.map((spec) => buildChild(axis, spec)); + return axis === "row" ? ui.row(props, children) : ui.column(props, children); +} + +const CASES: readonly FlexCase[] = [ + { + name: "flex:1 + flex:2 splits 90 as 30/60", + main: 90, + cross: 5, + children: [{ flex: 1 }, { flex: 2 }], + expectedRowChildren: [ + { x: 0, y: 0, w: 30, h: 0 }, + { x: 30, y: 0, w: 60, h: 0 }, + ], + expectedColumnChildren: [ + { x: 0, y: 0, w: 0, h: 30 }, + { x: 0, y: 30, w: 0, h: 60 }, + ], + }, + { + name: "flex:0 child does not receive distributed space", + main: 30, + cross: 5, + children: [{ flex: 0 }, { flex: 1 }], + expectedRowChildren: [ + { x: 0, y: 0, w: 0, h: 0 }, + { x: 0, y: 0, w: 30, h: 0 }, + ], + expectedColumnChildren: [ + { x: 0, y: 0, w: 0, h: 0 }, + { x: 0, y: 0, w: 0, h: 30 }, + ], + }, + { + name: "fractional 1/1/1 with gap rounds deterministically", + main: 10, + cross: 5, + gap: 1, + children: [{ flex: 1 }, { flex: 1 }, { flex: 1 }], + expectedRowChildren: [ + { x: 0, y: 0, w: 3, h: 0 }, + { x: 4, y: 0, w: 3, h: 0 }, + { x: 8, y: 0, w: 2, h: 0 }, + ], + expectedColumnChildren: [ + { x: 0, y: 0, w: 0, h: 3 }, + { x: 0, y: 4, w: 0, h: 3 }, + { x: 0, y: 8, w: 0, h: 2 }, + ], + }, + { + name: "fractional 1/2/3 in 17 => 3/6/8", + main: 17, + cross: 5, + children: [{ flex: 1 }, { flex: 2 }, { flex: 3 }], + expectedRowChildren: [ + { x: 0, y: 0, w: 3, h: 0 }, + { x: 3, y: 0, w: 6, h: 0 }, + { x: 9, y: 0, w: 8, h: 0 }, + ], + expectedColumnChildren: [ + { x: 0, y: 0, w: 0, h: 3 }, + { x: 0, y: 3, w: 0, h: 6 }, + { x: 0, y: 9, w: 0, h: 8 }, + ], + }, + { + name: "min/max constraints override naive equal flex", + main: 20, + cross: 5, + children: [{ flex: 1, min: 7 }, { flex: 1, max: 3 }, { flex: 1 }], + expectedRowChildren: [ + { x: 0, y: 0, w: 9, h: 0 }, + { x: 9, y: 0, w: 3, h: 0 }, + { x: 12, y: 0, w: 8, h: 0 }, + ], + expectedColumnChildren: [ + { x: 0, y: 0, w: 0, h: 9 }, + { x: 0, y: 9, w: 0, h: 3 }, + { x: 0, y: 12, w: 0, h: 8 }, + ], + }, + { + name: "percent children are resolved before flex distribution", + main: 20, + cross: 5, + children: [{ main: "50%" }, { flex: 1 }, { main: "25%" }, { flex: 1 }], + expectedRowChildren: [ + { x: 0, y: 0, w: 10, h: 0 }, + { x: 10, y: 0, w: 3, h: 0 }, + { x: 13, y: 0, w: 5, h: 0 }, + { x: 18, y: 0, w: 2, h: 0 }, + ], + expectedColumnChildren: [ + { x: 0, y: 0, w: 0, h: 10 }, + { x: 0, y: 10, w: 0, h: 3 }, + { x: 0, y: 13, w: 0, h: 5 }, + { x: 0, y: 18, w: 0, h: 2 }, + ], + }, + { + name: "gap is subtracted before flex share computation", + main: 25, + cross: 5, + gap: 2, + children: [{ flex: 1 }, { flex: 1 }, { flex: 1 }], + expectedRowChildren: [ + { x: 0, y: 0, w: 7, h: 0 }, + { x: 9, y: 0, w: 7, h: 0 }, + { x: 18, y: 0, w: 7, h: 0 }, + ], + expectedColumnChildren: [ + { x: 0, y: 0, w: 0, h: 7 }, + { x: 0, y: 9, w: 0, h: 7 }, + { x: 0, y: 18, w: 0, h: 7 }, + ], + }, + { + name: "max caps trigger redistribution to uncapped flex siblings", + main: 18, + cross: 5, + children: [{ flex: 1, max: 2 }, { flex: 1, max: 4 }, { flex: 1 }], + expectedRowChildren: [ + { x: 0, y: 0, w: 2, h: 0 }, + { x: 2, y: 0, w: 4, h: 0 }, + { x: 6, y: 0, w: 12, h: 0 }, + ], + expectedColumnChildren: [ + { x: 0, y: 0, w: 0, h: 2 }, + { x: 0, y: 2, w: 0, h: 4 }, + { x: 0, y: 6, w: 0, h: 12 }, + ], + }, + { + name: "fixed main-size child is allocated before flex children", + main: 19, + cross: 5, + gap: 1, + children: [{ main: 4 }, { flex: 2 }, { flex: 1 }], + expectedRowChildren: [ + { x: 0, y: 0, w: 4, h: 0 }, + { x: 5, y: 0, w: 9, h: 0 }, + { x: 15, y: 0, w: 4, h: 0 }, + ], + expectedColumnChildren: [ + { x: 0, y: 0, w: 0, h: 4 }, + { x: 0, y: 5, w: 0, h: 9 }, + { x: 0, y: 15, w: 0, h: 4 }, + ], + }, + { + name: "flex:0 with explicit fixed main-size keeps fixed value", + main: 16, + cross: 5, + children: [{ main: 5, flex: 0 }, { flex: 1 }], + expectedRowChildren: [ + { x: 0, y: 0, w: 5, h: 0 }, + { x: 5, y: 0, w: 11, h: 0 }, + ], + expectedColumnChildren: [ + { x: 0, y: 0, w: 0, h: 5 }, + { x: 0, y: 5, w: 0, h: 11 }, + ], + }, + { + name: "min constraints do not backfill when no remaining space exists", + main: 12, + cross: 5, + children: [{ flex: 1, min: 8 }, { flex: 1 }], + expectedRowChildren: [ + { x: 0, y: 0, w: 6, h: 0 }, + { x: 6, y: 0, w: 6, h: 0 }, + ], + expectedColumnChildren: [ + { x: 0, y: 0, w: 0, h: 6 }, + { x: 0, y: 6, w: 0, h: 6 }, + ], + }, + { + name: "fractional remainder ties break by lower child index", + main: 8, + cross: 5, + children: [{ flex: 1 }, { flex: 1 }, { flex: 1 }, { flex: 1 }, { flex: 1 }], + expectedRowChildren: [ + { x: 0, y: 0, w: 2, h: 0 }, + { x: 2, y: 0, w: 2, h: 0 }, + { x: 4, y: 0, w: 2, h: 0 }, + { x: 6, y: 0, w: 1, h: 0 }, + { x: 7, y: 0, w: 1, h: 0 }, + ], + expectedColumnChildren: [ + { x: 0, y: 0, w: 0, h: 2 }, + { x: 0, y: 2, w: 0, h: 2 }, + { x: 0, y: 4, w: 0, h: 2 }, + { x: 0, y: 6, w: 0, h: 1 }, + { x: 0, y: 7, w: 0, h: 1 }, + ], + }, + { + name: "all flex:0 children remain zero-size and only advance by gap", + main: 10, + cross: 5, + gap: 1, + children: [{ flex: 0 }, { flex: 0 }, { flex: 0 }], + expectedRowChildren: [ + { x: 0, y: 0, w: 0, h: 0 }, + { x: 1, y: 0, w: 0, h: 0 }, + { x: 2, y: 0, w: 0, h: 0 }, + ], + expectedColumnChildren: [ + { x: 0, y: 0, w: 0, h: 0 }, + { x: 0, y: 1, w: 0, h: 0 }, + { x: 0, y: 2, w: 0, h: 0 }, + ], + }, +] as const; + +describe("layout flex distribution (deterministic)", () => { + for (const c of CASES) { + test(`row: ${c.name}`, () => { + const tree = mustLayout(buildStack("row", c), c.main, c.cross, "row"); + assert.deepEqual(tree.rect, { x: 0, y: 0, w: c.main, h: c.cross }); + assert.deepEqual( + tree.children.map((child) => child.rect), + c.expectedRowChildren, + ); + }); + + test(`column: ${c.name}`, () => { + const tree = mustLayout(buildStack("column", c), c.cross, c.main, "column"); + assert.deepEqual(tree.rect, { x: 0, y: 0, w: c.cross, h: c.main }); + assert.deepEqual( + tree.children.map((child) => child.rect), + c.expectedColumnChildren, + ); + }); + } +}); diff --git a/packages/core/src/layout/__tests__/layout.hit-test-edge.test.ts b/packages/core/src/layout/__tests__/layout.hit-test-edge.test.ts new file mode 100644 index 00000000..5e43a71f --- /dev/null +++ b/packages/core/src/layout/__tests__/layout.hit-test-edge.test.ts @@ -0,0 +1,202 @@ +import { assert, describe, test } from "@rezi-ui/testkit"; +import type { VNode } from "../../index.js"; +import { hitTestFocusable } from "../hitTest.js"; +import type { LayoutTree } from "../layout.js"; +import type { Rect } from "../types.js"; + +function buttonNode(id: string): VNode { + return { kind: "button", props: { id, label: id } } as unknown as VNode; +} + +function containerNode(children: readonly VNode[]): VNode { + return { + kind: "column", + props: {}, + children: Object.freeze([...children]), + } as unknown as VNode; +} + +function layoutNode(vnode: VNode, rect: Rect, children: readonly LayoutTree[] = []): LayoutTree { + return { + vnode, + rect, + children: Object.freeze([...children]), + }; +} + +describe("hit test edge behavior", () => { + test("nested clip intersections allow hits inside the final intersection", () => { + const leaf = buttonNode("btn"); + const parent = containerNode([leaf]); + const root = containerNode([parent]); + + const tree = layoutNode(root, { x: 0, y: 0, w: 8, h: 4 }, [ + layoutNode(parent, { x: 6, y: 0, w: 6, h: 4 }, [ + layoutNode(leaf, { x: 7, y: 1, w: 4, h: 2 }), + ]), + ]); + + assert.equal(hitTestFocusable(root, tree, 7, 1), "btn"); + }); + + test("nested clip intersections exclude overflow outside combined clip", () => { + const leaf = buttonNode("btn"); + const parent = containerNode([leaf]); + const root = containerNode([parent]); + + const tree = layoutNode(root, { x: 0, y: 0, w: 8, h: 4 }, [ + layoutNode(parent, { x: 6, y: 0, w: 6, h: 4 }, [ + layoutNode(leaf, { x: 7, y: 1, w: 4, h: 2 }), + ]), + ]); + + assert.equal(hitTestFocusable(root, tree, 8, 1), null); + }); + + test("triple-nested clip intersection still hits at one-cell overlap", () => { + const leaf = buttonNode("deep"); + const midB = containerNode([leaf]); + const midA = containerNode([midB]); + const root = containerNode([midA]); + + const tree = layoutNode(root, { x: 0, y: 0, w: 10, h: 5 }, [ + layoutNode(midA, { x: 2, y: 0, w: 8, h: 5 }, [ + layoutNode(midB, { x: 5, y: 0, w: 6, h: 5 }, [ + layoutNode(leaf, { x: 9, y: 2, w: 4, h: 1 }), + ]), + ]), + ]); + + assert.equal(hitTestFocusable(root, tree, 9, 2), "deep"); + }); + + test("triple-nested clip excludes points just outside overlap", () => { + const leaf = buttonNode("deep"); + const midB = containerNode([leaf]); + const midA = containerNode([midB]); + const root = containerNode([midA]); + + const tree = layoutNode(root, { x: 0, y: 0, w: 10, h: 5 }, [ + layoutNode(midA, { x: 2, y: 0, w: 8, h: 5 }, [ + layoutNode(midB, { x: 5, y: 0, w: 6, h: 5 }, [ + layoutNode(leaf, { x: 9, y: 2, w: 4, h: 1 }), + ]), + ]), + ]); + + assert.equal(hitTestFocusable(root, tree, 10, 2), null); + }); + + test("left and top boundaries are inclusive", () => { + const leaf = buttonNode("edge"); + const root = containerNode([leaf]); + const tree = layoutNode(root, { x: 0, y: 0, w: 20, h: 10 }, [ + layoutNode(leaf, { x: 3, y: 4, w: 5, h: 2 }), + ]); + + assert.equal(hitTestFocusable(root, tree, 3, 4), "edge"); + }); + + test("right boundary is exclusive", () => { + const leaf = buttonNode("edge"); + const root = containerNode([leaf]); + const tree = layoutNode(root, { x: 0, y: 0, w: 20, h: 10 }, [ + layoutNode(leaf, { x: 3, y: 4, w: 5, h: 2 }), + ]); + + assert.equal(hitTestFocusable(root, tree, 8, 4), null); + }); + + test("bottom boundary is exclusive", () => { + const leaf = buttonNode("edge"); + const root = containerNode([leaf]); + const tree = layoutNode(root, { x: 0, y: 0, w: 20, h: 10 }, [ + layoutNode(leaf, { x: 3, y: 4, w: 5, h: 2 }), + ]); + + assert.equal(hitTestFocusable(root, tree, 3, 6), null); + }); + + test("empty root clip produces no hits", () => { + const leaf = buttonNode("zero"); + const root = containerNode([leaf]); + const tree = layoutNode(root, { x: 0, y: 0, w: 0, h: 5 }, [ + layoutNode(leaf, { x: 0, y: 0, w: 3, h: 1 }), + ]); + + assert.equal(hitTestFocusable(root, tree, 0, 0), null); + }); + + test("empty clip from ancestor intersection produces no hits", () => { + const leaf = buttonNode("zero"); + const parent = containerNode([leaf]); + const root = containerNode([parent]); + + const tree = layoutNode(root, { x: 0, y: 0, w: 5, h: 2 }, [ + layoutNode(parent, { x: 0, y: 2, w: 5, h: 2 }, [ + layoutNode(leaf, { x: 0, y: 2, w: 3, h: 1 }), + ]), + ]); + + assert.equal(hitTestFocusable(root, tree, 0, 2), null); + }); + + test("deep nesting still resolves hits deterministically", () => { + const leaf = buttonNode("deep-leaf"); + const n5 = containerNode([leaf]); + const n4 = containerNode([n5]); + const n3 = containerNode([n4]); + const n2 = containerNode([n3]); + const n1 = containerNode([n2]); + const root = containerNode([n1]); + + const tree = layoutNode(root, { x: 0, y: 0, w: 20, h: 20 }, [ + layoutNode(n1, { x: 1, y: 1, w: 18, h: 18 }, [ + layoutNode(n2, { x: 2, y: 2, w: 16, h: 16 }, [ + layoutNode(n3, { x: 3, y: 3, w: 14, h: 14 }, [ + layoutNode(n4, { x: 4, y: 4, w: 12, h: 12 }, [ + layoutNode(n5, { x: 5, y: 5, w: 10, h: 10 }, [ + layoutNode(leaf, { x: 6, y: 6, w: 3, h: 1 }), + ]), + ]), + ]), + ]), + ]), + ]); + + assert.equal(hitTestFocusable(root, tree, 6, 6), "deep-leaf"); + }); + + test("deep nesting returns null when an inner ancestor clips away the point", () => { + const leaf = buttonNode("deep-leaf"); + const n3 = containerNode([leaf]); + const n2 = containerNode([n3]); + const n1 = containerNode([n2]); + const root = containerNode([n1]); + + const tree = layoutNode(root, { x: 0, y: 0, w: 20, h: 20 }, [ + layoutNode(n1, { x: 1, y: 1, w: 18, h: 18 }, [ + layoutNode(n2, { x: 2, y: 2, w: 4, h: 4 }, [ + layoutNode(n3, { x: 10, y: 10, w: 3, h: 3 }, [ + layoutNode(leaf, { x: 10, y: 10, w: 3, h: 1 }), + ]), + ]), + ]), + ]); + + assert.equal(hitTestFocusable(root, tree, 10, 10), null); + }); + + test("later overlapping sibling wins within clipped bounds", () => { + const first = buttonNode("first"); + const second = buttonNode("second"); + const root = containerNode([first, second]); + + const tree = layoutNode(root, { x: 0, y: 0, w: 6, h: 2 }, [ + layoutNode(first, { x: 1, y: 0, w: 4, h: 1 }), + layoutNode(second, { x: 2, y: 0, w: 4, h: 1 }), + ]); + + assert.equal(hitTestFocusable(root, tree, 2, 0), "second"); + }); +}); diff --git a/packages/core/src/layout/__tests__/layout.margin.test.ts b/packages/core/src/layout/__tests__/layout.margin.test.ts new file mode 100644 index 00000000..f6ab021a --- /dev/null +++ b/packages/core/src/layout/__tests__/layout.margin.test.ts @@ -0,0 +1,237 @@ +import { assert, describe, test } from "@rezi-ui/testkit"; +import type { VNode } from "../../index.js"; +import type { LayoutTree } from "../layout.js"; +import { layout, measure } from "../layout.js"; +import type { Axis } from "../types.js"; + +function mustMeasure(node: VNode, maxW: number, maxH: number, axis: Axis = "column") { + const res = measure(node, maxW, maxH, axis); + assert.equal(res.ok, true, "measure should succeed"); + if (!res.ok) { + throw new Error("measure failed"); + } + return res.value; +} + +function mustLayout( + node: VNode, + maxW: number, + maxH: number, + axis: Axis = "column", +): LayoutTree { + const res = layout(node, 0, 0, maxW, maxH, axis); + assert.equal(res.ok, true, "layout should succeed"); + if (!res.ok) { + throw new Error("layout failed"); + } + return res.value; +} + +describe("layout margin (deterministic)", () => { + test("root box uniform margin insets by all sides", () => { + const node: VNode = { + kind: "box", + props: { border: "none", width: 4, height: 2, m: 1 }, + children: Object.freeze([]), + }; + const out = mustLayout(node, 10, 10); + assert.deepEqual(out.rect, { x: 1, y: 1, w: 4, h: 2 }); + }); + + test("root box asymmetric margin offsets x/y deterministically", () => { + const node: VNode = { + kind: "box", + props: { border: "none", width: 6, height: 3, mt: 2, mr: 1, mb: 0, ml: 3 }, + children: Object.freeze([]), + }; + const out = mustLayout(node, 20, 10); + assert.deepEqual(out.rect, { x: 3, y: 2, w: 6, h: 3 }); + }); + + test("row root margin insets stack and child coordinates", () => { + const node: VNode = { + kind: "row", + props: { m: 2 }, + children: Object.freeze([{ kind: "text", text: "A", props: {} }]), + }; + const out = mustLayout(node, 10, 10, "row"); + assert.deepEqual(out.rect, { x: 2, y: 2, w: 1, h: 1 }); + assert.deepEqual(out.children[0]?.rect, { x: 2, y: 2, w: 1, h: 1 }); + }); + + test("column root margin insets stack and child coordinates", () => { + const node: VNode = { + kind: "column", + props: { m: 1 }, + children: Object.freeze([{ kind: "text", text: "A", props: {} }]), + }; + const out = mustLayout(node, 10, 10, "column"); + assert.deepEqual(out.rect, { x: 1, y: 1, w: 1, h: 1 }); + assert.deepEqual(out.children[0]?.rect, { x: 1, y: 1, w: 1, h: 1 }); + }); + + test("negative root margins can expand rendered box from zero outer size", () => { + const node: VNode = { + kind: "box", + props: { border: "none", width: 2, height: 1, ml: -2, mr: -1, mt: -1, mb: -2 }, + children: Object.freeze([]), + }; + const size = mustMeasure(node, 4, 4); + const out = mustLayout(node, 4, 4); + assert.deepEqual(size, { w: 0, h: 0 }); + assert.deepEqual(out.rect, { x: -2, y: -1, w: 3, h: 3 }); + }); + + test("negative row child margins can expand child rect beyond parent", () => { + const row: VNode = { + kind: "row", + props: {}, + children: Object.freeze([ + { + kind: "box", + props: { border: "none", width: 1, height: 1, ml: -2, mr: -2, mt: -1, mb: -1 }, + children: Object.freeze([]), + }, + { kind: "box", props: { border: "none", width: 2, height: 1 }, children: Object.freeze([]) }, + ]), + }; + const out = mustLayout(row, 5, 2, "row"); + assert.deepEqual(out.rect, { x: 0, y: 0, w: 2, h: 1 }); + assert.deepEqual(out.children[0]?.rect, { x: -2, y: -1, w: 4, h: 2 }); + assert.deepEqual(out.children[1]?.rect, { x: 0, y: 0, w: 2, h: 1 }); + }); + + test("negative column child margins can expand child rect beyond parent", () => { + const column: VNode = { + kind: "column", + props: {}, + children: Object.freeze([ + { + kind: "box", + props: { border: "none", width: 2, height: 1, ml: -1, mr: -1, mt: -2, mb: -1 }, + children: Object.freeze([]), + }, + { kind: "box", props: { border: "none", width: 1, height: 1 }, children: Object.freeze([]) }, + ]), + }; + const out = mustLayout(column, 4, 3, "column"); + assert.deepEqual(out.rect, { x: 0, y: 0, w: 1, h: 1 }); + assert.deepEqual(out.children[0]?.rect, { x: -1, y: -2, w: 2, h: 3 }); + assert.deepEqual(out.children[1]?.rect, { x: 0, y: 0, w: 1, h: 1 }); + }); + + test("child margin and parent padding offsets are additive", () => { + const row: VNode = { + kind: "row", + props: { p: 1 }, + children: Object.freeze([ + { kind: "box", props: { border: "none", width: 2, height: 1, m: 1 }, children: Object.freeze([]) }, + ]), + }; + const out = mustLayout(row, 20, 10, "row"); + assert.deepEqual(out.rect, { x: 0, y: 0, w: 6, h: 5 }); + assert.deepEqual(out.children[0]?.rect, { x: 2, y: 2, w: 2, h: 1 }); + }); + + test("row margin and padding shrink-wrap deterministically", () => { + const row: VNode = { + kind: "row", + props: { m: 1, p: 1 }, + children: Object.freeze([{ kind: "text", text: "AA", props: {} }]), + }; + const out = mustLayout(row, 20, 10, "row"); + assert.deepEqual(out.rect, { x: 1, y: 1, w: 4, h: 3 }); + assert.deepEqual(out.children[0]?.rect, { x: 2, y: 2, w: 2, h: 1 }); + }); + + test("column margin and padding shrink-wrap deterministically", () => { + const column: VNode = { + kind: "column", + props: { m: 1, p: 1 }, + children: Object.freeze([{ kind: "text", text: "A", props: {} }]), + }; + const out = mustLayout(column, 20, 10, "column"); + assert.deepEqual(out.rect, { x: 1, y: 1, w: 3, h: 3 }); + assert.deepEqual(out.children[0]?.rect, { x: 2, y: 2, w: 1, h: 1 }); + }); + + test('spacing key resolution: m:"md" matches numeric m:2', () => { + const withKey: VNode = { + kind: "row", + props: { m: "md", p: "sm" }, + children: Object.freeze([{ kind: "text", text: "X", props: {} }]), + }; + const numeric: VNode = { + kind: "row", + props: { m: 2, p: 1 }, + children: Object.freeze([{ kind: "text", text: "X", props: {} }]), + }; + const a = mustLayout(withKey, 20, 10, "row"); + const b = mustLayout(numeric, 20, 10, "row"); + assert.deepEqual(a.rect, b.rect); + assert.deepEqual(a.children[0]?.rect, b.children[0]?.rect); + }); + + test('spacing key resolution: p:"lg" resolves to 3 cells per side', () => { + const column: VNode = { + kind: "column", + props: { p: "lg" }, + children: Object.freeze([{ kind: "text", text: "AB", props: {} }]), + }; + const out = mustLayout(column, 20, 10, "column"); + assert.deepEqual(out.rect, { x: 0, y: 0, w: 8, h: 7 }); + assert.deepEqual(out.children[0]?.rect, { x: 3, y: 3, w: 2, h: 1 }); + }); + + test('spacing key resolution: m:"xs" and m:"sm" are equivalent', () => { + const xsNode: VNode = { + kind: "box", + props: { border: "none", width: 2, height: 1, m: "xs" }, + children: Object.freeze([]), + }; + const smNode: VNode = { + kind: "box", + props: { border: "none", width: 2, height: 1, m: "sm" }, + children: Object.freeze([]), + }; + const xs = mustLayout(xsNode, 20, 10); + const sm = mustLayout(smNode, 20, 10); + assert.deepEqual(xs.rect, sm.rect); + }); + + test("per-side margins override mx/my/m in layout", () => { + const box: VNode = { + kind: "box", + props: { border: "none", width: 4, height: 2, m: 1, mx: 2, my: 3, mt: 4, mr: 5, mb: 6, ml: 7 }, + children: Object.freeze([]), + }; + const size = mustMeasure(box, 40, 40); + const out = mustLayout(box, 40, 40); + assert.deepEqual(size, { w: 16, h: 12 }); + assert.deepEqual(out.rect, { x: 7, y: 4, w: 4, h: 2 }); + }); + + test("mixed margin shorthand and side override resolve deterministically", () => { + const box: VNode = { + kind: "box", + props: { border: "none", width: 3, height: 1, m: "xl", mx: "sm", ml: 0 }, + children: Object.freeze([]), + }; + const size = mustMeasure(box, 20, 20); + const out = mustLayout(box, 20, 20); + assert.deepEqual(size, { w: 4, h: 9 }); + assert.deepEqual(out.rect, { x: 0, y: 4, w: 3, h: 1 }); + }); + + test('large spacing key margin "2xl" can clamp content area to zero', () => { + const box: VNode = { + kind: "box", + props: { border: "none", width: 5, height: 2, m: "2xl" }, + children: Object.freeze([]), + }; + const size = mustMeasure(box, 8, 6); + const out = mustLayout(box, 8, 6); + assert.deepEqual(size, { w: 8, h: 6 }); + assert.deepEqual(out.rect, { x: 6, y: 6, w: 0, h: 0 }); + }); +}); diff --git a/packages/core/src/layout/__tests__/layout.measure-cache.test.ts b/packages/core/src/layout/__tests__/layout.measure-cache.test.ts new file mode 100644 index 00000000..dc537bd0 --- /dev/null +++ b/packages/core/src/layout/__tests__/layout.measure-cache.test.ts @@ -0,0 +1,186 @@ +import { assert, describe, test } from "@rezi-ui/testkit"; +import type { VNode } from "../../index.js"; +import { layout } from "../layout.js"; + +function mustLayout( + node: VNode, + maxW: number, + maxH: number, + axis: "row" | "column" = "column", + measureCache?: WeakMap, +): void { + const result = layout(node, 0, 0, maxW, maxH, axis, measureCache); + if (!result.ok) { + assert.fail(`layout failed: ${result.fatal.code}: ${result.fatal.detail}`); + } +} + +function createCountingTextVNode( + value: string, + props: Record = {}, +): Readonly<{ + vnode: VNode; + reads: () => number; +}> { + let readCount = 0; + const node = { kind: "text", props } as { + kind: "text"; + props: Record; + text?: string; + }; + Object.defineProperty(node, "text", { + configurable: true, + enumerable: true, + get: () => { + readCount++; + return value; + }, + }); + return Object.freeze({ + vnode: node as unknown as VNode, + reads: () => readCount, + }); +} + +function rowWithChildren(children: readonly VNode[]): VNode { + return { + kind: "row", + props: {}, + children: Object.freeze([...children]), + } as unknown as VNode; +} + +describe("layout measure cache", () => { + test("shared cache hits for same vnode and constraints", () => { + const cache = new WeakMap(); + const tracked = createCountingTextVNode("cache-hit"); + + mustLayout(tracked.vnode, 40, 5, "column", cache); + const firstReads = tracked.reads(); + assert.ok(firstReads > 0); + + mustLayout(tracked.vnode, 40, 5, "column", cache); + assert.equal(tracked.reads(), firstReads); + }); + + test("different maxW causes cache miss", () => { + const cache = new WeakMap(); + const tracked = createCountingTextVNode("width-miss"); + + mustLayout(tracked.vnode, 40, 5, "column", cache); + const readsAfterFirst = tracked.reads(); + + mustLayout(tracked.vnode, 39, 5, "column", cache); + assert.ok(tracked.reads() > readsAfterFirst); + }); + + test("different maxH causes cache miss", () => { + const cache = new WeakMap(); + const tracked = createCountingTextVNode("height-miss"); + + mustLayout(tracked.vnode, 40, 5, "column", cache); + const readsAfterFirst = tracked.reads(); + + mustLayout(tracked.vnode, 40, 4, "column", cache); + assert.ok(tracked.reads() > readsAfterFirst); + }); + + test("axis change causes cache miss (row vs column)", () => { + const cache = new WeakMap(); + const tracked = createCountingTextVNode("axis-miss"); + + mustLayout(tracked.vnode, 40, 5, "column", cache); + const readsAfterFirst = tracked.reads(); + + mustLayout(tracked.vnode, 40, 5, "row", cache); + assert.ok(tracked.reads() > readsAfterFirst); + }); + + test("separate WeakMap instances do not share hits", () => { + const cacheA = new WeakMap(); + const cacheB = new WeakMap(); + const tracked = createCountingTextVNode("separate-caches"); + + mustLayout(tracked.vnode, 40, 5, "column", cacheA); + const readsAfterA = tracked.reads(); + + mustLayout(tracked.vnode, 40, 5, "column", cacheB); + assert.ok(tracked.reads() > readsAfterA); + }); + + test("shared cache still hits after using a different cache", () => { + const cacheA = new WeakMap(); + const cacheB = new WeakMap(); + const tracked = createCountingTextVNode("return-to-cache-a"); + + mustLayout(tracked.vnode, 40, 5, "column", cacheA); + const readsAfterA = tracked.reads(); + + mustLayout(tracked.vnode, 40, 5, "column", cacheB); + const readsAfterB = tracked.reads(); + assert.ok(readsAfterB > readsAfterA); + + mustLayout(tracked.vnode, 40, 5, "column", cacheA); + assert.equal(tracked.reads(), readsAfterB); + }); + + test("structurally equal but distinct vnode identity misses", () => { + const cache = new WeakMap(); + const a = createCountingTextVNode("same-shape"); + const b = createCountingTextVNode("same-shape"); + + mustLayout(a.vnode, 40, 5, "column", cache); + assert.ok(a.reads() > 0); + assert.equal(b.reads(), 0); + + mustLayout(b.vnode, 40, 5, "column", cache); + assert.ok(b.reads() > 0); + }); + + test("shared cache keeps independent entries per vnode identity", () => { + const cache = new WeakMap(); + const a = createCountingTextVNode("A"); + const b = createCountingTextVNode("B"); + + mustLayout(a.vnode, 30, 4, "column", cache); + mustLayout(b.vnode, 30, 4, "column", cache); + + assert.ok(cache.get(a.vnode) !== undefined); + assert.ok(cache.get(b.vnode) !== undefined); + }); + + test("without shared cache, repeated layout remeasures", () => { + const tracked = createCountingTextVNode("no-shared-cache"); + + mustLayout(tracked.vnode, 40, 5); + const readsAfterFirst = tracked.reads(); + + mustLayout(tracked.vnode, 40, 5); + assert.ok(tracked.reads() > readsAfterFirst); + }); + + test("nested child measurements hit cache across repeated root layouts", () => { + const cache = new WeakMap(); + const child = createCountingTextVNode("child"); + const root = rowWithChildren([child.vnode]); + + mustLayout(root, 40, 5, "column", cache); + const readsAfterFirst = child.reads(); + assert.ok(readsAfterFirst > 0); + + mustLayout(root, 40, 5, "column", cache); + assert.equal(child.reads(), readsAfterFirst); + }); + + test("nested child remeasures when parent constraints change", () => { + const cache = new WeakMap(); + const child = createCountingTextVNode("child"); + const root = rowWithChildren([child.vnode]); + + mustLayout(root, 40, 5, "column", cache); + const readsAfterFirst = child.reads(); + + mustLayout(root, 39, 5, "column", cache); + assert.ok(child.reads() > readsAfterFirst); + }); +}); diff --git a/packages/core/src/layout/__tests__/layout.percentage.test.ts b/packages/core/src/layout/__tests__/layout.percentage.test.ts new file mode 100644 index 00000000..ed352723 --- /dev/null +++ b/packages/core/src/layout/__tests__/layout.percentage.test.ts @@ -0,0 +1,342 @@ +import { assert, describe, test } from "@rezi-ui/testkit"; +import { type VNode, ui } from "../../index.js"; +import { type LayoutTree, layout } from "../layout.js"; + +type Axis = "row" | "column"; +type Rect = Readonly<{ x: number; y: number; w: number; h: number }>; + +type PercentageCase = Readonly<{ + name: string; + vnode: VNode; + maxW: number; + maxH: number; + axis: Axis; + expectedRoot: Rect; + expectedChildren: readonly Rect[]; + expectedChild0Children?: readonly Rect[]; +}>; + +function mustLayout(node: VNode, maxW: number, maxH: number, axis: Axis): LayoutTree { + const res = layout(node, 0, 0, maxW, maxH, axis); + if (!res.ok) { + assert.fail(`layout failed: ${res.fatal.code}: ${res.fatal.detail}`); + } + return res.value; +} + +const ROW_CASES: readonly PercentageCase[] = [ + { + name: 'row main-axis "100%" consumes full width', + vnode: ui.row({ width: 20, height: 6 }, [ui.box({ border: "none", width: "100%" }, [])]), + maxW: 20, + maxH: 6, + axis: "row", + expectedRoot: { x: 0, y: 0, w: 20, h: 6 }, + expectedChildren: [{ x: 0, y: 0, w: 20, h: 0 }], + }, + { + name: 'row main-axis "0%" yields zero-width sibling', + vnode: ui.row( + { width: 20, height: 6 }, + [ui.box({ border: "none", width: "0%" }, []), ui.box({ border: "none", width: 4 }, [])], + ), + maxW: 20, + maxH: 6, + axis: "row", + expectedRoot: { x: 0, y: 0, w: 20, h: 6 }, + expectedChildren: [ + { x: 0, y: 0, w: 0, h: 0 }, + { x: 0, y: 0, w: 4, h: 0 }, + ], + }, + { + name: 'row main-axis "150%" clamps to available width', + vnode: ui.row({ width: 20, height: 6 }, [ui.box({ border: "none", width: "150%" }, [])]), + maxW: 20, + maxH: 6, + axis: "row", + expectedRoot: { x: 0, y: 0, w: 20, h: 6 }, + expectedChildren: [{ x: 0, y: 0, w: 20, h: 0 }], + }, + { + name: 'row "150%" first child can starve later fixed sibling', + vnode: ui.row( + { width: 20, height: 6 }, + [ui.box({ border: "none", width: "150%" }, []), ui.box({ border: "none", width: 3 }, [])], + ), + maxW: 20, + maxH: 6, + axis: "row", + expectedRoot: { x: 0, y: 0, w: 20, h: 6 }, + expectedChildren: [ + { x: 0, y: 0, w: 20, h: 0 }, + { x: 20, y: 0, w: 0, h: 0 }, + ], + }, + { + name: "row nested percentages 50% -> 50% resolve recursively", + vnode: ui.row({ width: 40, height: 8 }, [ + ui.box({ border: "none", width: "50%" }, [ui.box({ border: "none", width: "50%" }, [ui.text("x")])]), + ]), + maxW: 40, + maxH: 8, + axis: "row", + expectedRoot: { x: 0, y: 0, w: 40, h: 8 }, + expectedChildren: [{ x: 0, y: 0, w: 20, h: 1 }], + expectedChild0Children: [{ x: 0, y: 0, w: 10, h: 1 }], + }, + { + name: "row nested 100% parent + 150% child clamps at parent content", + vnode: ui.row({ width: 20, height: 8 }, [ + ui.box( + { border: "none", width: "100%" }, + [ui.box({ border: "none", width: "150%" }, [ui.text("x")])], + ), + ]), + maxW: 20, + maxH: 8, + axis: "row", + expectedRoot: { x: 0, y: 0, w: 20, h: 8 }, + expectedChildren: [{ x: 0, y: 0, w: 20, h: 1 }], + expectedChild0Children: [{ x: 0, y: 0, w: 20, h: 1 }], + }, + { + name: "row cross-axis 50% height triggers parent fill-cross behavior", + vnode: ui.row({ width: 20 }, [ui.box({ border: "none", width: 4, height: "50%" }, [])]), + maxW: 20, + maxH: 10, + axis: "row", + expectedRoot: { x: 0, y: 0, w: 20, h: 10 }, + expectedChildren: [{ x: 0, y: 0, w: 4, h: 5 }], + }, + { + name: 'row cross-axis "0%" height resolves to zero', + vnode: ui.row({ width: 20, height: 10 }, [ui.box({ border: "none", width: 4, height: "0%" }, [])]), + maxW: 20, + maxH: 10, + axis: "row", + expectedRoot: { x: 0, y: 0, w: 20, h: 10 }, + expectedChildren: [{ x: 0, y: 0, w: 4, h: 0 }], + }, + { + name: 'row cross-axis "150%" height clamps to container height', + vnode: ui.row({ width: 20, height: 10 }, [ui.box({ border: "none", width: 4, height: "150%" }, [])]), + maxW: 20, + maxH: 10, + axis: "row", + expectedRoot: { x: 0, y: 0, w: 20, h: 10 }, + expectedChildren: [{ x: 0, y: 0, w: 4, h: 10 }], + }, + { + name: "row percentage sizing subtracts total gap before allocation", + vnode: ui.row( + { width: 21, height: 5, gap: 2 }, + [ui.box({ border: "none", width: "50%" }, []), ui.box({ border: "none", width: "50%" }, [])], + ), + maxW: 21, + maxH: 5, + axis: "row", + expectedRoot: { x: 0, y: 0, w: 21, h: 5 }, + expectedChildren: [ + { x: 0, y: 0, w: 10, h: 0 }, + { x: 12, y: 0, w: 9, h: 0 }, + ], + }, + { + name: "row percent + flex ordering is stable", + vnode: ui.row({ width: 20, height: 5 }, [ + ui.box({ border: "none", width: "25%" }, []), + ui.box({ border: "none", flex: 1 }, []), + ui.box({ border: "none", width: "25%" }, []), + ui.box({ border: "none", flex: 3 }, []), + ]), + maxW: 20, + maxH: 5, + axis: "row", + expectedRoot: { x: 0, y: 0, w: 20, h: 5 }, + expectedChildren: [ + { x: 0, y: 0, w: 5, h: 0 }, + { x: 5, y: 0, w: 3, h: 0 }, + { x: 8, y: 0, w: 5, h: 0 }, + { x: 13, y: 0, w: 7, h: 0 }, + ], + }, +] as const; + +const COLUMN_CASES: readonly PercentageCase[] = [ + { + name: 'column main-axis "100%" consumes full height', + vnode: ui.column({ height: 20, width: 6 }, [ui.box({ border: "none", height: "100%" }, [])]), + maxW: 6, + maxH: 20, + axis: "column", + expectedRoot: { x: 0, y: 0, w: 6, h: 20 }, + expectedChildren: [{ x: 0, y: 0, w: 0, h: 20 }], + }, + { + name: 'column main-axis "0%" yields zero-height sibling', + vnode: ui.column( + { height: 20, width: 6 }, + [ui.box({ border: "none", height: "0%" }, []), ui.box({ border: "none", height: 4 }, [])], + ), + maxW: 6, + maxH: 20, + axis: "column", + expectedRoot: { x: 0, y: 0, w: 6, h: 20 }, + expectedChildren: [ + { x: 0, y: 0, w: 0, h: 0 }, + { x: 0, y: 0, w: 0, h: 4 }, + ], + }, + { + name: 'column main-axis "150%" clamps to available height', + vnode: ui.column({ height: 20, width: 6 }, [ui.box({ border: "none", height: "150%" }, [])]), + maxW: 6, + maxH: 20, + axis: "column", + expectedRoot: { x: 0, y: 0, w: 6, h: 20 }, + expectedChildren: [{ x: 0, y: 0, w: 0, h: 20 }], + }, + { + name: 'column "150%" first child can starve later fixed sibling', + vnode: ui.column( + { height: 20, width: 6 }, + [ui.box({ border: "none", height: "150%" }, []), ui.box({ border: "none", height: 3 }, [])], + ), + maxW: 6, + maxH: 20, + axis: "column", + expectedRoot: { x: 0, y: 0, w: 6, h: 20 }, + expectedChildren: [ + { x: 0, y: 0, w: 0, h: 20 }, + { x: 0, y: 20, w: 0, h: 0 }, + ], + }, + { + name: "column nested percentages 50% -> 50% resolve recursively", + vnode: ui.column({ height: 40, width: 8 }, [ + ui.box( + { border: "none", height: "50%" }, + [ui.box({ border: "none", height: "50%" }, [ui.text("x")])], + ), + ]), + maxW: 8, + maxH: 40, + axis: "column", + expectedRoot: { x: 0, y: 0, w: 8, h: 40 }, + expectedChildren: [{ x: 0, y: 0, w: 1, h: 20 }], + expectedChild0Children: [{ x: 0, y: 0, w: 1, h: 10 }], + }, + { + name: "column nested 100% parent + 150% child clamps at parent content", + vnode: ui.column({ height: 20, width: 8 }, [ + ui.box( + { border: "none", height: "100%" }, + [ui.box({ border: "none", height: "150%" }, [ui.text("x")])], + ), + ]), + maxW: 8, + maxH: 20, + axis: "column", + expectedRoot: { x: 0, y: 0, w: 8, h: 20 }, + expectedChildren: [{ x: 0, y: 0, w: 1, h: 20 }], + expectedChild0Children: [{ x: 0, y: 0, w: 1, h: 20 }], + }, + { + name: "column cross-axis 50% width triggers parent fill-cross behavior", + vnode: ui.column({ height: 20 }, [ui.box({ border: "none", height: 4, width: "50%" }, [])]), + maxW: 10, + maxH: 20, + axis: "column", + expectedRoot: { x: 0, y: 0, w: 10, h: 20 }, + expectedChildren: [{ x: 0, y: 0, w: 5, h: 4 }], + }, + { + name: 'column cross-axis "0%" width resolves to zero', + vnode: ui.column({ height: 20, width: 10 }, [ui.box({ border: "none", height: 4, width: "0%" }, [])]), + maxW: 10, + maxH: 20, + axis: "column", + expectedRoot: { x: 0, y: 0, w: 10, h: 20 }, + expectedChildren: [{ x: 0, y: 0, w: 0, h: 4 }], + }, + { + name: 'column cross-axis "150%" width clamps to container width', + vnode: ui.column({ height: 20, width: 10 }, [ui.box({ border: "none", height: 4, width: "150%" }, [])]), + maxW: 10, + maxH: 20, + axis: "column", + expectedRoot: { x: 0, y: 0, w: 10, h: 20 }, + expectedChildren: [{ x: 0, y: 0, w: 10, h: 4 }], + }, + { + name: "column percentage sizing subtracts total gap before allocation", + vnode: ui.column( + { height: 21, width: 5, gap: 2 }, + [ui.box({ border: "none", height: "50%" }, []), ui.box({ border: "none", height: "50%" }, [])], + ), + maxW: 5, + maxH: 21, + axis: "column", + expectedRoot: { x: 0, y: 0, w: 5, h: 21 }, + expectedChildren: [ + { x: 0, y: 0, w: 0, h: 10 }, + { x: 0, y: 12, w: 0, h: 9 }, + ], + }, + { + name: "column percent + flex ordering is stable", + vnode: ui.column({ height: 20, width: 5 }, [ + ui.box({ border: "none", height: "25%" }, []), + ui.box({ border: "none", flex: 1 }, []), + ui.box({ border: "none", height: "25%" }, []), + ui.box({ border: "none", flex: 3 }, []), + ]), + maxW: 5, + maxH: 20, + axis: "column", + expectedRoot: { x: 0, y: 0, w: 5, h: 20 }, + expectedChildren: [ + { x: 0, y: 0, w: 0, h: 5 }, + { x: 0, y: 5, w: 0, h: 3 }, + { x: 0, y: 8, w: 0, h: 5 }, + { x: 0, y: 13, w: 0, h: 7 }, + ], + }, +] as const; + +describe("layout percentages (deterministic)", () => { + for (const c of ROW_CASES) { + test(c.name, () => { + const tree = mustLayout(c.vnode, c.maxW, c.maxH, c.axis); + assert.deepEqual(tree.rect, c.expectedRoot); + assert.deepEqual( + tree.children.map((child) => child.rect), + c.expectedChildren, + ); + if (c.expectedChild0Children !== undefined) { + assert.deepEqual( + tree.children[0]?.children.map((child) => child.rect), + c.expectedChild0Children, + ); + } + }); + } + + for (const c of COLUMN_CASES) { + test(c.name, () => { + const tree = mustLayout(c.vnode, c.maxW, c.maxH, c.axis); + assert.deepEqual(tree.rect, c.expectedRoot); + assert.deepEqual( + tree.children.map((child) => child.rect), + c.expectedChildren, + ); + if (c.expectedChild0Children !== undefined) { + assert.deepEqual( + tree.children[0]?.children.map((child) => child.rect), + c.expectedChild0Children, + ); + } + }); + } +}); diff --git a/packages/core/src/layout/__tests__/layout.stability-signature.test.ts b/packages/core/src/layout/__tests__/layout.stability-signature.test.ts new file mode 100644 index 00000000..46d6087b --- /dev/null +++ b/packages/core/src/layout/__tests__/layout.stability-signature.test.ts @@ -0,0 +1,213 @@ +import { assert, describe, test } from "@rezi-ui/testkit"; +import type { VNode } from "../../index.js"; +import { updateLayoutStabilitySignatures } from "../../app/widgetRenderer/submitFramePipeline.js"; +import type { RuntimeInstance } from "../../runtime/commit.js"; +import type { InstanceId } from "../../runtime/instance.js"; + +function textNode(text: string, props: Record = {}): VNode { + return { kind: "text", text, props } as unknown as VNode; +} + +function buttonNode(label: string, props: Record = {}): VNode { + return { kind: "button", props: { label, ...props } } as unknown as VNode; +} + +function rowNode( + children: readonly VNode[], + props: Record = {}, +): VNode { + return { + kind: "row", + props, + children: Object.freeze([...children]), + } as unknown as VNode; +} + +function boxNode( + children: readonly VNode[], + props: Record = {}, +): VNode { + return { + kind: "box", + props, + children: Object.freeze([...children]), + } as unknown as VNode; +} + +function runtimeNode( + instanceId: InstanceId, + vnode: VNode, + children: readonly RuntimeInstance[] = [], +): RuntimeInstance { + return { + instanceId, + vnode, + children: Object.freeze([...children]), + }; +} + +function runSignatures(root: RuntimeInstance, prev: Map): boolean { + const next = new Map(); + const stack: RuntimeInstance[] = []; + return updateLayoutStabilitySignatures(root, prev, next, stack); +} + +describe("layout stability signatures", () => { + test("second pass on unchanged tree reports no change", () => { + const prev = new Map(); + const root = runtimeNode(1, textNode("stable")); + + assert.equal(runSignatures(root, prev), true); + assert.equal(runSignatures(root, prev), false); + }); + + test("row layout-relevant prop change is included", () => { + const prev = new Map(); + const child = runtimeNode(2, textNode("child")); + const base = runtimeNode(1, rowNode([child.vnode], { gap: 1 }), [child]); + const changed = runtimeNode(1, rowNode([child.vnode], { gap: 2 }), [child]); + + assert.equal(runSignatures(base, prev), true); + assert.equal(runSignatures(changed, prev), true); + }); + + test("row style-only prop change is excluded", () => { + const prev = new Map(); + const child = runtimeNode(2, textNode("child")); + const base = runtimeNode( + 1, + rowNode([child.vnode], { gap: 1, style: { fg: "red" } }), + [child], + ); + const styleOnly = runtimeNode( + 1, + rowNode([child.vnode], { gap: 1, style: { fg: "blue" } }), + [child], + ); + + assert.equal(runSignatures(base, prev), true); + assert.equal(runSignatures(styleOnly, prev), false); + }); + + test("box layout-relevant prop change is included", () => { + const prev = new Map(); + const child = runtimeNode(2, textNode("child")); + const base = runtimeNode(1, boxNode([child.vnode], { border: "single", pad: 1 }), [child]); + const changed = runtimeNode(1, boxNode([child.vnode], { border: "single", pad: 2 }), [child]); + + assert.equal(runSignatures(base, prev), true); + assert.equal(runSignatures(changed, prev), true); + }); + + test("box style-only prop change is excluded", () => { + const prev = new Map(); + const child = runtimeNode(2, textNode("child")); + const base = runtimeNode( + 1, + boxNode([child.vnode], { border: "single", pad: 1, style: { fg: "red" } }), + [child], + ); + const styleOnly = runtimeNode( + 1, + boxNode([child.vnode], { border: "single", pad: 1, style: { fg: "green" } }), + [child], + ); + + assert.equal(runSignatures(base, prev), true); + assert.equal(runSignatures(styleOnly, prev), false); + }); + + test("text content change is included", () => { + const prev = new Map(); + const base = runtimeNode(1, textNode("a")); + const changed = runtimeNode(1, textNode("aaaa")); + + assert.equal(runSignatures(base, prev), true); + assert.equal(runSignatures(changed, prev), true); + }); + + test("text style-only change is excluded", () => { + const prev = new Map(); + const base = runtimeNode(1, textNode("abc", { style: { fg: "red" } })); + const styleOnly = runtimeNode(1, textNode("abc", { style: { fg: "blue" } })); + + assert.equal(runSignatures(base, prev), true); + assert.equal(runSignatures(styleOnly, prev), false); + }); + + test("button label change is included", () => { + const prev = new Map(); + const base = runtimeNode(1, buttonNode("Go", { id: "btn-a", px: 1 })); + const changed = runtimeNode(1, buttonNode("Launch", { id: "btn-a", px: 1 })); + + assert.equal(runSignatures(base, prev), true); + assert.equal(runSignatures(changed, prev), true); + }); + + test("button id-only change is excluded", () => { + const prev = new Map(); + const base = runtimeNode(1, buttonNode("Go", { id: "btn-a", px: 2 })); + const idOnly = runtimeNode(1, buttonNode("Go", { id: "btn-b", px: 2 })); + + assert.equal(runSignatures(base, prev), true); + assert.equal(runSignatures(idOnly, prev), false); + }); + + test("adding a child is included", () => { + const prev = new Map(); + const childA = runtimeNode(2, textNode("A")); + const childB = runtimeNode(3, textNode("B")); + + const base = runtimeNode(1, rowNode([childA.vnode], { gap: 0 }), [childA]); + const withAddedChild = runtimeNode( + 1, + rowNode([childA.vnode, childB.vnode], { gap: 0 }), + [childA, childB], + ); + + assert.equal(runSignatures(base, prev), true); + assert.equal(runSignatures(withAddedChild, prev), true); + }); + + test("removing a child is included", () => { + const prev = new Map(); + const childA = runtimeNode(2, textNode("A")); + const childB = runtimeNode(3, textNode("B")); + + const base = runtimeNode(1, rowNode([childA.vnode, childB.vnode], { gap: 0 }), [childA, childB]); + const withRemovedChild = runtimeNode(1, rowNode([childA.vnode], { gap: 0 }), [childA]); + + assert.equal(runSignatures(base, prev), true); + assert.equal(runSignatures(withRemovedChild, prev), true); + }); + + test("reordering children is included", () => { + const prev = new Map(); + const childA = runtimeNode(2, textNode("A")); + const childB = runtimeNode(3, textNode("B")); + + const base = runtimeNode(1, rowNode([childA.vnode, childB.vnode], { gap: 0 }), [childA, childB]); + const reordered = runtimeNode(1, rowNode([childB.vnode, childA.vnode], { gap: 0 }), [childB, childA]); + + assert.equal(runSignatures(base, prev), true); + assert.equal(runSignatures(reordered, prev), true); + }); + + test("unsupported kinds conservatively force relayout and clear maps", () => { + const prev = new Map(); + const supported = runtimeNode(1, textNode("ok")); + assert.equal(runSignatures(supported, prev), true); + assert.ok(prev.size > 0); + + const unsupported = runtimeNode( + 1, + { + kind: "select", + props: { id: "s", value: "", options: Object.freeze([]) }, + } as unknown as VNode, + ); + + assert.equal(runSignatures(unsupported, prev), true); + assert.equal(prev.size, 0); + }); +}); diff --git a/packages/core/src/layout/__tests__/layout.zero-edge.test.ts b/packages/core/src/layout/__tests__/layout.zero-edge.test.ts new file mode 100644 index 00000000..4254efa2 --- /dev/null +++ b/packages/core/src/layout/__tests__/layout.zero-edge.test.ts @@ -0,0 +1,249 @@ +import { assert, describe, test } from "@rezi-ui/testkit"; +import type { VNode } from "../../index.js"; +import type { LayoutTree } from "../layout.js"; +import { layout, measure } from "../layout.js"; +import type { Axis } from "../types.js"; + +function mustMeasure(node: VNode, maxW: number, maxH: number, axis: Axis = "column") { + const res = measure(node, maxW, maxH, axis); + assert.equal(res.ok, true, "measure should succeed"); + if (!res.ok) { + throw new Error("measure failed"); + } + return res.value; +} + +function mustLayout( + node: VNode, + maxW: number, + maxH: number, + axis: Axis = "column", +): LayoutTree { + const res = layout(node, 0, 0, maxW, maxH, axis); + assert.equal(res.ok, true, "layout should succeed"); + if (!res.ok) { + throw new Error("layout failed"); + } + return res.value; +} + +function deepest(node: LayoutTree): LayoutTree { + let cur = node; + while (cur.children.length === 1) { + const next = cur.children[0]; + if (!next) break; + cur = next; + } + return cur; +} + +function chainDepth(node: LayoutTree): number { + let depth = 1; + let cur = node; + while (cur.children.length === 1) { + const next = cur.children[0]; + if (!next) break; + cur = next; + depth++; + } + return depth; +} + +function assertAllZeroRect(node: LayoutTree): void { + assert.equal(node.rect.w, 0, "expected zero width"); + assert.equal(node.rect.h, 0, "expected zero height"); + for (const child of node.children) { + assertAllZeroRect(child); + } +} + +describe("layout zero/empty edges (deterministic)", () => { + test("row with zero children measures as zero", () => { + const row: VNode = { kind: "row", props: {}, children: Object.freeze([]) }; + assert.deepEqual(mustMeasure(row, 80, 24, "row"), { w: 0, h: 0 }); + }); + + test("column with zero children measures as zero", () => { + const column: VNode = { kind: "column", props: {}, children: Object.freeze([]) }; + assert.deepEqual(mustMeasure(column, 80, 24, "column"), { w: 0, h: 0 }); + }); + + test("row with zero children but padding retains footprint", () => { + const row: VNode = { kind: "row", props: { p: 1 }, children: Object.freeze([]) }; + assert.deepEqual(mustMeasure(row, 10, 6, "row"), { w: 2, h: 2 }); + }); + + test("column with zero children but padding retains footprint", () => { + const column: VNode = { kind: "column", props: { p: 1 }, children: Object.freeze([]) }; + assert.deepEqual(mustMeasure(column, 10, 6, "column"), { w: 2, h: 2 }); + }); + + test("row with zero children layouts to empty tree", () => { + const row: VNode = { kind: "row", props: {}, children: Object.freeze([]) }; + const out = mustLayout(row, 10, 6, "row"); + assert.deepEqual(out.rect, { x: 0, y: 0, w: 0, h: 0 }); + assert.equal(out.children.length, 0); + }); + + test("column with zero children layouts to empty tree", () => { + const column: VNode = { kind: "column", props: {}, children: Object.freeze([]) }; + const out = mustLayout(column, 10, 6, "column"); + assert.deepEqual(out.rect, { x: 0, y: 0, w: 0, h: 0 }); + assert.equal(out.children.length, 0); + }); + + test("row in zero-width viewport produces zero-size children", () => { + const row: VNode = { + kind: "row", + props: {}, + children: Object.freeze([ + { kind: "text", text: "abc", props: {} } as VNode, + { + kind: "box", + props: { border: "none", width: 2, height: 1 }, + children: Object.freeze([]), + } as VNode, + ]), + }; + const out = mustLayout(row, 0, 4, "row"); + assert.deepEqual(out.rect, { x: 0, y: 0, w: 0, h: 0 }); + assert.deepEqual(out.children[0]?.rect, { x: 0, y: 0, w: 0, h: 0 }); + assert.deepEqual(out.children[1]?.rect, { x: 0, y: 0, w: 0, h: 0 }); + }); + + test("column in zero-height viewport produces zero-size children", () => { + const column: VNode = { + kind: "column", + props: {}, + children: Object.freeze([ + { kind: "text", text: "abc", props: {} } as VNode, + { + kind: "box", + props: { border: "none", width: 1, height: 2 }, + children: Object.freeze([]), + } as VNode, + ]), + }; + const out = mustLayout(column, 4, 0, "column"); + assert.deepEqual(out.rect, { x: 0, y: 0, w: 0, h: 0 }); + assert.deepEqual(out.children[0]?.rect, { x: 0, y: 0, w: 0, h: 0 }); + assert.deepEqual(out.children[1]?.rect, { x: 0, y: 0, w: 0, h: 0 }); + }); + + test("explicit zero-size child survives in row tree", () => { + const row: VNode = { + kind: "row", + props: {}, + children: Object.freeze([ + { + kind: "box", + props: { border: "none", width: 0, height: 0 }, + children: Object.freeze([]), + } as VNode, + { kind: "text", text: "A", props: {} } as VNode, + ]), + }; + const out = mustLayout(row, 5, 2, "row"); + assert.deepEqual(out.rect, { x: 0, y: 0, w: 1, h: 1 }); + assert.deepEqual(out.children[0]?.rect, { x: 0, y: 0, w: 0, h: 0 }); + assert.deepEqual(out.children[1]?.rect, { x: 0, y: 0, w: 1, h: 1 }); + }); + + test("explicit zero-size child survives in column tree", () => { + const column: VNode = { + kind: "column", + props: {}, + children: Object.freeze([ + { + kind: "box", + props: { border: "none", width: 0, height: 0 }, + children: Object.freeze([]), + } as VNode, + { kind: "text", text: "B", props: {} } as VNode, + ]), + }; + const out = mustLayout(column, 4, 3, "column"); + assert.deepEqual(out.rect, { x: 0, y: 0, w: 1, h: 1 }); + assert.deepEqual(out.children[0]?.rect, { x: 0, y: 0, w: 0, h: 0 }); + assert.deepEqual(out.children[1]?.rect, { x: 0, y: 0, w: 1, h: 1 }); + }); + + test("empty text measures as width 0 and height 1", () => { + const text: VNode = { kind: "text", text: "", props: {} }; + assert.deepEqual(mustMeasure(text, 10, 3), { w: 0, h: 1 }); + }); + + test("empty text layout with maxW=0 keeps deterministic height", () => { + const text: VNode = { kind: "text", text: "", props: {} }; + const out = mustLayout(text, 0, 3); + assert.deepEqual(out.rect, { x: 0, y: 0, w: 0, h: 1 }); + }); + + test("row containing only empty text has zero width but non-zero row height", () => { + const row: VNode = { + kind: "row", + props: {}, + children: Object.freeze([{ kind: "text", text: "", props: {} }]), + }; + const out = mustLayout(row, 5, 3, "row"); + assert.deepEqual(out.rect, { x: 0, y: 0, w: 0, h: 1 }); + assert.deepEqual(out.children[0]?.rect, { x: 0, y: 0, w: 0, h: 0 }); + }); + + test("deep nesting 24 columns with empty text remains stable", () => { + let node: VNode = { kind: "text", text: "", props: {} }; + for (let i = 0; i < 24; i++) { + node = { kind: "column", props: {}, children: Object.freeze([node]) }; + } + const out = mustLayout(node, 5, 5, "column"); + assert.deepEqual(out.rect, { x: 0, y: 0, w: 0, h: 1 }); + assert.equal(chainDepth(out), 25); + const leaf = deepest(out); + assert.equal(leaf.vnode.kind, "text"); + assert.deepEqual(leaf.rect, { x: 0, y: 0, w: 0, h: 1 }); + }); + + test("deep alternating nesting 25+ levels in zero viewport remains all-zero", () => { + let node: VNode = { kind: "text", text: "Z", props: {} }; + for (let i = 0; i < 25; i++) { + node = + i % 2 === 0 + ? { kind: "row", props: {}, children: Object.freeze([node]) } + : { kind: "column", props: {}, children: Object.freeze([node]) }; + } + const rootAxis: Axis = node.kind === "row" ? "row" : "column"; + const out = mustLayout(node, 0, 0, rootAxis); + assert.equal(chainDepth(out), 26); + assertAllZeroRect(out); + }); + + test("single-child row is equivalent to direct child layout", () => { + const child: VNode = { + kind: "box", + props: { border: "none", width: 3, height: 2 }, + children: Object.freeze([]), + }; + const direct = mustLayout(child, 10, 10, "column"); + const rowWrapped: VNode = { kind: "row", props: {}, children: Object.freeze([child]) }; + const wrapped = mustLayout(rowWrapped, 10, 10, "row"); + assert.deepEqual(wrapped.rect, direct.rect); + assert.deepEqual(wrapped.children[0]?.rect, direct.rect); + }); + + test("single-child column is equivalent to direct child layout", () => { + const child: VNode = { + kind: "box", + props: { border: "none", width: 2, height: 3 }, + children: Object.freeze([]), + }; + const direct = mustLayout(child, 10, 10, "column"); + const wrappedColumn: VNode = { + kind: "column", + props: {}, + children: Object.freeze([child]), + }; + const wrapped = mustLayout(wrappedColumn, 10, 10, "column"); + assert.deepEqual(wrapped.rect, direct.rect); + assert.deepEqual(wrapped.children[0]?.rect, direct.rect); + }); +}); From dcc4f6927193e78653f46890332c603da9629bc7 Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Tue, 17 Feb 2026 18:54:55 +0400 Subject: [PATCH 3/7] docs(layout): codify layout invariants and resolution order Document int32/clamping rules, two-phase measure/layout flow, flex allocation invariants, percentage resolution order, margin semantics, and aspect-ratio precedence to match current engine behavior. --- docs/guide/layout.md | 58 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/docs/guide/layout.md b/docs/guide/layout.md index 82198e4f..f1a483ec 100644 --- a/docs/guide/layout.md +++ b/docs/guide/layout.md @@ -156,6 +156,64 @@ ui.row({ gap: 1 }, [ ]); ``` +## Layout Invariants + +These behaviors are guaranteed by the current layout engine and validation pipeline. + +### Coordinate system and int32 bounds + +- Rects are in terminal cell units: `x`, `y`, `w`, `h` with origin at top-left. +- `layout(node, x, y, maxW, maxH, axis)` requires `x/y` to be int32 and `maxW/maxH` to be int32 `>= 0`. +- `measure(node, maxW, maxH, axis)` requires `maxW/maxH` to be int32 `>= 0`. +- Integer-valued size/spacing inputs are int32-bounded (signed for margins, non-negative where required). Out-of-range values fail with deterministic `ZRUI_INVALID_PROPS` rather than being wrapped. +- Computed leaf rects are validated as int32 cells. + +### Non-negative dimension clamping + +- Width/height never go negative; dimension math uses non-negative clamps after subtraction steps (margin, border, padding, remaining space). +- Final node sizes are bounded by available `maxW/maxH` and clamped to `>= 0`. + +### Two-phase measure -> layout behavior + +- `measure(...)` computes size only; it does not assign positions. +- `layout(...)` measures first, then places nodes using the measured/forced size. +- In `row`/`column`, if any child has main-axis `%` sizing or `flex > 0`, layout runs a constraint pass first (resolve main sizes, then place children with resolved sizes). +- If that trigger is absent, stacks use the greedy path (measure in child order and place directly). +- Even when remaining space reaches zero, the subtree is still measured with zero constraints for deterministic validation. + +### Flex distribution rules + +- Only children with `flex > 0` participate in flex allocation. +- Fixed-size and non-flex children consume space before flex allocation. +- Remaining space is distributed proportionally to flex weights, using integer cells: floor base shares first, then remainder cells by largest fractional share (ties by lower child index). +- Per-child max constraints are enforced during distribution; leftover space is redistributed iteratively to still-active flex items. +- No flex distribution occurs when remaining space is `0` or when effective total flex is `<= 0`. +- Min constraints are a best-effort top-up after proportional allocation, only while space remains. + +### Percentage resolution, flooring, and clamping + +- Percentage constraints resolve with flooring: `floor(parentSize * percent / 100)`. +- Percentages resolve against the parent size provided to constraint resolution for that axis (stack content bounds in stacks; box content bounds for boxed children). +- Resolved percent values are then clamped by min/max constraints and by the currently available space. +- Main-axis percentages in stacks trigger the constraint-pass path before final placement. + +### Margin behavior and interactions + +- Margin precedence is side -> axis -> all: `ml/mr/mt/mb` overrides `mx/my`, which overrides `m`. +- Margins are outside the widget rect and affect both measured outer size and positioned offset. +- Positive margins reserve outer space. +- Negative margins are allowed (signed int32): they can move `x/y` negative and can expand computed rect size after subtraction. +- Padding and borders are applied inside the margin-adjusted rect. + +### Aspect ratio resolution order + +- `width`/`height` are resolved first (number or percent; `"auto"` behaves as unspecified here). +- If `aspectRatio > 0` and exactly one axis is resolved, the other is derived with flooring: + - `height = floor(width / aspectRatio)` + - `width = floor(height * aspectRatio)` +- If both `width` and `height` are already resolved, `aspectRatio` does not override them. +- After derivation, min/max constraints clamp the chosen size, then final size is capped by available bounds and non-negative clamping. + ## Borders `ui.box` can draw a border around its content: From 81eafb0f14298d3cf92bb902f6decf43fd607924 Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Tue, 17 Feb 2026 18:59:09 +0400 Subject: [PATCH 4/7] test(layout): extend flex-distribution edge matrix Add deterministic cases for single-flex remainder capture, zero-total-flex over-subscription clamps, fractional 0.5/1.5 allocation, and zero-space-after-gap collapse behavior. --- .../layout.flex-distribution.test.ts | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/packages/core/src/layout/__tests__/layout.flex-distribution.test.ts b/packages/core/src/layout/__tests__/layout.flex-distribution.test.ts index f21ca56e..6a09f93b 100644 --- a/packages/core/src/layout/__tests__/layout.flex-distribution.test.ts +++ b/packages/core/src/layout/__tests__/layout.flex-distribution.test.ts @@ -267,6 +267,66 @@ const CASES: readonly FlexCase[] = [ { x: 0, y: 7, w: 0, h: 1 }, ], }, + { + name: "single flex child receives all remaining space", + main: 20, + cross: 5, + gap: 2, + children: [{ main: 3 }, { flex: 1 }, { main: 5 }], + expectedRowChildren: [ + { x: 0, y: 0, w: 3, h: 0 }, + { x: 5, y: 0, w: 8, h: 0 }, + { x: 15, y: 0, w: 5, h: 0 }, + ], + expectedColumnChildren: [ + { x: 0, y: 0, w: 0, h: 3 }, + { x: 0, y: 5, w: 0, h: 8 }, + { x: 0, y: 15, w: 0, h: 5 }, + ], + }, + { + name: "total flex is zero and oversized fixed children clamp without negatives", + main: 5, + cross: 5, + children: [{ main: 6, flex: 0 }, { main: 6, flex: 0 }], + expectedRowChildren: [ + { x: 0, y: 0, w: 5, h: 0 }, + { x: 5, y: 0, w: 0, h: 0 }, + ], + expectedColumnChildren: [ + { x: 0, y: 0, w: 0, h: 5 }, + { x: 0, y: 5, w: 0, h: 0 }, + ], + }, + { + name: "fractional flex 0.5 vs 1.5 rounds to deterministic 2/6 split", + main: 8, + cross: 5, + children: [{ flex: 0.5 }, { flex: 1.5 }], + expectedRowChildren: [ + { x: 0, y: 0, w: 2, h: 0 }, + { x: 2, y: 0, w: 6, h: 0 }, + ], + expectedColumnChildren: [ + { x: 0, y: 0, w: 0, h: 2 }, + { x: 0, y: 2, w: 0, h: 6 }, + ], + }, + { + name: "flex children collapse to zero when no main-axis space remains after gap", + main: 1, + cross: 5, + gap: 1, + children: [{ flex: 1 }, { flex: 1 }], + expectedRowChildren: [ + { x: 0, y: 0, w: 0, h: 0 }, + { x: 1, y: 0, w: 0, h: 0 }, + ], + expectedColumnChildren: [ + { x: 0, y: 0, w: 0, h: 0 }, + { x: 0, y: 1, w: 0, h: 0 }, + ], + }, { name: "all flex:0 children remain zero-size and only advance by gap", main: 10, From 7a7afe592525f5b40c3366ce86bd49edfacccc22 Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Tue, 17 Feb 2026 19:06:48 +0400 Subject: [PATCH 5/7] fix(layout): ignore sparse holes in stack gap/justify math Use non-empty child counts for stack gap totals, justify start offsets, and justify extra-gap boundary math in row/column layout+measure constraint paths. Add regression coverage for sparse children with justify and with flex/percent constraint passes. --- .../layout/__tests__/layout.edgecases.test.ts | 66 +++++++++++++++ packages/core/src/layout/kinds/stack.ts | 83 +++++++++++++------ 2 files changed, 125 insertions(+), 24 deletions(-) diff --git a/packages/core/src/layout/__tests__/layout.edgecases.test.ts b/packages/core/src/layout/__tests__/layout.edgecases.test.ts index a4f3f97d..0a999889 100644 --- a/packages/core/src/layout/__tests__/layout.edgecases.test.ts +++ b/packages/core/src/layout/__tests__/layout.edgecases.test.ts @@ -151,6 +151,72 @@ describe("layout edge cases", () => { } }); + test("sparse children do not consume justify/gap slots", () => { + const sparseRow = { + kind: "row", + props: { width: 10, gap: 1, justify: "end" }, + children: Object.freeze([{ kind: "text", text: "A", props: {} }, undefined]), + } as unknown as VNode; + const rowLayout = layout(sparseRow, 0, 0, 10, 3, "row"); + assert.equal(rowLayout.ok, true, "row with sparse children should layout"); + if (rowLayout.ok) { + assert.equal(rowLayout.value.children.length, 1); + assert.deepEqual(rowLayout.value.children[0]?.rect, { x: 9, y: 0, w: 1, h: 1 }); + } + + const sparseColumn = { + kind: "column", + props: { height: 10, gap: 1, justify: "end" }, + children: Object.freeze([{ kind: "text", text: "B", props: {} }, undefined]), + } as unknown as VNode; + const columnLayout = layout(sparseColumn, 0, 0, 3, 10, "column"); + assert.equal(columnLayout.ok, true, "column with sparse children should layout"); + if (columnLayout.ok) { + assert.equal(columnLayout.value.children.length, 1); + assert.deepEqual(columnLayout.value.children[0]?.rect, { x: 0, y: 9, w: 1, h: 1 }); + } + }); + + test("sparse children do not consume flex/percent constraint slots", () => { + const sparseFlexRow = { + kind: "row", + props: { width: 10, gap: 1 }, + children: Object.freeze([ + { kind: "box", props: { border: "none", flex: 1 }, children: Object.freeze([]) }, + undefined, + { kind: "box", props: { border: "none", flex: 1 }, children: Object.freeze([]) }, + ]), + } as unknown as VNode; + const rowLayout = layout(sparseFlexRow, 0, 0, 10, 3, "row"); + assert.equal(rowLayout.ok, true, "row constraint pass with sparse children should layout"); + if (rowLayout.ok) { + assert.equal(rowLayout.value.children.length, 2); + assert.deepEqual(rowLayout.value.children[0]?.rect, { x: 0, y: 0, w: 5, h: 0 }); + assert.deepEqual(rowLayout.value.children[1]?.rect, { x: 6, y: 0, w: 4, h: 0 }); + } + + const sparsePercentColumn = { + kind: "column", + props: { height: 10, gap: 1 }, + children: Object.freeze([ + { kind: "box", props: { border: "none", height: "50%" }, children: Object.freeze([]) }, + undefined, + { kind: "box", props: { border: "none", height: "50%" }, children: Object.freeze([]) }, + ]), + } as unknown as VNode; + const columnLayout = layout(sparsePercentColumn, 0, 0, 3, 10, "column"); + assert.equal( + columnLayout.ok, + true, + "column constraint pass with sparse children should layout", + ); + if (columnLayout.ok) { + assert.equal(columnLayout.value.children.length, 2); + assert.deepEqual(columnLayout.value.children[0]?.rect, { x: 0, y: 0, w: 0, h: 5 }); + assert.deepEqual(columnLayout.value.children[1]?.rect, { x: 0, y: 6, w: 0, h: 4 }); + } + }); + test("hit testing respects ancestor clip bounds", () => { const child: VNode = { kind: "button", props: { id: "clipped-btn", label: "Click" } }; const root: VNode = { diff --git a/packages/core/src/layout/kinds/stack.ts b/packages/core/src/layout/kinds/stack.ts index cf55039f..5238c84d 100644 --- a/packages/core/src/layout/kinds/stack.ts +++ b/packages/core/src/layout/kinds/stack.ts @@ -38,6 +38,15 @@ type LayoutNodeFn = ( forcedH?: number | null, ) => LayoutResult; +function countNonEmptyChildren(children: readonly (VNode | undefined)[]): number { + let count = 0; + for (const child of children) { + if (!child) continue; + count++; + } + return count; +} + export function measureStackKinds( vnode: VNode, maxW: number, @@ -80,6 +89,7 @@ export function measureStackKinds( const fillMain = forcedW === null && hasPercentInMainAxis; const fillCross = forcedH === null && vnode.children.some((c) => childHasPercentInCrossAxis(c, "row")); + const childCount = countNonEmptyChildren(vnode.children); const outerWLimit = forcedW ?? maxWCap; const outerHLimit = forcedH ?? maxHCap; @@ -92,7 +102,7 @@ export function measureStackKinds( if (needsConstraintPass) { const parentRect: Rect = { x: 0, y: 0, w: cw, h: chLimit }; - const gapTotal = vnode.children.length <= 1 ? 0 : gap * (vnode.children.length - 1); + const gapTotal = childCount <= 1 ? 0 : gap * (childCount - 1); const availableForChildren = clampNonNegative(cw - gapTotal); const mainSizes = new Array(vnode.children.length).fill(0); @@ -198,8 +208,7 @@ export function measureStackKinds( for (let i = 0; i < mainSizes.length; i++) { usedMainInConstraintPass += mainSizes[i] ?? 0; } - usedMainInConstraintPass += - vnode.children.length <= 1 ? 0 : gap * (vnode.children.length - 1); + usedMainInConstraintPass += childCount <= 1 ? 0 : gap * (childCount - 1); } else { let remainingWidth = cw; let cursorX = 0; @@ -285,6 +294,7 @@ export function measureStackKinds( const fillMain = forcedH === null && hasPercentInMainAxis; const fillCross = forcedW === null && vnode.children.some((c) => childHasPercentInCrossAxis(c, "column")); + const childCount = countNonEmptyChildren(vnode.children); const outerWLimit = forcedW ?? maxWCap; const outerHLimit = forcedH ?? maxHCap; @@ -297,7 +307,7 @@ export function measureStackKinds( if (needsConstraintPass) { const parentRect: Rect = { x: 0, y: 0, w: cw, h: ch }; - const gapTotal = vnode.children.length <= 1 ? 0 : gap * (vnode.children.length - 1); + const gapTotal = childCount <= 1 ? 0 : gap * (childCount - 1); const availableForChildren = clampNonNegative(ch - gapTotal); const mainSizes = new Array(vnode.children.length).fill(0); @@ -403,8 +413,7 @@ export function measureStackKinds( for (let i = 0; i < mainSizes.length; i++) { usedMainInConstraintPass += mainSizes[i] ?? 0; } - usedMainInConstraintPass += - vnode.children.length <= 1 ? 0 : gap * (vnode.children.length - 1); + usedMainInConstraintPass += childCount <= 1 ? 0 : gap * (childCount - 1); } else { let remainingHeight = ch; let cursorY = 0; @@ -492,6 +501,7 @@ export function layoutStackKinds( const ch = clampNonNegative(stackH - spacing.top - spacing.bottom); const count = vnode.children.length; + const childCount = countNonEmptyChildren(vnode.children); const children: LayoutTree[] = []; const needsConstraintPass = vnode.children.some( @@ -519,12 +529,13 @@ export function layoutStackKinds( for (let i = 0; i < mainSizes.length; i++) { usedMain += mainSizes[i] ?? 0; } - usedMain += count <= 1 ? 0 : gap * (count - 1); + usedMain += childCount <= 1 ? 0 : gap * (childCount - 1); const extra = clampNonNegative(cw - usedMain); - const startOffset = computeJustifyStartOffset(justify, extra, count); + const startOffset = computeJustifyStartOffset(justify, extra, childCount); let cursorX = cx + startOffset; let remainingWidth = clampNonNegative(cw - startOffset); + let childOrdinal = 0; for (let i = 0; i < count; i++) { const child = vnode.children[i]; @@ -534,6 +545,7 @@ export function layoutStackKinds( const childRes = layoutNode(child, cursorX, cy, 0, 0, "row"); if (!childRes.ok) return childRes; children.push(childRes.value); + childOrdinal++; continue; } @@ -563,14 +575,18 @@ export function layoutStackKinds( if (!childRes.ok) return childRes; children.push(childRes.value); - const extraGap = i < count - 1 ? computeJustifyExtraGap(justify, extra, count, i) : 0; - const step = childW + (i < count - 1 ? gap + extraGap : 0); + const hasNextChild = childOrdinal < childCount - 1; + const extraGap = hasNextChild + ? computeJustifyExtraGap(justify, extra, childCount, childOrdinal) + : 0; + const step = childW + (hasNextChild ? gap + extraGap : 0); cursorX = cursorX + step; remainingWidth = clampNonNegative(remainingWidth - step); + childOrdinal++; } } else { const parentRect: Rect = { x: 0, y: 0, w: cw, h: ch }; - const gapTotal = vnode.children.length <= 1 ? 0 : gap * (vnode.children.length - 1); + const gapTotal = childCount <= 1 ? 0 : gap * (childCount - 1); const availableForChildren = clampNonNegative(cw - gapTotal); const mainSizes = new Array(vnode.children.length).fill(0); @@ -674,12 +690,13 @@ export function layoutStackKinds( for (let i = 0; i < mainSizes.length; i++) { usedMain += mainSizes[i] ?? 0; } - usedMain += count <= 1 ? 0 : gap * (count - 1); + usedMain += childCount <= 1 ? 0 : gap * (childCount - 1); const extra = clampNonNegative(cw - usedMain); - const startOffset = computeJustifyStartOffset(justify, extra, count); + const startOffset = computeJustifyStartOffset(justify, extra, childCount); let cursorX = cx + startOffset; let remainingWidth = clampNonNegative(cw - startOffset); + let childOrdinal = 0; for (let i = 0; i < count; i++) { const child = vnode.children[i]; @@ -689,6 +706,7 @@ export function layoutStackKinds( const childRes = layoutNode(child, cursorX, cy, 0, 0, "row"); if (!childRes.ok) return childRes; children.push(childRes.value); + childOrdinal++; continue; } @@ -713,10 +731,14 @@ export function layoutStackKinds( if (!childRes.ok) return childRes; children.push(childRes.value); - const extraGap = i < count - 1 ? computeJustifyExtraGap(justify, extra, count, i) : 0; - const step = main + (i < count - 1 ? gap + extraGap : 0); + const hasNextChild = childOrdinal < childCount - 1; + const extraGap = hasNextChild + ? computeJustifyExtraGap(justify, extra, childCount, childOrdinal) + : 0; + const step = main + (hasNextChild ? gap + extraGap : 0); cursorX = cursorX + step; remainingWidth = clampNonNegative(remainingWidth - step); + childOrdinal++; } } @@ -745,6 +767,7 @@ export function layoutStackKinds( const ch = clampNonNegative(stackH - spacing.top - spacing.bottom); const count = vnode.children.length; + const childCount = countNonEmptyChildren(vnode.children); const children: LayoutTree[] = []; const needsConstraintPass = vnode.children.some( @@ -772,12 +795,13 @@ export function layoutStackKinds( for (let i = 0; i < mainSizes.length; i++) { usedMain += mainSizes[i] ?? 0; } - usedMain += count <= 1 ? 0 : gap * (count - 1); + usedMain += childCount <= 1 ? 0 : gap * (childCount - 1); const extra = clampNonNegative(ch - usedMain); - const startOffset = computeJustifyStartOffset(justify, extra, count); + const startOffset = computeJustifyStartOffset(justify, extra, childCount); let cursorY = cy + startOffset; let remainingHeight = clampNonNegative(ch - startOffset); + let childOrdinal = 0; for (let i = 0; i < count; i++) { const child = vnode.children[i]; @@ -787,6 +811,7 @@ export function layoutStackKinds( const childRes = layoutNode(child, cx, cursorY, 0, 0, "column"); if (!childRes.ok) return childRes; children.push(childRes.value); + childOrdinal++; continue; } @@ -816,14 +841,18 @@ export function layoutStackKinds( if (!childRes.ok) return childRes; children.push(childRes.value); - const extraGap = i < count - 1 ? computeJustifyExtraGap(justify, extra, count, i) : 0; - const step = childH + (i < count - 1 ? gap + extraGap : 0); + const hasNextChild = childOrdinal < childCount - 1; + const extraGap = hasNextChild + ? computeJustifyExtraGap(justify, extra, childCount, childOrdinal) + : 0; + const step = childH + (hasNextChild ? gap + extraGap : 0); cursorY = cursorY + step; remainingHeight = clampNonNegative(remainingHeight - step); + childOrdinal++; } } else { const parentRect: Rect = { x: 0, y: 0, w: cw, h: ch }; - const gapTotal = vnode.children.length <= 1 ? 0 : gap * (vnode.children.length - 1); + const gapTotal = childCount <= 1 ? 0 : gap * (childCount - 1); const availableForChildren = clampNonNegative(ch - gapTotal); const mainSizes = new Array(vnode.children.length).fill(0); @@ -927,12 +956,13 @@ export function layoutStackKinds( for (let i = 0; i < mainSizes.length; i++) { usedMain += mainSizes[i] ?? 0; } - usedMain += count <= 1 ? 0 : gap * (count - 1); + usedMain += childCount <= 1 ? 0 : gap * (childCount - 1); const extra = clampNonNegative(ch - usedMain); - const startOffset = computeJustifyStartOffset(justify, extra, count); + const startOffset = computeJustifyStartOffset(justify, extra, childCount); let cursorY = cy + startOffset; let remainingHeight = clampNonNegative(ch - startOffset); + let childOrdinal = 0; for (let i = 0; i < count; i++) { const child = vnode.children[i]; @@ -942,6 +972,7 @@ export function layoutStackKinds( const childRes = layoutNode(child, cx, cursorY, 0, 0, "column"); if (!childRes.ok) return childRes; children.push(childRes.value); + childOrdinal++; continue; } @@ -966,10 +997,14 @@ export function layoutStackKinds( if (!childRes.ok) return childRes; children.push(childRes.value); - const extraGap = i < count - 1 ? computeJustifyExtraGap(justify, extra, count, i) : 0; - const step = main + (i < count - 1 ? gap + extraGap : 0); + const hasNextChild = childOrdinal < childCount - 1; + const extraGap = hasNextChild + ? computeJustifyExtraGap(justify, extra, childCount, childOrdinal) + : 0; + const step = main + (hasNextChild ? gap + extraGap : 0); cursorY = cursorY + step; remainingHeight = clampNonNegative(remainingHeight - step); + childOrdinal++; } } From dab35a227cf2513e4fc44944e3787be3243c3f81 Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Tue, 17 Feb 2026 19:09:40 +0400 Subject: [PATCH 6/7] ci: disable recursive submodule checkout in PR workflows CI/docs/codeql jobs failed before build/test because actions/checkout recursive submodule clone of vendor/zireael could not authenticate in Actions. These workflows do not require submodule materialization to run guardrails/build/tests/docs/codeql, so use non-recursive checkout. --- .github/workflows/ci.yml | 10 +++++----- .github/workflows/codeql.yml | 4 ++-- .github/workflows/docs.yml | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a0acda5..87e2506a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - submodules: recursive + submodules: false - name: Install tools run: sudo apt-get update && sudo apt-get install -y ripgrep - name: Run guardrails @@ -32,7 +32,7 @@ jobs: - name: Checkout (with submodules) uses: actions/checkout@v4 with: - submodules: recursive + submodules: false - name: Setup Node uses: actions/setup-node@v4 @@ -120,7 +120,7 @@ jobs: - name: Checkout (with submodules) uses: actions/checkout@v4 with: - submodules: recursive + submodules: false - name: Setup Node uses: actions/setup-node@v4 @@ -164,7 +164,7 @@ jobs: - name: Checkout (with submodules) uses: actions/checkout@v4 with: - submodules: recursive + submodules: false - name: Setup Node uses: actions/setup-node@v4 @@ -213,7 +213,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - submodules: recursive + submodules: false - name: Setup Node uses: actions/setup-node@v4 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 5fd51083..87070c53 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -19,7 +19,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - submodules: recursive + submodules: false - name: Initialize CodeQL uses: github/codeql-action/init@v3 @@ -38,7 +38,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - submodules: recursive + submodules: false - name: Initialize CodeQL uses: github/codeql-action/init@v3 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index c5e59551..48707bda 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -21,7 +21,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - submodules: recursive + submodules: false - name: Setup Node uses: actions/setup-node@v4 From da88f3eb9b7a643b021b330b18c6f918dfdb192f Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Tue, 17 Feb 2026 19:11:45 +0400 Subject: [PATCH 7/7] style(layout-tests): apply biome formatting and import ordering Fix lint failures in CI by formatting newly added layout test suites and normalizing import ordering. --- .../__tests__/layout.aspect-ratio.test.ts | 23 ++--- .../__tests__/layout.auto-sizing.test.ts | 6 +- .../layout.flex-distribution.test.ts | 5 +- .../layout/__tests__/layout.margin.test.ts | 38 +++++--- .../__tests__/layout.percentage.test.ts | 89 ++++++++++--------- .../layout.stability-signature.test.ts | 61 ++++++------- .../layout/__tests__/layout.zero-edge.test.ts | 7 +- 7 files changed, 124 insertions(+), 105 deletions(-) diff --git a/packages/core/src/layout/__tests__/layout.aspect-ratio.test.ts b/packages/core/src/layout/__tests__/layout.aspect-ratio.test.ts index 2552c20a..aa920779 100644 --- a/packages/core/src/layout/__tests__/layout.aspect-ratio.test.ts +++ b/packages/core/src/layout/__tests__/layout.aspect-ratio.test.ts @@ -4,12 +4,7 @@ import type { LayoutTree } from "../layout.js"; import { layout } from "../layout.js"; import type { Axis } from "../types.js"; -function mustLayout( - node: VNode, - maxW: number, - maxH: number, - axis: Axis = "column", -): LayoutTree { +function mustLayout(node: VNode, maxW: number, maxH: number, axis: Axis = "column"): LayoutTree { const res = layout(node, 0, 0, maxW, maxH, axis); assert.equal(res.ok, true, "layout should succeed"); if (!res.ok) { @@ -59,7 +54,7 @@ describe("layout aspect-ratio (deterministic)", () => { assert.deepEqual(out.rect, { x: 0, y: 0, w: 7, h: 3 }); }); - test('percent width with aspectRatio resolves against parent width', () => { + test("percent width with aspectRatio resolves against parent width", () => { const node: VNode = { kind: "box", props: { border: "none", width: "50%", aspectRatio: 2 }, @@ -69,7 +64,7 @@ describe("layout aspect-ratio (deterministic)", () => { assert.deepEqual(out.rect, { x: 0, y: 0, w: 7, h: 3 }); }); - test('percent height with aspectRatio resolves against parent height', () => { + test("percent height with aspectRatio resolves against parent height", () => { const node: VNode = { kind: "box", props: { border: "none", height: "25%", aspectRatio: 2 }, @@ -124,7 +119,11 @@ describe("layout aspect-ratio (deterministic)", () => { kind: "row", props: {}, children: Object.freeze([ - { kind: "box", props: { border: "none", height: 4, aspectRatio: 2 }, children: Object.freeze([]) }, + { + kind: "box", + props: { border: "none", height: 4, aspectRatio: 2 }, + children: Object.freeze([]), + }, { kind: "box", props: { border: "none", flex: 1 }, children: Object.freeze([]) }, ]), }; @@ -139,7 +138,11 @@ describe("layout aspect-ratio (deterministic)", () => { kind: "column", props: {}, children: Object.freeze([ - { kind: "box", props: { border: "none", width: 6, aspectRatio: 2 }, children: Object.freeze([]) }, + { + kind: "box", + props: { border: "none", width: 6, aspectRatio: 2 }, + children: Object.freeze([]), + }, { kind: "box", props: { border: "none", flex: 1 }, children: Object.freeze([]) }, ]), }; diff --git a/packages/core/src/layout/__tests__/layout.auto-sizing.test.ts b/packages/core/src/layout/__tests__/layout.auto-sizing.test.ts index 428bc0d0..ed101e39 100644 --- a/packages/core/src/layout/__tests__/layout.auto-sizing.test.ts +++ b/packages/core/src/layout/__tests__/layout.auto-sizing.test.ts @@ -59,7 +59,7 @@ const CASES: readonly AutoCase[] = [ expectedChildren: [{ x: 0, y: 0, w: 5, h: 1 }], }, { - name: 'auto box includes padding in intrinsic size', + name: "auto box includes padding in intrinsic size", vnode: ui.box({ border: "none", width: "auto", height: "auto", p: 2 }, [ui.text("hello")]), axis: "column", maxW: 50, @@ -68,7 +68,7 @@ const CASES: readonly AutoCase[] = [ expectedChildren: [{ x: 2, y: 2, w: 5, h: 1 }], }, { - name: 'row auto child without flex does not join flex distribution', + name: "row auto child without flex does not join flex distribution", vnode: ui.row({ width: 20, height: 4, gap: 1 }, [ ui.box({ border: "none", width: "auto" }, [ui.text("abcd")]), ui.box({ border: "none", flex: 1 }, []), @@ -132,7 +132,7 @@ const CASES: readonly AutoCase[] = [ expectedChild0Children: [{ x: 0, y: 0, w: 2, h: 1 }], }, { - name: 'column auto child without flex does not join flex distribution', + name: "column auto child without flex does not join flex distribution", vnode: ui.column({ width: 12, height: 12, gap: 1 }, [ ui.box({ border: "none", height: "auto" }, [ui.text("x")]), ui.box({ border: "none", flex: 1 }, []), diff --git a/packages/core/src/layout/__tests__/layout.flex-distribution.test.ts b/packages/core/src/layout/__tests__/layout.flex-distribution.test.ts index 6a09f93b..297ec352 100644 --- a/packages/core/src/layout/__tests__/layout.flex-distribution.test.ts +++ b/packages/core/src/layout/__tests__/layout.flex-distribution.test.ts @@ -288,7 +288,10 @@ const CASES: readonly FlexCase[] = [ name: "total flex is zero and oversized fixed children clamp without negatives", main: 5, cross: 5, - children: [{ main: 6, flex: 0 }, { main: 6, flex: 0 }], + children: [ + { main: 6, flex: 0 }, + { main: 6, flex: 0 }, + ], expectedRowChildren: [ { x: 0, y: 0, w: 5, h: 0 }, { x: 5, y: 0, w: 0, h: 0 }, diff --git a/packages/core/src/layout/__tests__/layout.margin.test.ts b/packages/core/src/layout/__tests__/layout.margin.test.ts index f6ab021a..3f71dd26 100644 --- a/packages/core/src/layout/__tests__/layout.margin.test.ts +++ b/packages/core/src/layout/__tests__/layout.margin.test.ts @@ -13,12 +13,7 @@ function mustMeasure(node: VNode, maxW: number, maxH: number, axis: Axis = "colu return res.value; } -function mustLayout( - node: VNode, - maxW: number, - maxH: number, - axis: Axis = "column", -): LayoutTree { +function mustLayout(node: VNode, maxW: number, maxH: number, axis: Axis = "column"): LayoutTree { const res = layout(node, 0, 0, maxW, maxH, axis); assert.equal(res.ok, true, "layout should succeed"); if (!res.ok) { @@ -92,7 +87,11 @@ describe("layout margin (deterministic)", () => { props: { border: "none", width: 1, height: 1, ml: -2, mr: -2, mt: -1, mb: -1 }, children: Object.freeze([]), }, - { kind: "box", props: { border: "none", width: 2, height: 1 }, children: Object.freeze([]) }, + { + kind: "box", + props: { border: "none", width: 2, height: 1 }, + children: Object.freeze([]), + }, ]), }; const out = mustLayout(row, 5, 2, "row"); @@ -111,7 +110,11 @@ describe("layout margin (deterministic)", () => { props: { border: "none", width: 2, height: 1, ml: -1, mr: -1, mt: -2, mb: -1 }, children: Object.freeze([]), }, - { kind: "box", props: { border: "none", width: 1, height: 1 }, children: Object.freeze([]) }, + { + kind: "box", + props: { border: "none", width: 1, height: 1 }, + children: Object.freeze([]), + }, ]), }; const out = mustLayout(column, 4, 3, "column"); @@ -125,7 +128,11 @@ describe("layout margin (deterministic)", () => { kind: "row", props: { p: 1 }, children: Object.freeze([ - { kind: "box", props: { border: "none", width: 2, height: 1, m: 1 }, children: Object.freeze([]) }, + { + kind: "box", + props: { border: "none", width: 2, height: 1, m: 1 }, + children: Object.freeze([]), + }, ]), }; const out = mustLayout(row, 20, 10, "row"); @@ -202,7 +209,18 @@ describe("layout margin (deterministic)", () => { test("per-side margins override mx/my/m in layout", () => { const box: VNode = { kind: "box", - props: { border: "none", width: 4, height: 2, m: 1, mx: 2, my: 3, mt: 4, mr: 5, mb: 6, ml: 7 }, + props: { + border: "none", + width: 4, + height: 2, + m: 1, + mx: 2, + my: 3, + mt: 4, + mr: 5, + mb: 6, + ml: 7, + }, children: Object.freeze([]), }; const size = mustMeasure(box, 40, 40); diff --git a/packages/core/src/layout/__tests__/layout.percentage.test.ts b/packages/core/src/layout/__tests__/layout.percentage.test.ts index ed352723..32cdf94c 100644 --- a/packages/core/src/layout/__tests__/layout.percentage.test.ts +++ b/packages/core/src/layout/__tests__/layout.percentage.test.ts @@ -36,10 +36,10 @@ const ROW_CASES: readonly PercentageCase[] = [ }, { name: 'row main-axis "0%" yields zero-width sibling', - vnode: ui.row( - { width: 20, height: 6 }, - [ui.box({ border: "none", width: "0%" }, []), ui.box({ border: "none", width: 4 }, [])], - ), + vnode: ui.row({ width: 20, height: 6 }, [ + ui.box({ border: "none", width: "0%" }, []), + ui.box({ border: "none", width: 4 }, []), + ]), maxW: 20, maxH: 6, axis: "row", @@ -60,10 +60,10 @@ const ROW_CASES: readonly PercentageCase[] = [ }, { name: 'row "150%" first child can starve later fixed sibling', - vnode: ui.row( - { width: 20, height: 6 }, - [ui.box({ border: "none", width: "150%" }, []), ui.box({ border: "none", width: 3 }, [])], - ), + vnode: ui.row({ width: 20, height: 6 }, [ + ui.box({ border: "none", width: "150%" }, []), + ui.box({ border: "none", width: 3 }, []), + ]), maxW: 20, maxH: 6, axis: "row", @@ -76,7 +76,9 @@ const ROW_CASES: readonly PercentageCase[] = [ { name: "row nested percentages 50% -> 50% resolve recursively", vnode: ui.row({ width: 40, height: 8 }, [ - ui.box({ border: "none", width: "50%" }, [ui.box({ border: "none", width: "50%" }, [ui.text("x")])]), + ui.box({ border: "none", width: "50%" }, [ + ui.box({ border: "none", width: "50%" }, [ui.text("x")]), + ]), ]), maxW: 40, maxH: 8, @@ -88,10 +90,9 @@ const ROW_CASES: readonly PercentageCase[] = [ { name: "row nested 100% parent + 150% child clamps at parent content", vnode: ui.row({ width: 20, height: 8 }, [ - ui.box( - { border: "none", width: "100%" }, - [ui.box({ border: "none", width: "150%" }, [ui.text("x")])], - ), + ui.box({ border: "none", width: "100%" }, [ + ui.box({ border: "none", width: "150%" }, [ui.text("x")]), + ]), ]), maxW: 20, maxH: 8, @@ -111,7 +112,9 @@ const ROW_CASES: readonly PercentageCase[] = [ }, { name: 'row cross-axis "0%" height resolves to zero', - vnode: ui.row({ width: 20, height: 10 }, [ui.box({ border: "none", width: 4, height: "0%" }, [])]), + vnode: ui.row({ width: 20, height: 10 }, [ + ui.box({ border: "none", width: 4, height: "0%" }, []), + ]), maxW: 20, maxH: 10, axis: "row", @@ -120,7 +123,9 @@ const ROW_CASES: readonly PercentageCase[] = [ }, { name: 'row cross-axis "150%" height clamps to container height', - vnode: ui.row({ width: 20, height: 10 }, [ui.box({ border: "none", width: 4, height: "150%" }, [])]), + vnode: ui.row({ width: 20, height: 10 }, [ + ui.box({ border: "none", width: 4, height: "150%" }, []), + ]), maxW: 20, maxH: 10, axis: "row", @@ -129,10 +134,10 @@ const ROW_CASES: readonly PercentageCase[] = [ }, { name: "row percentage sizing subtracts total gap before allocation", - vnode: ui.row( - { width: 21, height: 5, gap: 2 }, - [ui.box({ border: "none", width: "50%" }, []), ui.box({ border: "none", width: "50%" }, [])], - ), + vnode: ui.row({ width: 21, height: 5, gap: 2 }, [ + ui.box({ border: "none", width: "50%" }, []), + ui.box({ border: "none", width: "50%" }, []), + ]), maxW: 21, maxH: 5, axis: "row", @@ -175,10 +180,10 @@ const COLUMN_CASES: readonly PercentageCase[] = [ }, { name: 'column main-axis "0%" yields zero-height sibling', - vnode: ui.column( - { height: 20, width: 6 }, - [ui.box({ border: "none", height: "0%" }, []), ui.box({ border: "none", height: 4 }, [])], - ), + vnode: ui.column({ height: 20, width: 6 }, [ + ui.box({ border: "none", height: "0%" }, []), + ui.box({ border: "none", height: 4 }, []), + ]), maxW: 6, maxH: 20, axis: "column", @@ -199,10 +204,10 @@ const COLUMN_CASES: readonly PercentageCase[] = [ }, { name: 'column "150%" first child can starve later fixed sibling', - vnode: ui.column( - { height: 20, width: 6 }, - [ui.box({ border: "none", height: "150%" }, []), ui.box({ border: "none", height: 3 }, [])], - ), + vnode: ui.column({ height: 20, width: 6 }, [ + ui.box({ border: "none", height: "150%" }, []), + ui.box({ border: "none", height: 3 }, []), + ]), maxW: 6, maxH: 20, axis: "column", @@ -215,10 +220,9 @@ const COLUMN_CASES: readonly PercentageCase[] = [ { name: "column nested percentages 50% -> 50% resolve recursively", vnode: ui.column({ height: 40, width: 8 }, [ - ui.box( - { border: "none", height: "50%" }, - [ui.box({ border: "none", height: "50%" }, [ui.text("x")])], - ), + ui.box({ border: "none", height: "50%" }, [ + ui.box({ border: "none", height: "50%" }, [ui.text("x")]), + ]), ]), maxW: 8, maxH: 40, @@ -230,10 +234,9 @@ const COLUMN_CASES: readonly PercentageCase[] = [ { name: "column nested 100% parent + 150% child clamps at parent content", vnode: ui.column({ height: 20, width: 8 }, [ - ui.box( - { border: "none", height: "100%" }, - [ui.box({ border: "none", height: "150%" }, [ui.text("x")])], - ), + ui.box({ border: "none", height: "100%" }, [ + ui.box({ border: "none", height: "150%" }, [ui.text("x")]), + ]), ]), maxW: 8, maxH: 20, @@ -253,7 +256,9 @@ const COLUMN_CASES: readonly PercentageCase[] = [ }, { name: 'column cross-axis "0%" width resolves to zero', - vnode: ui.column({ height: 20, width: 10 }, [ui.box({ border: "none", height: 4, width: "0%" }, [])]), + vnode: ui.column({ height: 20, width: 10 }, [ + ui.box({ border: "none", height: 4, width: "0%" }, []), + ]), maxW: 10, maxH: 20, axis: "column", @@ -262,7 +267,9 @@ const COLUMN_CASES: readonly PercentageCase[] = [ }, { name: 'column cross-axis "150%" width clamps to container width', - vnode: ui.column({ height: 20, width: 10 }, [ui.box({ border: "none", height: 4, width: "150%" }, [])]), + vnode: ui.column({ height: 20, width: 10 }, [ + ui.box({ border: "none", height: 4, width: "150%" }, []), + ]), maxW: 10, maxH: 20, axis: "column", @@ -271,10 +278,10 @@ const COLUMN_CASES: readonly PercentageCase[] = [ }, { name: "column percentage sizing subtracts total gap before allocation", - vnode: ui.column( - { height: 21, width: 5, gap: 2 }, - [ui.box({ border: "none", height: "50%" }, []), ui.box({ border: "none", height: "50%" }, [])], - ), + vnode: ui.column({ height: 21, width: 5, gap: 2 }, [ + ui.box({ border: "none", height: "50%" }, []), + ui.box({ border: "none", height: "50%" }, []), + ]), maxW: 5, maxH: 21, axis: "column", diff --git a/packages/core/src/layout/__tests__/layout.stability-signature.test.ts b/packages/core/src/layout/__tests__/layout.stability-signature.test.ts index 46d6087b..0639605d 100644 --- a/packages/core/src/layout/__tests__/layout.stability-signature.test.ts +++ b/packages/core/src/layout/__tests__/layout.stability-signature.test.ts @@ -1,6 +1,6 @@ import { assert, describe, test } from "@rezi-ui/testkit"; -import type { VNode } from "../../index.js"; import { updateLayoutStabilitySignatures } from "../../app/widgetRenderer/submitFramePipeline.js"; +import type { VNode } from "../../index.js"; import type { RuntimeInstance } from "../../runtime/commit.js"; import type { InstanceId } from "../../runtime/instance.js"; @@ -12,10 +12,7 @@ function buttonNode(label: string, props: Record = {}): VNode { return { kind: "button", props: { label, ...props } } as unknown as VNode; } -function rowNode( - children: readonly VNode[], - props: Record = {}, -): VNode { +function rowNode(children: readonly VNode[], props: Record = {}): VNode { return { kind: "row", props, @@ -23,10 +20,7 @@ function rowNode( } as unknown as VNode; } -function boxNode( - children: readonly VNode[], - props: Record = {}, -): VNode { +function boxNode(children: readonly VNode[], props: Record = {}): VNode { return { kind: "box", props, @@ -74,16 +68,10 @@ describe("layout stability signatures", () => { test("row style-only prop change is excluded", () => { const prev = new Map(); const child = runtimeNode(2, textNode("child")); - const base = runtimeNode( - 1, - rowNode([child.vnode], { gap: 1, style: { fg: "red" } }), - [child], - ); - const styleOnly = runtimeNode( - 1, - rowNode([child.vnode], { gap: 1, style: { fg: "blue" } }), - [child], - ); + const base = runtimeNode(1, rowNode([child.vnode], { gap: 1, style: { fg: "red" } }), [child]); + const styleOnly = runtimeNode(1, rowNode([child.vnode], { gap: 1, style: { fg: "blue" } }), [ + child, + ]); assert.equal(runSignatures(base, prev), true); assert.equal(runSignatures(styleOnly, prev), false); @@ -159,11 +147,10 @@ describe("layout stability signatures", () => { const childB = runtimeNode(3, textNode("B")); const base = runtimeNode(1, rowNode([childA.vnode], { gap: 0 }), [childA]); - const withAddedChild = runtimeNode( - 1, - rowNode([childA.vnode, childB.vnode], { gap: 0 }), - [childA, childB], - ); + const withAddedChild = runtimeNode(1, rowNode([childA.vnode, childB.vnode], { gap: 0 }), [ + childA, + childB, + ]); assert.equal(runSignatures(base, prev), true); assert.equal(runSignatures(withAddedChild, prev), true); @@ -174,7 +161,10 @@ describe("layout stability signatures", () => { const childA = runtimeNode(2, textNode("A")); const childB = runtimeNode(3, textNode("B")); - const base = runtimeNode(1, rowNode([childA.vnode, childB.vnode], { gap: 0 }), [childA, childB]); + const base = runtimeNode(1, rowNode([childA.vnode, childB.vnode], { gap: 0 }), [ + childA, + childB, + ]); const withRemovedChild = runtimeNode(1, rowNode([childA.vnode], { gap: 0 }), [childA]); assert.equal(runSignatures(base, prev), true); @@ -186,8 +176,14 @@ describe("layout stability signatures", () => { const childA = runtimeNode(2, textNode("A")); const childB = runtimeNode(3, textNode("B")); - const base = runtimeNode(1, rowNode([childA.vnode, childB.vnode], { gap: 0 }), [childA, childB]); - const reordered = runtimeNode(1, rowNode([childB.vnode, childA.vnode], { gap: 0 }), [childB, childA]); + const base = runtimeNode(1, rowNode([childA.vnode, childB.vnode], { gap: 0 }), [ + childA, + childB, + ]); + const reordered = runtimeNode(1, rowNode([childB.vnode, childA.vnode], { gap: 0 }), [ + childB, + childA, + ]); assert.equal(runSignatures(base, prev), true); assert.equal(runSignatures(reordered, prev), true); @@ -199,13 +195,10 @@ describe("layout stability signatures", () => { assert.equal(runSignatures(supported, prev), true); assert.ok(prev.size > 0); - const unsupported = runtimeNode( - 1, - { - kind: "select", - props: { id: "s", value: "", options: Object.freeze([]) }, - } as unknown as VNode, - ); + const unsupported = runtimeNode(1, { + kind: "select", + props: { id: "s", value: "", options: Object.freeze([]) }, + } as unknown as VNode); assert.equal(runSignatures(unsupported, prev), true); assert.equal(prev.size, 0); diff --git a/packages/core/src/layout/__tests__/layout.zero-edge.test.ts b/packages/core/src/layout/__tests__/layout.zero-edge.test.ts index 4254efa2..52e326a3 100644 --- a/packages/core/src/layout/__tests__/layout.zero-edge.test.ts +++ b/packages/core/src/layout/__tests__/layout.zero-edge.test.ts @@ -13,12 +13,7 @@ function mustMeasure(node: VNode, maxW: number, maxH: number, axis: Axis = "colu return res.value; } -function mustLayout( - node: VNode, - maxW: number, - maxH: number, - axis: Axis = "column", -): LayoutTree { +function mustLayout(node: VNode, maxW: number, maxH: number, axis: Axis = "column"): LayoutTree { const res = layout(node, 0, 0, maxW, maxH, axis); assert.equal(res.ok, true, "layout should succeed"); if (!res.ok) {