diff --git a/docs/guide/layout.md b/docs/guide/layout.md index 24595d7b..46e88f70 100644 --- a/docs/guide/layout.md +++ b/docs/guide/layout.md @@ -57,6 +57,63 @@ app.view(() => await app.start(); ``` +## Grid layout + +Use `ui.grid(...)` for 2D, dashboard-style TUIs (cards, stats, panels, and compact control surfaces). + +Key props: + +- `columns` (required): positive number or track string +- `rows` (optional): non-negative number or track string +- `gap`: default gap for both axes +- `rowGap`: row gap override +- `columnGap`: column gap override + +Track syntax (`columns` / `rows` strings): + +- Fixed numbers (cells): `12`, `24` +- `auto` +- `fr` fractions: `1fr`, `2fr` + +Behavior: + +- Numeric `columns` (for example `columns: 3`) create equal-width columns. +- Placement is auto-flow row-major: left-to-right, then wraps to the next row. +- When `rows` is explicit (`rows: 2` or a row track string), grid capacity is fixed; extra children are not rendered. +- Measurement note: `fr` tracks have natural size `0` and grow from remaining space. + +### Example: Dashboard cards + +```typescript +import { ui } from "@rezi-ui/core"; + +ui.grid( + { columns: 3, gap: 1 }, + ui.box({ border: "rounded", p: 1 }, [ui.text("CPU 42%")]), + ui.box({ border: "rounded", p: 1 }, [ui.text("Mem 68%")]), + ui.box({ border: "rounded", p: 1 }, [ui.text("Disk 71%")]), + ui.box({ border: "rounded", p: 1 }, [ui.text("Net 12MB/s")]), + ui.box({ border: "rounded", p: 1 }, [ui.text("Queue 9")]), +); +``` + +### Example: Explicit rows + mixed tracks + +```typescript +import { ui } from "@rezi-ui/core"; + +ui.grid( + { columns: "14 auto 1fr", rows: 2, columnGap: 2, rowGap: 1 }, + ui.text("Host"), + ui.text("rezi-prod-01"), + ui.text("healthy"), + ui.text("Region"), + ui.text("us-east-1"), + ui.text("ok"), + ui.text("Not rendered (overflow)"), +); +``` + ## Padding and margins Container widgets accept spacing props (values are **cells**, or named keys like `"sm"`, `"md"`, `"lg"`): diff --git a/packages/core/src/layout/__tests__/layout.grid.edge.test.ts b/packages/core/src/layout/__tests__/layout.grid.edge.test.ts new file mode 100644 index 00000000..3ebf7c41 --- /dev/null +++ b/packages/core/src/layout/__tests__/layout.grid.edge.test.ts @@ -0,0 +1,306 @@ +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 fixedBox(width: number, height: number): VNode { + return { + kind: "box", + props: { border: "none", width, height }, + children: Object.freeze([]), + }; +} + +describe("layout grid edge cases (deterministic)", () => { + test("empty implicit grid measures and layouts to zero", () => { + const grid: VNode = { kind: "grid", props: { columns: 3 }, children: Object.freeze([]) }; + assert.deepEqual(mustMeasure(grid, 40, 20), { w: 0, h: 0 }); + const laidOut = mustLayout(grid, 40, 20); + assert.deepEqual(laidOut.rect, { x: 0, y: 0, w: 0, h: 0 }); + assert.equal(laidOut.children.length, 0); + }); + + test("empty explicit tracks keep gap-only footprint", () => { + const grid: VNode = { + kind: "grid", + props: { columns: "auto auto auto", rows: "auto auto", columnGap: 3, rowGap: 5 }, + children: Object.freeze([]), + }; + assert.deepEqual(mustMeasure(grid, 40, 20), { w: 6, h: 5 }); + const laidOut = mustLayout(grid, 40, 20); + assert.deepEqual(laidOut.rect, { x: 0, y: 0, w: 6, h: 5 }); + assert.equal(laidOut.children.length, 0); + }); + + test("rows:0 creates explicit zero-capacity grid", () => { + const grid: VNode = { + kind: "grid", + props: { columns: 2, rows: 0 }, + children: Object.freeze([fixedBox(1, 1), fixedBox(1, 1)]), + }; + assert.deepEqual(mustMeasure(grid, 20, 20), { w: 0, h: 0 }); + const laidOut = mustLayout(grid, 20, 20); + assert.deepEqual(laidOut.rect, { x: 0, y: 0, w: 0, h: 0 }); + assert.equal(laidOut.children.length, 0); + }); + + test("1-column implicit rows behave like vertical stack ordering", () => { + const grid: VNode = { + kind: "grid", + props: { columns: 1, rowGap: 1 }, + children: Object.freeze([fixedBox(2, 1), fixedBox(4, 2), fixedBox(3, 1)]), + }; + + assert.deepEqual(mustMeasure(grid, 50, 50), { w: 4, h: 6 }); + const laidOut = mustLayout(grid, 50, 50); + assert.deepEqual(laidOut.rect, { x: 0, y: 0, w: 4, h: 6 }); + assert.deepEqual(laidOut.children[0]?.rect, { x: 0, y: 0, w: 4, h: 1 }); + assert.deepEqual(laidOut.children[1]?.rect, { x: 0, y: 2, w: 4, h: 2 }); + assert.deepEqual(laidOut.children[2]?.rect, { x: 0, y: 5, w: 4, h: 1 }); + }); + + test("1-column explicit rows drops overflow children", () => { + const grid: VNode = { + kind: "grid", + props: { columns: 1, rows: 2, rowGap: 1 }, + children: Object.freeze([fixedBox(2, 1), fixedBox(3, 2), fixedBox(9, 9)]), + }; + + assert.deepEqual(mustMeasure(grid, 50, 50), { w: 3, h: 4 }); + const laidOut = mustLayout(grid, 50, 50); + assert.equal(laidOut.children.length, 2); + assert.deepEqual(laidOut.children[0]?.rect, { x: 0, y: 0, w: 3, h: 1 }); + assert.deepEqual(laidOut.children[1]?.rect, { x: 0, y: 2, w: 3, h: 2 }); + }); + + test("children beyond explicit rows*columns are dropped", () => { + const grid: VNode = { + kind: "grid", + props: { columns: "2 3", rows: 1, columnGap: 1 }, + children: Object.freeze([fixedBox(1, 1), fixedBox(1, 1), fixedBox(9, 9)]), + }; + + assert.deepEqual(mustMeasure(grid, 50, 50), { w: 6, h: 1 }); + const laidOut = mustLayout(grid, 50, 50); + assert.equal(laidOut.children.length, 2); + assert.deepEqual(laidOut.children[0]?.rect, { x: 0, y: 0, w: 2, h: 1 }); + assert.deepEqual(laidOut.children[1]?.rect, { x: 3, y: 0, w: 3, h: 1 }); + }); + + test("zero available space produces zero-size tracks and child rects", () => { + const grid: VNode = { + kind: "grid", + props: { columns: "auto auto", rows: "auto auto" }, + children: Object.freeze([fixedBox(2, 2), fixedBox(3, 1), fixedBox(1, 4), fixedBox(2, 3)]), + }; + + const laidOut = mustLayout(grid, 0, 0); + assert.deepEqual(laidOut.rect, { x: 0, y: 0, w: 0, h: 0 }); + assert.equal(laidOut.children.length, 4); + for (const child of laidOut.children) { + assert.deepEqual(child.rect, { x: 0, y: 0, w: 0, h: 0 }); + } + }); + + test("all auto tracks size from per-track max content", () => { + const grid: VNode = { + kind: "grid", + props: { columns: "auto auto", rows: "auto auto", columnGap: 1, rowGap: 2 }, + children: Object.freeze([fixedBox(2, 1), fixedBox(5, 2), fixedBox(3, 4), fixedBox(1, 3)]), + }; + + assert.deepEqual(mustMeasure(grid, 50, 50), { w: 9, h: 8 }); + const laidOut = mustLayout(grid, 50, 50); + assert.deepEqual(laidOut.rect, { x: 0, y: 0, w: 9, h: 8 }); + assert.deepEqual(laidOut.children[0]?.rect, { x: 0, y: 0, w: 3, h: 2 }); + assert.deepEqual(laidOut.children[1]?.rect, { x: 4, y: 0, w: 5, h: 2 }); + assert.deepEqual(laidOut.children[2]?.rect, { x: 0, y: 4, w: 3, h: 4 }); + assert.deepEqual(laidOut.children[3]?.rect, { x: 4, y: 4, w: 5, h: 4 }); + }); + + test("all fr tracks distribute constrained space deterministically", () => { + const inner: VNode = { + kind: "grid", + props: { columns: "1fr 2fr", rows: "1fr 3fr", columnGap: 1, rowGap: 1 }, + children: Object.freeze([fixedBox(5, 2), fixedBox(1, 1), fixedBox(3, 2), fixedBox(1, 2)]), + }; + + assert.deepEqual(mustMeasure(inner, 50, 50), { w: 1, h: 1 }); + const wrapper: VNode = { + kind: "grid", + props: { columns: "7", rows: "5" }, + children: Object.freeze([inner]), + }; + const laidOut = mustLayout(wrapper, 7, 5); + const forcedInner = laidOut.children[0]; + assert.ok(forcedInner !== undefined); + assert.deepEqual(forcedInner.rect, { x: 0, y: 0, w: 7, h: 5 }); + assert.deepEqual(forcedInner.children[0]?.rect, { x: 0, y: 0, w: 2, h: 1 }); + assert.deepEqual(forcedInner.children[1]?.rect, { x: 3, y: 0, w: 4, h: 1 }); + assert.deepEqual(forcedInner.children[2]?.rect, { x: 0, y: 2, w: 2, h: 3 }); + assert.deepEqual(forcedInner.children[3]?.rect, { x: 3, y: 2, w: 4, h: 3 }); + }); + + test("mixed auto/fr/fixed tracks clamp under constrained space", () => { + const inner: VNode = { + kind: "grid", + props: { columns: "auto 1fr 4", rows: "auto 1fr", columnGap: 1, rowGap: 1 }, + children: Object.freeze([ + fixedBox(5, 2), + fixedBox(3, 1), + fixedBox(1, 2), + fixedBox(4, 4), + fixedBox(2, 3), + fixedBox(2, 1), + ]), + }; + + assert.deepEqual(mustMeasure(inner, 10, 5), { w: 10, h: 3 }); + const wrapper: VNode = { + kind: "grid", + props: { columns: "10", rows: "5" }, + children: Object.freeze([inner]), + }; + const laidOut = mustLayout(wrapper, 10, 5); + const forcedInner = laidOut.children[0]; + assert.ok(forcedInner !== undefined); + assert.deepEqual(forcedInner.rect, { x: 0, y: 0, w: 10, h: 5 }); + assert.deepEqual(forcedInner.children[0]?.rect, { x: 0, y: 0, w: 5, h: 2 }); + assert.deepEqual(forcedInner.children[1]?.rect, { x: 6, y: 0, w: 0, h: 2 }); + assert.deepEqual(forcedInner.children[2]?.rect, { x: 7, y: 0, w: 3, h: 2 }); + assert.deepEqual(forcedInner.children[3]?.rect, { x: 0, y: 3, w: 5, h: 2 }); + assert.deepEqual(forcedInner.children[4]?.rect, { x: 6, y: 3, w: 0, h: 2 }); + assert.deepEqual(forcedInner.children[5]?.rect, { x: 7, y: 3, w: 3, h: 2 }); + }); + + test("large odd gaps are preserved in measure and placement", () => { + const grid: VNode = { + kind: "grid", + props: { columns: "2 2 2", rows: "1 1", columnGap: 5, rowGap: 3 }, + children: Object.freeze([ + fixedBox(1, 1), + fixedBox(1, 1), + fixedBox(1, 1), + fixedBox(1, 1), + fixedBox(1, 1), + fixedBox(1, 1), + ]), + }; + + assert.deepEqual(mustMeasure(grid, 50, 50), { w: 16, h: 5 }); + const laidOut = mustLayout(grid, 50, 50); + assert.deepEqual(laidOut.children[0]?.rect, { x: 0, y: 0, w: 2, h: 1 }); + assert.deepEqual(laidOut.children[1]?.rect, { x: 7, y: 0, w: 2, h: 1 }); + assert.deepEqual(laidOut.children[2]?.rect, { x: 14, y: 0, w: 2, h: 1 }); + assert.deepEqual(laidOut.children[5]?.rect, { x: 14, y: 4, w: 2, h: 1 }); + }); + + test("zero gaps create contiguous track starts", () => { + const grid: VNode = { + kind: "grid", + props: { columns: "2 3", rows: "1 2", gap: 0 }, + children: Object.freeze([fixedBox(1, 1), fixedBox(1, 1), fixedBox(1, 1), fixedBox(1, 1)]), + }; + + assert.deepEqual(mustMeasure(grid, 50, 50), { w: 5, h: 3 }); + const laidOut = mustLayout(grid, 50, 50); + assert.deepEqual(laidOut.children[0]?.rect, { x: 0, y: 0, w: 2, h: 1 }); + assert.deepEqual(laidOut.children[1]?.rect, { x: 2, y: 0, w: 3, h: 1 }); + assert.deepEqual(laidOut.children[2]?.rect, { x: 0, y: 1, w: 2, h: 2 }); + assert.deepEqual(laidOut.children[3]?.rect, { x: 2, y: 1, w: 3, h: 2 }); + }); + + test("fractional columns number is truncated", () => { + const grid: VNode = { + kind: "grid", + props: { columns: 2.9, rows: 2, gap: 0 }, + children: Object.freeze([fixedBox(1, 1), fixedBox(1, 1), fixedBox(1, 1), fixedBox(1, 1)]), + }; + + assert.deepEqual(mustMeasure(grid, 50, 50), { w: 2, h: 2 }); + const laidOut = mustLayout(grid, 50, 50); + assert.deepEqual(laidOut.children[0]?.rect, { x: 0, y: 0, w: 1, h: 1 }); + assert.deepEqual(laidOut.children[1]?.rect, { x: 1, y: 0, w: 1, h: 1 }); + assert.deepEqual(laidOut.children[2]?.rect, { x: 0, y: 1, w: 1, h: 1 }); + assert.deepEqual(laidOut.children[3]?.rect, { x: 1, y: 1, w: 1, h: 1 }); + }); + + test("fractional rows number is truncated and overflow is dropped", () => { + const grid: VNode = { + kind: "grid", + props: { columns: 2, rows: 1.9, gap: 0 }, + children: Object.freeze([fixedBox(1, 1), fixedBox(1, 1), fixedBox(9, 9)]), + }; + + assert.deepEqual(mustMeasure(grid, 50, 50), { w: 2, h: 1 }); + const laidOut = mustLayout(grid, 50, 50); + assert.equal(laidOut.children.length, 2); + assert.deepEqual(laidOut.children[0]?.rect, { x: 0, y: 0, w: 1, h: 1 }); + assert.deepEqual(laidOut.children[1]?.rect, { x: 1, y: 0, w: 1, h: 1 }); + }); + + test("fractional gap is truncated and shared by row/column gaps", () => { + const grid: VNode = { + kind: "grid", + props: { columns: "1 1", rows: "1 1", gap: 2.9 }, + children: Object.freeze([fixedBox(1, 1), fixedBox(1, 1), fixedBox(1, 1), fixedBox(1, 1)]), + }; + + assert.deepEqual(mustMeasure(grid, 50, 50), { w: 4, h: 4 }); + const laidOut = mustLayout(grid, 50, 50); + assert.deepEqual(laidOut.children[3]?.rect, { x: 3, y: 3, w: 1, h: 1 }); + }); + + test("rowGap/columnGap override gap and truncate independently", () => { + const grid: VNode = { + kind: "grid", + props: { columns: "1 1", rows: "1 1", gap: 9.9, rowGap: 1.9, columnGap: 3.9 }, + children: Object.freeze([fixedBox(1, 1), fixedBox(1, 1), fixedBox(1, 1), fixedBox(1, 1)]), + }; + + assert.deepEqual(mustMeasure(grid, 50, 50), { w: 5, h: 3 }); + const laidOut = mustLayout(grid, 50, 50); + assert.deepEqual(laidOut.children[1]?.rect, { x: 4, y: 0, w: 1, h: 1 }); + assert.deepEqual(laidOut.children[3]?.rect, { x: 4, y: 2, w: 1, h: 1 }); + }); + + test("sparse children arrays are tolerated and holes do not consume grid slots", () => { + const sparseGrid = { + kind: "grid", + props: { columns: "2 2", rows: "1 1", gap: 1 }, + children: Object.freeze([ + fixedBox(1, 1), + undefined, + fixedBox(1, 1), + undefined, + fixedBox(1, 1), + ]), + } as unknown as VNode; + + assert.deepEqual(mustMeasure(sparseGrid, 50, 50), { w: 5, h: 3 }); + const laidOut = mustLayout(sparseGrid, 50, 50); + assert.equal(laidOut.children.length, 3); + assert.deepEqual(laidOut.children[0]?.rect, { x: 0, y: 0, w: 2, h: 1 }); + assert.deepEqual(laidOut.children[1]?.rect, { x: 3, y: 0, w: 2, h: 1 }); + assert.deepEqual(laidOut.children[2]?.rect, { x: 0, y: 2, w: 2, h: 1 }); + }); +}); diff --git a/packages/core/src/layout/__tests__/layout.grid.test.ts b/packages/core/src/layout/__tests__/layout.grid.test.ts new file mode 100644 index 00000000..495d0aaf --- /dev/null +++ b/packages/core/src/layout/__tests__/layout.grid.test.ts @@ -0,0 +1,569 @@ +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"; + +type GridProps = Readonly<{ + columns: number | string; + rows?: number | string; + gap?: number; + rowGap?: number; + columnGap?: number; +}>; + +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 text(value: string): VNode { + return { kind: "text", text: value, props: {} }; +} + +function box(width: number, height: number): VNode { + return { + kind: "box", + props: { border: "none", width, height }, + children: Object.freeze([]), + }; +} + +function gridNode(props: GridProps, children: readonly (VNode | undefined)[]): VNode { + return { + kind: "grid", + props, + children: Object.freeze([...children]) as readonly VNode[], + }; +} + +function childRects(node: LayoutTree) { + return node.children.map((c) => c.rect); +} + +describe("layout grid (deterministic)", () => { + test("fixed numeric columns measure inferred rows and per-column naturals", () => { + const node = gridNode( + { columns: 3 }, + Object.freeze([text("A"), text("BB"), text("CCC"), text("DDDD")]), + ); + assert.deepEqual(mustMeasure(node, 40, 10), { w: 9, h: 2 }); + }); + + test("fixed numeric columns layout uses equal count-track widths", () => { + const node = gridNode( + { columns: 3 }, + Object.freeze([text("A"), text("BB"), text("CCC"), text("DDDD")]), + ); + const out = mustLayout(node, 40, 10); + assert.deepEqual(out.rect, { x: 0, y: 0, w: 9, h: 2 }); + assert.deepEqual(childRects(out), [ + { x: 0, y: 0, w: 3, h: 1 }, + { x: 3, y: 0, w: 3, h: 1 }, + { x: 6, y: 0, w: 3, h: 1 }, + { x: 0, y: 1, w: 3, h: 1 }, + ]); + }); + + test("fixed numeric columns distribute constrained remainder deterministically", () => { + const node = gridNode( + { columns: 3 }, + Object.freeze([text("AAAA"), text("BBBB"), text("CCCC")]), + ); + const out = mustLayout(node, 5, 4); + assert.deepEqual(out.rect, { x: 0, y: 0, w: 5, h: 1 }); + assert.deepEqual(childRects(out), [ + { x: 0, y: 0, w: 2, h: 1 }, + { x: 2, y: 0, w: 2, h: 1 }, + { x: 4, y: 0, w: 1, h: 1 }, + ]); + }); + + test("explicit rows=0 capacity drops all children", () => { + const node = gridNode( + { columns: 3, rows: 0 }, + Object.freeze([text("A"), text("B"), text("C")]), + ); + assert.deepEqual(mustMeasure(node, 40, 10), { w: 0, h: 0 }); + const out = mustLayout(node, 40, 10); + assert.deepEqual(out.rect, { x: 0, y: 0, w: 0, h: 0 }); + assert.equal(out.children.length, 0); + }); + + test("mixed string tracks (fixed/auto/fr + fixed/auto rows) layout deterministically", () => { + const node = gridNode( + { columns: "4 auto 1fr", rows: "2 auto" }, + Object.freeze([text("a"), text("bb"), text("ccc"), text("d"), text("eeeee"), text("f")]), + ); + assert.deepEqual(mustMeasure(node, 100, 100), { w: 9, h: 3 }); + + const out = mustLayout(node, 100, 100); + assert.deepEqual(out.rect, { x: 0, y: 0, w: 9, h: 3 }); + assert.deepEqual(childRects(out), [ + { x: 0, y: 0, w: 4, h: 2 }, + { x: 4, y: 0, w: 5, h: 2 }, + { x: 9, y: 0, w: 0, h: 2 }, + { x: 0, y: 2, w: 4, h: 1 }, + { x: 4, y: 2, w: 5, h: 1 }, + { x: 9, y: 2, w: 0, h: 1 }, + ]); + + const forced = mustLayout(gridNode({ columns: "12", rows: "3" }, Object.freeze([node])), 12, 3); + const forcedInner = forced.children[0]; + assert.ok(forcedInner !== undefined); + assert.deepEqual(forcedInner.rect, { x: 0, y: 0, w: 12, h: 3 }); + assert.deepEqual(childRects(forcedInner), [ + { x: 0, y: 0, w: 4, h: 2 }, + { x: 4, y: 0, w: 5, h: 2 }, + { x: 9, y: 0, w: 3, h: 2 }, + { x: 0, y: 2, w: 4, h: 1 }, + { x: 4, y: 2, w: 5, h: 1 }, + { x: 9, y: 2, w: 3, h: 1 }, + ]); + }); + + test("string tracks parse commas and whitespace equivalently", () => { + const a = gridNode( + { columns: "2, auto, 1fr", rows: "1, auto" }, + Object.freeze([text("A"), text("BBBB"), text("CC"), text("D"), text("EEE"), text("F")]), + ); + const b = gridNode( + { columns: "2 auto 1fr", rows: "1 auto" }, + Object.freeze([text("A"), text("BBBB"), text("CC"), text("D"), text("EEE"), text("F")]), + ); + + assert.deepEqual(mustMeasure(a, 60, 20), mustMeasure(b, 60, 20)); + const outA = mustLayout(a, 60, 20); + const outB = mustLayout(b, 60, 20); + assert.deepEqual(outA.rect, outB.rect); + assert.deepEqual(childRects(outA), childRects(outB)); + }); + + test("bare fr token behaves as 1fr", () => { + const withBare = gridNode( + { columns: "fr 2fr", rows: "1" }, + Object.freeze([text("AAAAAA"), text("BBBBBB")]), + ); + const withOne = gridNode( + { columns: "1fr 2fr", rows: "1" }, + Object.freeze([text("AAAAAA"), text("BBBBBB")]), + ); + + assert.deepEqual(mustMeasure(withBare, 6, 4), { w: 0, h: 1 }); + assert.deepEqual(mustMeasure(withOne, 6, 4), { w: 0, h: 1 }); + + const outerA = mustLayout( + gridNode({ columns: "6", rows: "1" }, Object.freeze([withBare])), + 6, + 1, + ); + const outerB = mustLayout( + gridNode({ columns: "6", rows: "1" }, Object.freeze([withOne])), + 6, + 1, + ); + const a = outerA.children[0]; + const b = outerB.children[0]; + assert.ok(a !== undefined); + assert.ok(b !== undefined); + assert.deepEqual(a.rect, { x: 0, y: 0, w: 6, h: 1 }); + assert.deepEqual(childRects(a), [ + { x: 0, y: 0, w: 2, h: 1 }, + { x: 2, y: 0, w: 4, h: 1 }, + ]); + assert.deepEqual(childRects(a), childRects(b)); + }); + + test("auto-flow places children left-to-right then wraps top-to-bottom", () => { + const node = gridNode( + { columns: 2 }, + Object.freeze([text("A"), text("B"), text("C"), text("D"), text("E")]), + ); + + const out = mustLayout(node, 20, 20); + assert.deepEqual(out.rect, { x: 0, y: 0, w: 2, h: 3 }); + assert.deepEqual(childRects(out), [ + { x: 0, y: 0, w: 1, h: 1 }, + { x: 1, y: 0, w: 1, h: 1 }, + { x: 0, y: 1, w: 1, h: 1 }, + { x: 1, y: 1, w: 1, h: 1 }, + { x: 0, y: 2, w: 1, h: 1 }, + ]); + }); + + test("auto-flow compacts sparse child arrays by skipping undefined entries", () => { + const node = gridNode( + { columns: 2 }, + Object.freeze([text("A"), undefined, text("B"), text("C")]), + ); + + const out = mustLayout(node, 20, 20); + assert.deepEqual(out.rect, { x: 0, y: 0, w: 2, h: 2 }); + assert.equal(out.children.length, 3); + assert.deepEqual(childRects(out), [ + { x: 0, y: 0, w: 1, h: 1 }, + { x: 1, y: 0, w: 1, h: 1 }, + { x: 0, y: 1, w: 1, h: 1 }, + ]); + }); + + test("gap applies to both axes when rowGap/columnGap are omitted", () => { + const node = gridNode( + { columns: "2 2", rows: "1 1", gap: 1 }, + Object.freeze([text("A"), text("B"), text("C"), text("D")]), + ); + + assert.deepEqual(mustMeasure(node, 40, 20), { w: 5, h: 3 }); + const out = mustLayout(node, 40, 20); + assert.deepEqual(childRects(out), [ + { x: 0, y: 0, w: 2, h: 1 }, + { x: 3, y: 0, w: 2, h: 1 }, + { x: 0, y: 2, w: 2, h: 1 }, + { x: 3, y: 2, w: 2, h: 1 }, + ]); + }); + + test("rowGap overrides gap for vertical spacing only", () => { + const node = gridNode( + { columns: "2 2", rows: "1 1", gap: 1, rowGap: 2 }, + Object.freeze([text("A"), text("B"), text("C"), text("D")]), + ); + + assert.deepEqual(mustMeasure(node, 40, 20), { w: 5, h: 4 }); + const out = mustLayout(node, 40, 20); + assert.deepEqual(childRects(out), [ + { x: 0, y: 0, w: 2, h: 1 }, + { x: 3, y: 0, w: 2, h: 1 }, + { x: 0, y: 3, w: 2, h: 1 }, + { x: 3, y: 3, w: 2, h: 1 }, + ]); + }); + + test("columnGap overrides gap for horizontal spacing only", () => { + const node = gridNode( + { columns: "2 2", rows: "1 1", gap: 1, columnGap: 2 }, + Object.freeze([text("A"), text("B"), text("C"), text("D")]), + ); + + assert.deepEqual(mustMeasure(node, 40, 20), { w: 6, h: 3 }); + const out = mustLayout(node, 40, 20); + assert.deepEqual(childRects(out), [ + { x: 0, y: 0, w: 2, h: 1 }, + { x: 4, y: 0, w: 2, h: 1 }, + { x: 0, y: 2, w: 2, h: 1 }, + { x: 4, y: 2, w: 2, h: 1 }, + ]); + }); + + test("rowGap and columnGap both override gap", () => { + const node = gridNode( + { columns: "2 2", rows: "1 1", gap: 3, rowGap: 1, columnGap: 2 }, + Object.freeze([text("A"), text("B"), text("C"), text("D")]), + ); + + assert.deepEqual(mustMeasure(node, 40, 20), { w: 6, h: 3 }); + const out = mustLayout(node, 40, 20); + assert.deepEqual(childRects(out), [ + { x: 0, y: 0, w: 2, h: 1 }, + { x: 4, y: 0, w: 2, h: 1 }, + { x: 0, y: 2, w: 2, h: 1 }, + { x: 4, y: 2, w: 2, h: 1 }, + ]); + }); + + test("fr tracks distribute width proportionally under constrained space", () => { + const inner = gridNode( + { columns: "1fr 2fr 3fr", rows: "1" }, + Object.freeze([text("XXXXXXXXXX"), text("YYYYYYYYYY"), text("ZZZZZZZZZZ")]), + ); + + assert.deepEqual(mustMeasure(inner, 24, 10), { w: 0, h: 1 }); + const out = mustLayout(gridNode({ columns: "24", rows: "1" }, Object.freeze([inner])), 24, 1); + const forcedInner = out.children[0]; + assert.ok(forcedInner !== undefined); + assert.deepEqual(forcedInner.rect, { x: 0, y: 0, w: 24, h: 1 }); + assert.deepEqual(childRects(forcedInner), [ + { x: 0, y: 0, w: 4, h: 1 }, + { x: 4, y: 0, w: 8, h: 1 }, + { x: 12, y: 0, w: 12, h: 1 }, + ]); + }); + + test("fr remainder distribution is deterministic by track order", () => { + const inner = gridNode( + { columns: "1fr 1fr 1fr", rows: "1" }, + Object.freeze([text("AAAA"), text("BBBB"), text("CCCC")]), + ); + + assert.deepEqual(mustMeasure(inner, 5, 10), { w: 0, h: 1 }); + const out = mustLayout(gridNode({ columns: "5", rows: "1" }, Object.freeze([inner])), 5, 1); + const forcedInner = out.children[0]; + assert.ok(forcedInner !== undefined); + assert.deepEqual(forcedInner.rect, { x: 0, y: 0, w: 5, h: 1 }); + assert.deepEqual(childRects(forcedInner), [ + { x: 0, y: 0, w: 2, h: 1 }, + { x: 2, y: 0, w: 2, h: 1 }, + { x: 4, y: 0, w: 1, h: 1 }, + ]); + }); + + test("mixed fixed and fr columns allocate fixed first then flex remainder", () => { + const inner = gridNode( + { columns: "3 1fr 2fr", rows: "1" }, + Object.freeze([text("AAAAAA"), text("BBBBBB"), text("CCCCCC")]), + ); + + assert.deepEqual(mustMeasure(inner, 12, 10), { w: 3, h: 1 }); + const out = mustLayout(gridNode({ columns: "12", rows: "1" }, Object.freeze([inner])), 12, 1); + const forcedInner = out.children[0]; + assert.ok(forcedInner !== undefined); + assert.deepEqual(forcedInner.rect, { x: 0, y: 0, w: 12, h: 1 }); + assert.deepEqual(childRects(forcedInner), [ + { x: 0, y: 0, w: 3, h: 1 }, + { x: 3, y: 0, w: 3, h: 1 }, + { x: 6, y: 0, w: 6, h: 1 }, + ]); + }); + + test("auto columns use largest natural width in each column", () => { + const node = gridNode( + { columns: "auto auto", rows: "auto auto" }, + Object.freeze([text("AA"), text("BBBB"), text("CCCCC"), text("D")]), + ); + + assert.deepEqual(mustMeasure(node, 40, 10), { w: 9, h: 2 }); + const out = mustLayout(node, 40, 10); + assert.deepEqual(childRects(out), [ + { x: 0, y: 0, w: 5, h: 1 }, + { x: 5, y: 0, w: 4, h: 1 }, + { x: 0, y: 1, w: 5, h: 1 }, + { x: 5, y: 1, w: 4, h: 1 }, + ]); + }); + + test("auto rows use tallest natural child height in each row", () => { + const node = gridNode( + { columns: "1 1", rows: "auto auto" }, + Object.freeze([box(1, 1), box(1, 3), box(1, 2), box(1, 1)]), + ); + + assert.deepEqual(mustMeasure(node, 20, 20), { w: 2, h: 5 }); + const out = mustLayout(node, 20, 20); + assert.deepEqual(childRects(out), [ + { x: 0, y: 0, w: 1, h: 3 }, + { x: 1, y: 0, w: 1, h: 3 }, + { x: 0, y: 3, w: 1, h: 2 }, + { x: 1, y: 3, w: 1, h: 2 }, + ]); + }); + + test("explicit numeric rows enforce capacity and drop overflow children", () => { + const node = gridNode( + { columns: 2, rows: 2 }, + Object.freeze([text("A"), text("B"), text("C"), text("D"), text("LLLLLLLLLL"), text("MMMM")]), + ); + + assert.deepEqual(mustMeasure(node, 40, 20), { w: 2, h: 2 }); + const out = mustLayout(node, 40, 20); + assert.equal(out.children.length, 4); + assert.deepEqual(childRects(out), [ + { x: 0, y: 0, w: 1, h: 1 }, + { x: 1, y: 0, w: 1, h: 1 }, + { x: 0, y: 1, w: 1, h: 1 }, + { x: 1, y: 1, w: 1, h: 1 }, + ]); + }); + + test("explicit string rows enforce capacity and drop overflow children", () => { + const node = gridNode( + { columns: "3 3", rows: "1" }, + Object.freeze([text("A"), text("B"), text("CCCCCCCC")]), + ); + + assert.deepEqual(mustMeasure(node, 40, 20), { w: 6, h: 1 }); + const out = mustLayout(node, 40, 20); + assert.equal(out.children.length, 2); + assert.deepEqual(childRects(out), [ + { x: 0, y: 0, w: 3, h: 1 }, + { x: 3, y: 0, w: 3, h: 1 }, + ]); + }); + + test("overflow children do not affect auto track sizing when rows are explicit", () => { + const node = gridNode( + { columns: "auto auto", rows: 1 }, + Object.freeze([text("AA"), text("BBB"), text("MMMMMMMMMMMM")]), + ); + + assert.deepEqual(mustMeasure(node, 40, 20), { w: 5, h: 1 }); + const out = mustLayout(node, 40, 20); + assert.equal(out.children.length, 2); + assert.deepEqual(childRects(out), [ + { x: 0, y: 0, w: 2, h: 1 }, + { x: 2, y: 0, w: 3, h: 1 }, + ]); + }); + + test("rows are inferred when omitted", () => { + const node = gridNode( + { columns: 3 }, + Object.freeze([text("A"), text("B"), text("C"), text("D"), text("E"), text("F"), text("G")]), + ); + + assert.deepEqual(mustMeasure(node, 40, 20), { w: 3, h: 3 }); + const out = mustLayout(node, 40, 20); + assert.equal(out.children.length, 7); + assert.deepEqual(out.children[6]?.rect, { x: 0, y: 2, w: 1, h: 1 }); + }); + + test("inferred rows include gap in measured and laid out height", () => { + const node = gridNode({ columns: 2, gap: 1 }, Object.freeze([text("A"), text("B"), text("C")])); + + assert.deepEqual(mustMeasure(node, 40, 20), { w: 3, h: 3 }); + const out = mustLayout(node, 40, 20); + assert.deepEqual(childRects(out), [ + { x: 0, y: 0, w: 1, h: 1 }, + { x: 2, y: 0, w: 1, h: 1 }, + { x: 0, y: 2, w: 1, h: 1 }, + ]); + }); + + test("rows omitted with zero children produce zero-sized grid", () => { + const node = gridNode({ columns: 3 }, Object.freeze([])); + assert.deepEqual(mustMeasure(node, 40, 20), { w: 0, h: 0 }); + const out = mustLayout(node, 40, 20); + assert.deepEqual(out.rect, { x: 0, y: 0, w: 0, h: 0 }); + assert.equal(out.children.length, 0); + }); + + test("nested: grid inside row lays out deterministically", () => { + const innerGrid = gridNode( + { columns: "2 2", rows: "1" }, + Object.freeze([text("a"), text("b")]), + ); + const row: VNode = { + kind: "row", + props: { gap: 1 }, + children: Object.freeze([text("L"), innerGrid, text("R")]), + }; + + const out = mustLayout(row, 30, 10, "row"); + assert.deepEqual(out.rect, { x: 0, y: 0, w: 8, h: 1 }); + assert.deepEqual(out.children[0]?.rect, { x: 0, y: 0, w: 1, h: 1 }); + assert.deepEqual(out.children[1]?.rect, { x: 2, y: 0, w: 4, h: 1 }); + assert.deepEqual(out.children[2]?.rect, { x: 7, y: 0, w: 1, h: 1 }); + const gridLayout = out.children[1]; + assert.ok(gridLayout !== undefined); + assert.deepEqual(childRects(gridLayout), [ + { x: 2, y: 0, w: 2, h: 1 }, + { x: 4, y: 0, w: 2, h: 1 }, + ]); + }); + + test("nested: grid inside column lays out deterministically", () => { + const innerGrid = gridNode( + { columns: "2 2", rows: "1 1" }, + Object.freeze([text("a"), text("b"), text("c"), text("d")]), + ); + const column: VNode = { + kind: "column", + props: { gap: 1 }, + children: Object.freeze([text("T"), innerGrid]), + }; + + const out = mustLayout(column, 30, 20, "column"); + assert.deepEqual(out.rect, { x: 0, y: 0, w: 4, h: 4 }); + assert.deepEqual(out.children[0]?.rect, { x: 0, y: 0, w: 1, h: 1 }); + assert.deepEqual(out.children[1]?.rect, { x: 0, y: 2, w: 4, h: 2 }); + const gridLayout = out.children[1]; + assert.ok(gridLayout !== undefined); + assert.deepEqual(childRects(gridLayout), [ + { x: 0, y: 2, w: 2, h: 1 }, + { x: 2, y: 2, w: 2, h: 1 }, + { x: 0, y: 3, w: 2, h: 1 }, + { x: 2, y: 3, w: 2, h: 1 }, + ]); + }); + + test("nested: row inside grid cell is forced to cell rect", () => { + const innerRow: VNode = { + kind: "row", + props: { gap: 1 }, + children: Object.freeze([text("A"), text("B")]), + }; + const node = gridNode({ columns: "5", rows: "2" }, Object.freeze([innerRow])); + + const out = mustLayout(node, 20, 20); + assert.deepEqual(out.rect, { x: 0, y: 0, w: 5, h: 2 }); + const rowLayout = out.children[0]; + assert.ok(rowLayout !== undefined); + assert.deepEqual(rowLayout.rect, { x: 0, y: 0, w: 5, h: 2 }); + assert.deepEqual(childRects(rowLayout), [ + { x: 0, y: 0, w: 1, h: 1 }, + { x: 2, y: 0, w: 1, h: 1 }, + ]); + }); + + test("nested: column inside grid cell is forced to cell rect", () => { + const innerColumn: VNode = { + kind: "column", + props: { gap: 1 }, + children: Object.freeze([text("A"), text("B")]), + }; + const node = gridNode({ columns: "4", rows: "3" }, Object.freeze([innerColumn])); + + const out = mustLayout(node, 20, 20); + assert.deepEqual(out.rect, { x: 0, y: 0, w: 4, h: 3 }); + const columnLayout = out.children[0]; + assert.ok(columnLayout !== undefined); + assert.deepEqual(columnLayout.rect, { x: 0, y: 0, w: 4, h: 3 }); + assert.deepEqual(childRects(columnLayout), [ + { x: 0, y: 0, w: 1, h: 1 }, + { x: 0, y: 2, w: 1, h: 1 }, + ]); + }); + + test("nested: row and column children can occupy separate grid cells", () => { + const cellRow: VNode = { + kind: "row", + props: { gap: 1 }, + children: Object.freeze([text("L"), text("R")]), + }; + const cellColumn: VNode = { + kind: "column", + props: { gap: 1 }, + children: Object.freeze([text("T"), text("B")]), + }; + const node = gridNode({ columns: "4 4", rows: "2" }, Object.freeze([cellRow, cellColumn])); + + const out = mustLayout(node, 40, 20); + assert.deepEqual(out.rect, { x: 0, y: 0, w: 8, h: 2 }); + assert.deepEqual(out.children[0]?.rect, { x: 0, y: 0, w: 4, h: 2 }); + assert.deepEqual(out.children[1]?.rect, { x: 4, y: 0, w: 4, h: 2 }); + const rowLayout = out.children[0]; + const columnLayout = out.children[1]; + assert.ok(rowLayout !== undefined); + assert.ok(columnLayout !== undefined); + assert.deepEqual(childRects(rowLayout), [ + { x: 0, y: 0, w: 1, h: 1 }, + { x: 2, y: 0, w: 1, h: 1 }, + ]); + assert.deepEqual(childRects(columnLayout), [ + { x: 4, y: 0, w: 1, h: 1 }, + { x: 4, y: 2, w: 0, h: 0 }, + ]); + }); +}); diff --git a/packages/core/src/layout/engine/layoutEngine.ts b/packages/core/src/layout/engine/layoutEngine.ts index 920e46f1..14904c4c 100644 --- a/packages/core/src/layout/engine/layoutEngine.ts +++ b/packages/core/src/layout/engine/layoutEngine.ts @@ -27,6 +27,7 @@ import type { LayoutResult } from "../validateProps.js"; export type { LayoutTree } from "./types.js"; import { layoutBoxKinds, measureBoxKinds } from "../kinds/box.js"; import { layoutCollections, measureCollections } from "../kinds/collections.js"; +import { layoutGridKinds, measureGridKinds } from "../kinds/grid.js"; import { layoutLeafKind, measureLeaf } from "../kinds/leaf.js"; import { layoutOverlays, measureOverlays } from "../kinds/overlays.js"; import { layoutSplitPaneKinds, measureSplitPaneKinds } from "../kinds/splitPane.js"; @@ -119,6 +120,10 @@ function measureNode(vnode: VNode, maxW: number, maxH: number, axis: Axis): Layo computed = measureStackKinds(vnode, maxW, maxH, axis, measureNode); break; } + case "grid": { + computed = measureGridKinds(vnode, maxW, maxH, axis, measureNode); + break; + } case "box": { computed = measureBoxKinds(vnode, maxW, maxH, axis, measureNode); break; @@ -298,6 +303,9 @@ function layoutNode( case "column": { return layoutStackKinds(vnode, x, y, rectW, rectH, axis, measureNode, layoutNode); } + case "grid": { + return layoutGridKinds(vnode, x, y, rectW, rectH, axis, measureNode, layoutNode); + } case "box": { return layoutBoxKinds(vnode, x, y, rectW, rectH, axis, layoutNode); } diff --git a/packages/core/src/layout/kinds/grid.ts b/packages/core/src/layout/kinds/grid.ts new file mode 100644 index 00000000..64899564 --- /dev/null +++ b/packages/core/src/layout/kinds/grid.ts @@ -0,0 +1,521 @@ +import type { VNode } from "../../index.js"; +import { clampNonNegative } from "../engine/bounds.js"; +import { type FlexItem, distributeFlex } from "../engine/flex.js"; +import { releaseArray } from "../engine/pool.js"; +import { ok } from "../engine/result.js"; +import type { LayoutTree } from "../engine/types.js"; +import type { Axis, Size } from "../types.js"; +import type { LayoutResult } from "../validateProps.js"; + +type MeasureNodeFn = (vnode: VNode, maxW: number, maxH: number, axis: Axis) => LayoutResult; + +type LayoutNodeFn = ( + vnode: VNode, + x: number, + y: number, + maxW: number, + maxH: number, + axis: Axis, + forcedW?: number | null, + forcedH?: number | null, +) => LayoutResult; + +type GridTrack = + | Readonly<{ kind: "fixed"; size: number }> + | Readonly<{ kind: "auto" }> + | Readonly<{ kind: "count" }> + | Readonly<{ kind: "fr"; flex: number }>; + +type ParsedGridProps = Readonly<{ + columnTracks: readonly GridTrack[]; + rowTracks: readonly GridTrack[] | null; + explicitRows: boolean; + rowGap: number; + columnGap: number; +}>; + +type PlacedChild = Readonly<{ child: VNode; column: number; row: number }>; + +type MeasuredPlacedChild = Readonly<{ + child: VNode; + column: number; + row: number; + size: Size; +}>; + +type PlacementPlan = Readonly<{ + placed: readonly PlacedChild[]; + rowCount: number; +}>; + +type GridTrackNaturals = Readonly<{ + columns: readonly number[]; + rows: readonly number[]; +}>; + +const I32_MAX = 2147483647; + +const AUTO_TRACK: GridTrack = Object.freeze({ kind: "auto" }); + +function invalid(detail: string): LayoutResult { + return { ok: false, fatal: { code: "ZRUI_INVALID_PROPS", detail } }; +} + +function parseI32FloorNonNegative(v: unknown): number | null { + if (typeof v !== "number" || !Number.isFinite(v)) return null; + const n = Math.floor(v); + if (n < 0 || n > I32_MAX) return null; + return n; +} + +function parseGapProp(name: "gap" | "rowGap" | "columnGap", raw: unknown): LayoutResult { + const parsed = parseI32FloorNonNegative(raw); + if (parsed === null) return invalid(`grid.${name} must be an int32 >= 0`); + return { ok: true, value: parsed }; +} + +function parseTrackToken(propName: "columns" | "rows", token: string): LayoutResult { + const raw = token.trim().toLowerCase(); + if (raw.length === 0) { + return invalid(`grid.${propName} contains an empty track token`); + } + + if (raw === "auto") { + return { ok: true, value: AUTO_TRACK }; + } + + if (raw.endsWith("fr")) { + const flexRaw = raw.slice(0, -2); + const flex = flexRaw.length === 0 ? 1 : Number.parseFloat(flexRaw); + if (!Number.isFinite(flex) || flex <= 0) { + return invalid( + `grid.${propName} track token "${token}" must be "auto", "", "px", or "fr"`, + ); + } + return { ok: true, value: { kind: "fr", flex } }; + } + + const fixedMatch = /^(\d+(?:\.\d+)?)(px)?$/.exec(raw); + if (fixedMatch) { + const n = Number.parseFloat(fixedMatch[1] ?? ""); + if (!Number.isFinite(n) || n < 0) { + return invalid( + `grid.${propName} track token "${token}" must be "auto", "", "px", or "fr"`, + ); + } + const size = Math.floor(n); + if (size > I32_MAX) { + return invalid(`grid.${propName} track token "${token}" is out of int32 range`); + } + return { ok: true, value: { kind: "fixed", size } }; + } + + return invalid( + `grid.${propName} track token "${token}" must be "auto", "", "px", or "fr"`, + ); +} + +function parseTrackString( + propName: "columns" | "rows", + raw: string, +): LayoutResult { + const tokens = raw + .split(/[\s,]+/) + .map((t) => t.trim()) + .filter((t) => t.length > 0); + if (tokens.length === 0) { + return invalid(`grid.${propName} must be a non-empty track string`); + } + + const tracks: GridTrack[] = []; + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + if (!token) continue; + const trackRes = parseTrackToken(propName, token); + if (!trackRes.ok) return trackRes; + tracks.push(trackRes.value); + } + + if (tracks.length === 0) { + return invalid(`grid.${propName} must be a non-empty track string`); + } + + return { ok: true, value: tracks }; +} + +function createCountTracks(count: number): readonly GridTrack[] { + const out: GridTrack[] = []; + for (let i = 0; i < count; i++) { + out.push({ kind: "count" }); + } + return out; +} + +function createAutoTracks(count: number): readonly GridTrack[] { + const out: GridTrack[] = []; + for (let i = 0; i < count; i++) { + out.push(AUTO_TRACK); + } + return out; +} + +function parseColumns(raw: unknown): LayoutResult { + if (typeof raw === "number") { + const count = parseI32FloorNonNegative(raw); + if (count === null || count <= 0) { + return invalid("grid.columns must be a positive int32 or a non-empty track string"); + } + return { ok: true, value: createCountTracks(count) }; + } + + if (typeof raw === "string") { + return parseTrackString("columns", raw); + } + + return invalid("grid.columns must be a positive int32 or a non-empty track string"); +} + +function parseRows( + raw: unknown, +): LayoutResult> { + if (raw === undefined) { + return { ok: true, value: { tracks: null, explicit: false } }; + } + + if (typeof raw === "number") { + const count = parseI32FloorNonNegative(raw); + if (count === null) { + return invalid("grid.rows must be an int32 >= 0 or a non-empty track string"); + } + return { ok: true, value: { tracks: createAutoTracks(count), explicit: true } }; + } + + if (typeof raw === "string") { + const parsed = parseTrackString("rows", raw); + if (!parsed.ok) return parsed; + return { ok: true, value: { tracks: parsed.value, explicit: true } }; + } + + return invalid("grid.rows must be an int32 >= 0 or a non-empty track string"); +} + +function parseGridProps(rawProps: unknown): LayoutResult { + const props = (rawProps ?? {}) as { + columns?: unknown; + rows?: unknown; + gap?: unknown; + rowGap?: unknown; + columnGap?: unknown; + }; + + const columnsRes = parseColumns(props.columns); + if (!columnsRes.ok) return columnsRes; + + const rowsRes = parseRows(props.rows); + if (!rowsRes.ok) return rowsRes; + + const gapRes = props.gap === undefined ? ok(0) : parseGapProp("gap", props.gap); + if (!gapRes.ok) return gapRes; + + const rowGapRes = + props.rowGap === undefined ? ok(gapRes.value) : parseGapProp("rowGap", props.rowGap); + if (!rowGapRes.ok) return rowGapRes; + + const columnGapRes = + props.columnGap === undefined ? ok(gapRes.value) : parseGapProp("columnGap", props.columnGap); + if (!columnGapRes.ok) return columnGapRes; + + return { + ok: true, + value: { + columnTracks: columnsRes.value, + rowTracks: rowsRes.value.tracks, + explicitRows: rowsRes.value.explicit, + rowGap: rowGapRes.value, + columnGap: columnGapRes.value, + }, + }; +} + +function buildPlacementPlan( + children: readonly (VNode | undefined)[], + columnCount: number, + explicitRows: boolean, + explicitRowCount: number, +): PlacementPlan { + const placed: PlacedChild[] = []; + const capacity = explicitRows ? columnCount * explicitRowCount : Number.POSITIVE_INFINITY; + + let slot = 0; + for (let i = 0; i < children.length; i++) { + const child = children[i]; + if (!child) continue; + if (slot >= capacity) break; + placed.push({ + child, + column: slot % columnCount, + row: Math.floor(slot / columnCount), + }); + slot++; + } + + const rowCount = explicitRows ? explicitRowCount : Math.ceil(slot / columnCount); + return { placed, rowCount }; +} + +function measurePlacedChildren( + placed: readonly PlacedChild[], + maxW: number, + maxH: number, + axis: Axis, + measureNode: MeasureNodeFn, +): LayoutResult { + const measured: MeasuredPlacedChild[] = []; + for (let i = 0; i < placed.length; i++) { + const p = placed[i]; + if (!p) continue; + const sizeRes = measureNode(p.child, maxW, maxH, axis); + if (!sizeRes.ok) return sizeRes; + measured.push({ child: p.child, column: p.column, row: p.row, size: sizeRes.value }); + } + return { ok: true, value: measured }; +} + +function resolveTrackNaturals( + columnTracks: readonly GridTrack[], + rowTracks: readonly GridTrack[], + measured: readonly MeasuredPlacedChild[], +): GridTrackNaturals { + const columnNaturals: number[] = new Array(columnTracks.length).fill(0); + const rowNaturals: number[] = new Array(rowTracks.length).fill(0); + + for (let i = 0; i < columnTracks.length; i++) { + const track = columnTracks[i]; + if (!track) continue; + if (track.kind === "fixed") { + columnNaturals[i] = track.size; + } + } + + for (let i = 0; i < rowTracks.length; i++) { + const track = rowTracks[i]; + if (!track) continue; + if (track.kind === "fixed") { + rowNaturals[i] = track.size; + } + } + + for (let i = 0; i < measured.length; i++) { + const item = measured[i]; + if (!item) continue; + + const colTrack = columnTracks[item.column]; + if (colTrack?.kind === "auto" || colTrack?.kind === "count") { + const cur = columnNaturals[item.column] ?? 0; + if (item.size.w > cur) { + columnNaturals[item.column] = item.size.w; + } + } + + const rowTrack = rowTracks[item.row]; + if (rowTrack?.kind === "auto") { + const cur = rowNaturals[item.row] ?? 0; + if (item.size.h > cur) { + rowNaturals[item.row] = item.size.h; + } + } + } + + return { columns: columnNaturals, rows: rowNaturals }; +} + +function sumTracksWithGap(sizes: readonly number[], gap: number): number { + let total = 0; + for (let i = 0; i < sizes.length; i++) { + total += sizes[i] ?? 0; + } + if (sizes.length > 1) { + total += gap * (sizes.length - 1); + } + return total; +} + +function resolveTrackSizes( + tracks: readonly GridTrack[], + autoNaturals: readonly number[], + available: number, + gap: number, +): number[] { + const out = new Array(tracks.length).fill(0); + if (tracks.length === 0) return out; + + const gapTotal = tracks.length <= 1 ? 0 : gap * (tracks.length - 1); + const availableForTracks = clampNonNegative(available - gapTotal); + + let remaining = availableForTracks; + const flexItems: FlexItem[] = []; + + for (let i = 0; i < tracks.length; i++) { + const track = tracks[i]; + if (!track) continue; + + if (track.kind === "fr" || track.kind === "count") { + const flex = track.kind === "fr" ? track.flex : 1; + flexItems.push({ index: i, flex, min: 0, max: availableForTracks }); + continue; + } + + const requested = track.kind === "fixed" ? track.size : (autoNaturals[i] ?? 0); + if (requested <= 0 || remaining <= 0) { + out[i] = 0; + continue; + } + + const size = Math.min(requested, remaining); + out[i] = size; + remaining = clampNonNegative(remaining - size); + } + + if (remaining > 0 && flexItems.length > 0) { + const alloc = distributeFlex(remaining, flexItems); + for (let i = 0; i < flexItems.length; i++) { + const item = flexItems[i]; + if (!item) continue; + out[item.index] = alloc[i] ?? 0; + } + releaseArray(alloc); + } + + return out; +} + +function computeTrackStarts(sizes: readonly number[], gap: number): number[] { + const starts = new Array(sizes.length).fill(0); + let cursor = 0; + for (let i = 0; i < sizes.length; i++) { + starts[i] = cursor; + const size = sizes[i] ?? 0; + cursor += size + (i < sizes.length - 1 ? gap : 0); + } + return starts; +} + +export function measureGridKinds( + vnode: VNode, + maxW: number, + maxH: number, + axis: Axis, + measureNode: MeasureNodeFn, +): LayoutResult { + switch (vnode.kind) { + case "grid": { + const parsedRes = parseGridProps(vnode.props); + if (!parsedRes.ok) return parsedRes; + const parsed = parsedRes.value; + + const explicitRowCount = parsed.rowTracks?.length ?? 0; + const placement = buildPlacementPlan( + vnode.children as readonly (VNode | undefined)[], + parsed.columnTracks.length, + parsed.explicitRows, + explicitRowCount, + ); + + const rowTracks = parsed.rowTracks ?? createAutoTracks(placement.rowCount); + const measuredRes = measurePlacedChildren(placement.placed, maxW, maxH, axis, measureNode); + if (!measuredRes.ok) return measuredRes; + + const naturals = resolveTrackNaturals(parsed.columnTracks, rowTracks, measuredRes.value); + + const naturalW = sumTracksWithGap(naturals.columns, parsed.columnGap); + const naturalH = sumTracksWithGap(naturals.rows, parsed.rowGap); + + return ok({ + w: clampNonNegative(Math.min(maxW, naturalW)), + h: clampNonNegative(Math.min(maxH, naturalH)), + }); + } + default: + return { + ok: false, + fatal: { code: "ZRUI_INVALID_PROPS", detail: "measureGridKinds: unexpected vnode kind" }, + }; + } +} + +export function layoutGridKinds( + vnode: VNode, + x: number, + y: number, + rectW: number, + rectH: number, + axis: Axis, + measureNode: MeasureNodeFn, + layoutNode: LayoutNodeFn, +): LayoutResult { + switch (vnode.kind) { + case "grid": { + const parsedRes = parseGridProps(vnode.props); + if (!parsedRes.ok) return parsedRes; + const parsed = parsedRes.value; + + const explicitRowCount = parsed.rowTracks?.length ?? 0; + const placement = buildPlacementPlan( + vnode.children as readonly (VNode | undefined)[], + parsed.columnTracks.length, + parsed.explicitRows, + explicitRowCount, + ); + + const rowTracks = parsed.rowTracks ?? createAutoTracks(placement.rowCount); + const measuredRes = measurePlacedChildren(placement.placed, rectW, rectH, axis, measureNode); + if (!measuredRes.ok) return measuredRes; + + const naturals = resolveTrackNaturals(parsed.columnTracks, rowTracks, measuredRes.value); + const columnSizes = resolveTrackSizes( + parsed.columnTracks, + naturals.columns, + rectW, + parsed.columnGap, + ); + const rowSizes = resolveTrackSizes(rowTracks, naturals.rows, rectH, parsed.rowGap); + const columnStarts = computeTrackStarts(columnSizes, parsed.columnGap); + const rowStarts = computeTrackStarts(rowSizes, parsed.rowGap); + + const children: LayoutTree[] = []; + for (let i = 0; i < placement.placed.length; i++) { + const placed = placement.placed[i]; + if (!placed) continue; + + const childX = x + (columnStarts[placed.column] ?? 0); + const childY = y + (rowStarts[placed.row] ?? 0); + const childW = columnSizes[placed.column] ?? 0; + const childH = rowSizes[placed.row] ?? 0; + + const childRes = layoutNode( + placed.child, + childX, + childY, + childW, + childH, + axis, + childW, + childH, + ); + if (!childRes.ok) return childRes; + children.push(childRes.value); + } + + return ok({ + vnode, + rect: { x, y, w: rectW, h: rectH }, + children: Object.freeze(children), + }); + } + default: + return { + ok: false, + fatal: { code: "ZRUI_INVALID_PROPS", detail: "layoutGridKinds: unexpected vnode kind" }, + }; + } +} diff --git a/packages/core/src/renderer/renderToDrawlist/renderTree.ts b/packages/core/src/renderer/renderToDrawlist/renderTree.ts index 923efc65..5f6e8f0f 100644 --- a/packages/core/src/renderer/renderToDrawlist/renderTree.ts +++ b/packages/core/src/renderer/renderToDrawlist/renderTree.ts @@ -101,7 +101,12 @@ export function renderTree( const currentTheme = themeByNode.get(node) ?? theme; let renderTheme = currentTheme; - if (vnode.kind === "row" || vnode.kind === "column" || vnode.kind === "box") { + if ( + vnode.kind === "row" || + vnode.kind === "column" || + vnode.kind === "grid" || + vnode.kind === "box" + ) { const props = vnode.props as { theme?: unknown }; renderTheme = mergeThemeOverride(currentTheme, props.theme); } @@ -114,6 +119,7 @@ export function renderTree( // Containers case "row": case "column": + case "grid": case "box": case "modal": case "focusZone": diff --git a/packages/core/src/renderer/renderToDrawlist/widgets/containers.ts b/packages/core/src/renderer/renderToDrawlist/widgets/containers.ts index b6f4f51c..dcd16cce 100644 --- a/packages/core/src/renderer/renderToDrawlist/widgets/containers.ts +++ b/packages/core/src/renderer/renderToDrawlist/widgets/containers.ts @@ -456,7 +456,8 @@ export function renderContainerWidget( switch (vnode.kind) { case "row": - case "column": { + case "column": + case "grid": { if (!isVisibleRect(rect)) break; const props = vnode.props as { pad?: unknown; @@ -506,7 +507,7 @@ export function renderContainerWidget( clipStack, childClip, damageRect, - vnode.kind, + vnode.kind === "row" || vnode.kind === "column" ? vnode.kind : undefined, ); break; } diff --git a/packages/core/src/runtime/commit.ts b/packages/core/src/runtime/commit.ts index b997a0e3..cf135b62 100644 --- a/packages/core/src/runtime/commit.ts +++ b/packages/core/src/runtime/commit.ts @@ -447,6 +447,7 @@ function commitChildrenForVNode(vnode: VNode): readonly VNode[] { vnode.kind === "box" || vnode.kind === "row" || vnode.kind === "column" || + vnode.kind === "grid" || vnode.kind === "focusZone" || vnode.kind === "focusTrap" || vnode.kind === "layers" || @@ -658,6 +659,7 @@ function commitNode( next.kind === "box" || next.kind === "row" || next.kind === "column" || + next.kind === "grid" || next.kind === "focusZone" || next.kind === "focusTrap" || next.kind === "layers" || @@ -680,6 +682,7 @@ function commitNode( vnode.kind === "box" || vnode.kind === "row" || vnode.kind === "column" || + vnode.kind === "grid" || vnode.kind === "focusZone" || vnode.kind === "focusTrap" || vnode.kind === "layers" || diff --git a/packages/core/src/widgets/__tests__/widgetRenderSmoke.test.ts b/packages/core/src/widgets/__tests__/widgetRenderSmoke.test.ts index d93a01d1..ed37221f 100644 --- a/packages/core/src/widgets/__tests__/widgetRenderSmoke.test.ts +++ b/packages/core/src/widgets/__tests__/widgetRenderSmoke.test.ts @@ -8,6 +8,36 @@ import { ui } from "../ui.js"; type TreeNode = Readonly<{ id: string; children: readonly TreeNode[] }>; +function u32(bytes: Uint8Array, off: number): number { + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return dv.getUint32(off, true); +} + +function parseInternedStrings(bytes: Uint8Array): readonly string[] { + const spanOffset = u32(bytes, 28); + const count = u32(bytes, 32); + const bytesOffset = u32(bytes, 36); + const bytesLen = u32(bytes, 40); + + if (count === 0) return Object.freeze([]); + + const tableEnd = bytesOffset + bytesLen; + assert.ok(tableEnd <= bytes.byteLength, "string table must be in-bounds"); + + const out: string[] = []; + const decoder = new TextDecoder(); + for (let i = 0; i < count; i++) { + const span = spanOffset + i * 8; + const off = u32(bytes, span); + const len = u32(bytes, span + 4); + const start = bytesOffset + off; + const end = start + len; + assert.ok(end <= tableEnd, "string span must be in-bounds"); + out.push(decoder.decode(bytes.subarray(start, end))); + } + return Object.freeze(out); +} + function renderBytes( vnode: VNode, viewport: Readonly<{ cols: number; rows: number }> = { cols: 100, rows: 40 }, @@ -77,6 +107,7 @@ describe("widget render smoke", () => { { name: "box", vnode: ui.box({}, [ui.text("inside")]) }, { name: "row", vnode: ui.row({ gap: 1 }, [ui.text("a"), ui.text("b")]) }, { name: "column", vnode: ui.column({ gap: 1 }, [ui.text("a"), ui.text("b")]) }, + { name: "grid", vnode: ui.grid({ columns: 2 }, ui.text("a"), ui.text("b")) }, { name: "spacer", vnode: ui.spacer({ size: 1 }) }, { name: "divider", vnode: ui.divider({ label: "div" }) }, { name: "icon", vnode: ui.icon("status.check") }, @@ -329,4 +360,11 @@ describe("widget render smoke", () => { assert.equal(bytes.byteLength > 0, true); }); } + + test("grid renders child text content", () => { + const bytes = renderBytes(ui.grid({ columns: 2 }, ui.text("cell-a"), ui.text("cell-b"))); + const strings = parseInternedStrings(bytes); + assert.equal(strings.includes("cell-a"), true); + assert.equal(strings.includes("cell-b"), true); + }); }); diff --git a/packages/core/src/widgets/types.ts b/packages/core/src/widgets/types.ts index 29d68920..a4184740 100644 --- a/packages/core/src/widgets/types.ts +++ b/packages/core/src/widgets/types.ts @@ -189,6 +189,14 @@ export type StackProps = Readonly< LayoutConstraints >; +export type GridProps = { + columns: number | string; + rows?: number | string; + gap?: number; + rowGap?: number; + columnGap?: number; +}; + /** Props for spacer element. size is in cells along stack axis. */ export type SpacerProps = Readonly<{ key?: string; @@ -1505,6 +1513,7 @@ export type VNode = | Readonly<{ kind: "box"; props: BoxProps; children: readonly VNode[] }> | Readonly<{ kind: "row"; props: StackProps; children: readonly VNode[] }> | Readonly<{ kind: "column"; props: StackProps; children: readonly VNode[] }> + | Readonly<{ kind: "grid"; props: GridProps; children: readonly VNode[] }> | Readonly<{ kind: "spacer"; props: SpacerProps }> | Readonly<{ kind: "divider"; props: DividerProps }> | Readonly<{ kind: "icon"; props: IconProps }> diff --git a/packages/core/src/widgets/ui.ts b/packages/core/src/widgets/ui.ts index b3149ca4..3906301b 100644 --- a/packages/core/src/widgets/ui.ts +++ b/packages/core/src/widgets/ui.ts @@ -30,6 +30,7 @@ import type { FocusTrapProps, FocusZoneProps, GaugeProps, + GridProps, IconProps, InputProps, KbdProps, @@ -107,6 +108,10 @@ function column(props: StackProps = {}, children: readonly UiChild[] = []): VNod return { kind: "column", props, children: filterChildren(children) }; } +function grid(props: GridProps, ...children: UiChild[]): VNode { + return { kind: "grid", props, children: filterChildren(children) }; +} + function vstack(gap: number, children: readonly UiChild[]): VNode; function vstack(children: readonly UiChild[]): VNode; function vstack(gapOrChildren: number | readonly UiChild[], children?: readonly UiChild[]): VNode { @@ -488,6 +493,7 @@ export const ui = { box, row, column, + grid, vstack, hstack, spacer,