Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -213,7 +213,7 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
submodules: false

- name: Setup Node
uses: actions/setup-node@v4
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
submodules: false

- name: Initialize CodeQL
uses: github/codeql-action/init@v3
Expand All @@ -38,7 +38,7 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
submodules: false

- name: Initialize CodeQL
uses: github/codeql-action/init@v3
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
submodules: false

- name: Setup Node
uses: actions/setup-node@v4
Expand Down
58 changes: 58 additions & 0 deletions docs/guide/layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
154 changes: 154 additions & 0 deletions packages/core/src/layout/__tests__/layout.aspect-ratio.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
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 });
});
});
Loading
Loading