Skip to content

Commit 33dadd6

Browse files
Merge pull request #63 from RtlZeroMemory/layout-engine-polish
Layout engine polish: sparse-child fix, exhaustive edge tests, invariants docs
2 parents 952ce70 + da88f3e commit 33dadd6

16 files changed

Lines changed: 2424 additions & 36 deletions

.github/workflows/ci.yml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ jobs:
1414
steps:
1515
- uses: actions/checkout@v4
1616
with:
17-
submodules: recursive
17+
submodules: false
1818
- name: Install tools
1919
run: sudo apt-get update && sudo apt-get install -y ripgrep
2020
- name: Run guardrails
@@ -32,7 +32,7 @@ jobs:
3232
- name: Checkout (with submodules)
3333
uses: actions/checkout@v4
3434
with:
35-
submodules: recursive
35+
submodules: false
3636

3737
- name: Setup Node
3838
uses: actions/setup-node@v4
@@ -120,7 +120,7 @@ jobs:
120120
- name: Checkout (with submodules)
121121
uses: actions/checkout@v4
122122
with:
123-
submodules: recursive
123+
submodules: false
124124

125125
- name: Setup Node
126126
uses: actions/setup-node@v4
@@ -164,7 +164,7 @@ jobs:
164164
- name: Checkout (with submodules)
165165
uses: actions/checkout@v4
166166
with:
167-
submodules: recursive
167+
submodules: false
168168

169169
- name: Setup Node
170170
uses: actions/setup-node@v4
@@ -213,7 +213,7 @@ jobs:
213213
steps:
214214
- uses: actions/checkout@v4
215215
with:
216-
submodules: recursive
216+
submodules: false
217217

218218
- name: Setup Node
219219
uses: actions/setup-node@v4

.github/workflows/codeql.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ jobs:
1919
steps:
2020
- uses: actions/checkout@v4
2121
with:
22-
submodules: recursive
22+
submodules: false
2323

2424
- name: Initialize CodeQL
2525
uses: github/codeql-action/init@v3
@@ -38,7 +38,7 @@ jobs:
3838
steps:
3939
- uses: actions/checkout@v4
4040
with:
41-
submodules: recursive
41+
submodules: false
4242

4343
- name: Initialize CodeQL
4444
uses: github/codeql-action/init@v3

.github/workflows/docs.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ jobs:
2121
steps:
2222
- uses: actions/checkout@v4
2323
with:
24-
submodules: recursive
24+
submodules: false
2525

2626
- name: Setup Node
2727
uses: actions/setup-node@v4

docs/guide/layout.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,64 @@ ui.row({ gap: 1 }, [
156156
]);
157157
```
158158

159+
## Layout Invariants
160+
161+
These behaviors are guaranteed by the current layout engine and validation pipeline.
162+
163+
### Coordinate system and int32 bounds
164+
165+
- Rects are in terminal cell units: `x`, `y`, `w`, `h` with origin at top-left.
166+
- `layout(node, x, y, maxW, maxH, axis)` requires `x/y` to be int32 and `maxW/maxH` to be int32 `>= 0`.
167+
- `measure(node, maxW, maxH, axis)` requires `maxW/maxH` to be int32 `>= 0`.
168+
- 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.
169+
- Computed leaf rects are validated as int32 cells.
170+
171+
### Non-negative dimension clamping
172+
173+
- Width/height never go negative; dimension math uses non-negative clamps after subtraction steps (margin, border, padding, remaining space).
174+
- Final node sizes are bounded by available `maxW/maxH` and clamped to `>= 0`.
175+
176+
### Two-phase measure -> layout behavior
177+
178+
- `measure(...)` computes size only; it does not assign positions.
179+
- `layout(...)` measures first, then places nodes using the measured/forced size.
180+
- 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).
181+
- If that trigger is absent, stacks use the greedy path (measure in child order and place directly).
182+
- Even when remaining space reaches zero, the subtree is still measured with zero constraints for deterministic validation.
183+
184+
### Flex distribution rules
185+
186+
- Only children with `flex > 0` participate in flex allocation.
187+
- Fixed-size and non-flex children consume space before flex allocation.
188+
- 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).
189+
- Per-child max constraints are enforced during distribution; leftover space is redistributed iteratively to still-active flex items.
190+
- No flex distribution occurs when remaining space is `0` or when effective total flex is `<= 0`.
191+
- Min constraints are a best-effort top-up after proportional allocation, only while space remains.
192+
193+
### Percentage resolution, flooring, and clamping
194+
195+
- Percentage constraints resolve with flooring: `floor(parentSize * percent / 100)`.
196+
- Percentages resolve against the parent size provided to constraint resolution for that axis (stack content bounds in stacks; box content bounds for boxed children).
197+
- Resolved percent values are then clamped by min/max constraints and by the currently available space.
198+
- Main-axis percentages in stacks trigger the constraint-pass path before final placement.
199+
200+
### Margin behavior and interactions
201+
202+
- Margin precedence is side -> axis -> all: `ml/mr/mt/mb` overrides `mx/my`, which overrides `m`.
203+
- Margins are outside the widget rect and affect both measured outer size and positioned offset.
204+
- Positive margins reserve outer space.
205+
- Negative margins are allowed (signed int32): they can move `x/y` negative and can expand computed rect size after subtraction.
206+
- Padding and borders are applied inside the margin-adjusted rect.
207+
208+
### Aspect ratio resolution order
209+
210+
- `width`/`height` are resolved first (number or percent; `"auto"` behaves as unspecified here).
211+
- If `aspectRatio > 0` and exactly one axis is resolved, the other is derived with flooring:
212+
- `height = floor(width / aspectRatio)`
213+
- `width = floor(height * aspectRatio)`
214+
- If both `width` and `height` are already resolved, `aspectRatio` does not override them.
215+
- After derivation, min/max constraints clamp the chosen size, then final size is capped by available bounds and non-negative clamping.
216+
159217
## Borders
160218

161219
`ui.box` can draw a border around its content:
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { assert, describe, test } from "@rezi-ui/testkit";
2+
import type { VNode } from "../../index.js";
3+
import type { LayoutTree } from "../layout.js";
4+
import { layout } from "../layout.js";
5+
import type { Axis } from "../types.js";
6+
7+
function mustLayout(node: VNode, maxW: number, maxH: number, axis: Axis = "column"): LayoutTree {
8+
const res = layout(node, 0, 0, maxW, maxH, axis);
9+
assert.equal(res.ok, true, "layout should succeed");
10+
if (!res.ok) {
11+
throw new Error("layout failed");
12+
}
13+
return res.value;
14+
}
15+
16+
describe("layout aspect-ratio (deterministic)", () => {
17+
test("width -> height derives via aspectRatio", () => {
18+
const node: VNode = {
19+
kind: "box",
20+
props: { border: "none", width: 8, aspectRatio: 2 },
21+
children: Object.freeze([]),
22+
};
23+
const out = mustLayout(node, 30, 20);
24+
assert.deepEqual(out.rect, { x: 0, y: 0, w: 8, h: 4 });
25+
});
26+
27+
test("height -> width derives via aspectRatio", () => {
28+
const node: VNode = {
29+
kind: "box",
30+
props: { border: "none", height: 6, aspectRatio: 2 },
31+
children: Object.freeze([]),
32+
};
33+
const out = mustLayout(node, 30, 20);
34+
assert.deepEqual(out.rect, { x: 0, y: 0, w: 12, h: 6 });
35+
});
36+
37+
test("both width and height take precedence over aspectRatio", () => {
38+
const node: VNode = {
39+
kind: "box",
40+
props: { border: "none", width: 10, height: 3, aspectRatio: 2 },
41+
children: Object.freeze([]),
42+
};
43+
const out = mustLayout(node, 30, 20);
44+
assert.deepEqual(out.rect, { x: 0, y: 0, w: 10, h: 3 });
45+
});
46+
47+
test("derived height uses floor rounding", () => {
48+
const node: VNode = {
49+
kind: "box",
50+
props: { border: "none", width: 7, aspectRatio: 2 },
51+
children: Object.freeze([]),
52+
};
53+
const out = mustLayout(node, 30, 20);
54+
assert.deepEqual(out.rect, { x: 0, y: 0, w: 7, h: 3 });
55+
});
56+
57+
test("percent width with aspectRatio resolves against parent width", () => {
58+
const node: VNode = {
59+
kind: "box",
60+
props: { border: "none", width: "50%", aspectRatio: 2 },
61+
children: Object.freeze([]),
62+
};
63+
const out = mustLayout(node, 15, 20);
64+
assert.deepEqual(out.rect, { x: 0, y: 0, w: 7, h: 3 });
65+
});
66+
67+
test("percent height with aspectRatio resolves against parent height", () => {
68+
const node: VNode = {
69+
kind: "box",
70+
props: { border: "none", height: "25%", aspectRatio: 2 },
71+
children: Object.freeze([]),
72+
};
73+
const out = mustLayout(node, 20, 13);
74+
assert.deepEqual(out.rect, { x: 0, y: 0, w: 6, h: 3 });
75+
});
76+
77+
test("minHeight clamp applies after width -> height derivation", () => {
78+
const node: VNode = {
79+
kind: "box",
80+
props: { border: "none", width: 8, aspectRatio: 2, minHeight: 5 },
81+
children: Object.freeze([]),
82+
};
83+
const out = mustLayout(node, 30, 20);
84+
assert.deepEqual(out.rect, { x: 0, y: 0, w: 8, h: 5 });
85+
});
86+
87+
test("maxHeight clamp applies after width -> height derivation", () => {
88+
const node: VNode = {
89+
kind: "box",
90+
props: { border: "none", width: 20, aspectRatio: 2, maxHeight: 6 },
91+
children: Object.freeze([]),
92+
};
93+
const out = mustLayout(node, 30, 20);
94+
assert.deepEqual(out.rect, { x: 0, y: 0, w: 20, h: 6 });
95+
});
96+
97+
test("minWidth clamp applies after height -> width derivation", () => {
98+
const node: VNode = {
99+
kind: "box",
100+
props: { border: "none", height: 4, aspectRatio: 2, minWidth: 10 },
101+
children: Object.freeze([]),
102+
};
103+
const out = mustLayout(node, 30, 20);
104+
assert.deepEqual(out.rect, { x: 0, y: 0, w: 10, h: 4 });
105+
});
106+
107+
test("maxWidth clamp applies after height -> width derivation", () => {
108+
const node: VNode = {
109+
kind: "box",
110+
props: { border: "none", height: 10, aspectRatio: 2, maxWidth: 15 },
111+
children: Object.freeze([]),
112+
};
113+
const out = mustLayout(node, 30, 20);
114+
assert.deepEqual(out.rect, { x: 0, y: 0, w: 15, h: 10 });
115+
});
116+
117+
test("row flex context respects aspect-derived fixed width", () => {
118+
const row: VNode = {
119+
kind: "row",
120+
props: {},
121+
children: Object.freeze([
122+
{
123+
kind: "box",
124+
props: { border: "none", height: 4, aspectRatio: 2 },
125+
children: Object.freeze([]),
126+
},
127+
{ kind: "box", props: { border: "none", flex: 1 }, children: Object.freeze([]) },
128+
]),
129+
};
130+
const out = mustLayout(row, 20, 6, "row");
131+
assert.deepEqual(out.rect, { x: 0, y: 0, w: 20, h: 4 });
132+
assert.deepEqual(out.children[0]?.rect, { x: 0, y: 0, w: 8, h: 4 });
133+
assert.deepEqual(out.children[1]?.rect, { x: 8, y: 0, w: 12, h: 0 });
134+
});
135+
136+
test("column flex context respects aspect-derived fixed height", () => {
137+
const column: VNode = {
138+
kind: "column",
139+
props: {},
140+
children: Object.freeze([
141+
{
142+
kind: "box",
143+
props: { border: "none", width: 6, aspectRatio: 2 },
144+
children: Object.freeze([]),
145+
},
146+
{ kind: "box", props: { border: "none", flex: 1 }, children: Object.freeze([]) },
147+
]),
148+
};
149+
const out = mustLayout(column, 8, 12, "column");
150+
assert.deepEqual(out.rect, { x: 0, y: 0, w: 6, h: 12 });
151+
assert.deepEqual(out.children[0]?.rect, { x: 0, y: 0, w: 6, h: 3 });
152+
assert.deepEqual(out.children[1]?.rect, { x: 0, y: 3, w: 0, h: 9 });
153+
});
154+
});

0 commit comments

Comments
 (0)