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
52 changes: 51 additions & 1 deletion docs/guide/layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,12 @@ Container widgets accept spacing props (values are **cells**, or named keys like
### Padding (inside)

- `p` (all), `px`/`py`, `pt`/`pr`/`pb`/`pl`
- Must be non-negative (`>= 0`) when provided as numbers.

### Margin (outside)

- `m` (all), `mx`/`my`
- `m` (all), `mx`/`my`, `mt`/`mr`/`mb`/`ml`
- May be negative (signed int32), which allows intentional overlap.

Example:

Expand All @@ -83,6 +85,38 @@ Notes:

- Padding reduces the available content area for children.
- Margin affects how the widget is positioned inside its parent stack.
- Negative margins can move a child outside the parent's origin and can cause overlap.

### Negative margin examples

Example 1: overlap siblings in a row

```typescript
import { ui } from "@rezi-ui/core";

ui.row({}, [
ui.box({ border: "none", width: 12, p: 1 }, [ui.text("Base")]),
ui.box({ border: "rounded", width: 10, ml: -6, p: 1 }, [ui.text("Overlay")]),
]);
```

Example 2: pull content upward in a column

```typescript
import { ui } from "@rezi-ui/core";

ui.column({ gap: 1 }, [
ui.box({ border: "single", p: 1 }, [ui.text("Header block")]),
ui.box({ border: "rounded", mt: -1, p: 1 }, [ui.text("Raised panel")]),
]);
```

Rules summary:

- `m/mx/my/mt/mr/mb/ml` accept signed int32 numbers (and spacing keys).
- `p/px/py/pt/pr/pb/pl`, legacy `pad`, and `gap` must stay non-negative.
- Computed `w/h` are always clamped to non-negative values.
- Computed `x/y` can be negative when margins pull widgets outward.

## Alignment

Expand Down Expand Up @@ -179,6 +213,22 @@ ui.box({ width: 20, border: "single", p: 1 }, [
]);
```

## Overlap hit-testing

When widgets overlap, input routing is deterministic:

- Layers: higher `zIndex` wins.
- Layers with equal `zIndex`: later registration wins.
- Regular layout tree (no layer distinction): the last focusable widget in depth-first preorder tree order wins.
- This means later siblings win ties.

## Gotchas

- Negative margins can make `x/y` negative; this is expected and supported.
- Large negative margins can significantly increase overlap. Keep fixtures for critical layouts.
- `pad` and `gap` do not allow negatives; use margins when you need pull/overlap effects.
- In overlap regions, tie-breaks follow deterministic order, not visual styling alone.

## Related

- [Concepts](concepts.md) - How VNodes and reconciliation work
Expand Down
32 changes: 32 additions & 0 deletions packages/core/src/layout/__tests__/constraints.golden.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ function mustLayout(node: VNode, maxW: number, maxH: number): LayoutTree {
return res.value;
}

function assertNonNegativeRectTree(node: LayoutTree): void {
assert.ok(node.rect.w >= 0, `width must be non-negative, got ${node.rect.w}`);
assert.ok(node.rect.h >= 0, `height must be non-negative, got ${node.rect.h}`);
for (const child of node.children) assertNonNegativeRectTree(child);
}

describe("constraints (deterministic) - golden cases", () => {
test("flex:1 + flex:2 in row of width 90 => 30 + 60", () => {
const tree = ui.row({}, [
Expand Down Expand Up @@ -125,4 +131,30 @@ describe("constraints (deterministic) - golden cases", () => {
const out = mustLayout(tree, 10, 10);
assert.deepEqual(out.rect, { x: 1, y: 1, w: 4, h: 2 });
});

test("aggressive negative row margins preserve non-negative computed sizes", () => {
const tree = ui.row({}, [
ui.box({ border: "none", width: 1, height: 1, ml: -3, mr: -3, mt: -2, mb: -2 }, []),
ui.box({ border: "none", width: 2, height: 1 }, []),
]);

const out = mustLayout(tree, 5, 2);
assertNonNegativeRectTree(out);
assert.deepEqual(out.rect, { x: 0, y: 0, w: 2, h: 1 });
assert.deepEqual(out.children[0]?.rect, { x: -3, y: -2, w: 6, h: 4 });
assert.deepEqual(out.children[1]?.rect, { x: 0, y: 0, w: 2, h: 1 });
});

test("aggressive negative column margins preserve non-negative computed sizes", () => {
const tree = ui.column({}, [
ui.box({ border: "none", width: 2, height: 1, ml: -2, mr: -2, mt: -2, mb: -2 }, []),
ui.box({ border: "none", width: 1, height: 1 }, []),
]);

const out = mustLayout(tree, 4, 3);
assertNonNegativeRectTree(out);
assert.deepEqual(out.rect, { x: 0, y: 0, w: 1, h: 1 });
assert.deepEqual(out.children[0]?.rect, { x: -2, y: -2, w: 4, h: 4 });
assert.deepEqual(out.children[1]?.rect, { x: 0, y: 0, w: 1, h: 1 });
});
});
3 changes: 3 additions & 0 deletions packages/core/src/layout/__tests__/layout.golden.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ function assertRectsMatch(
lNode: LayoutTree,
rects: Record<string, FixtureRect>,
): void {
assert.ok(lNode.rect.w >= 0, `width must be non-negative for ref=${fNode.ref}`);
assert.ok(lNode.rect.h >= 0, `height must be non-negative for ref=${fNode.ref}`);

const expected = rects[fNode.ref];
assert.ok(expected !== undefined, `missing expected rect for ref=${fNode.ref}`);
assert.deepEqual(lNode.rect, expected, `rect mismatch for ref=${fNode.ref}`);
Expand Down
43 changes: 43 additions & 0 deletions packages/core/src/layout/__tests__/spacing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,47 @@ describe("spacing", () => {
assert.equal(res.value.px, 3);
assert.equal(res.value.my, 1);
});

test("validateBoxProps: accepts signed int32 margins", () => {
const res = validateBoxProps({
m: -1,
mx: -2,
my: -3,
mt: -4,
mr: -5,
mb: -6,
ml: -7,
});
assert.equal(res.ok, true);
if (!res.ok) throw new Error("expected ok");
assert.equal(res.value.m, -1);
assert.equal(res.value.mx, -2);
assert.equal(res.value.my, -3);
assert.equal(res.value.mt, -4);
assert.equal(res.value.mr, -5);
assert.equal(res.value.mb, -6);
assert.equal(res.value.ml, -7);
});

test("validateBoxProps: rejects negative padding with deterministic detail", () => {
const pRes = validateBoxProps({ p: -1 });
assert.equal(pRes.ok, false);
if (pRes.ok) throw new Error("expected fatal");
assert.equal(pRes.fatal.code, "ZRUI_INVALID_PROPS");
assert.equal(pRes.fatal.detail, "box.p must be an int32 >= 0");

const padRes = validateBoxProps({ pad: -1 });
assert.equal(padRes.ok, false);
if (padRes.ok) throw new Error("expected fatal");
assert.equal(padRes.fatal.code, "ZRUI_INVALID_PROPS");
assert.equal(padRes.fatal.detail, "box.pad must be an int32 >= 0");
});

test("validateStackProps: rejects negative gap with deterministic detail", () => {
const res = validateStackProps("row", { gap: -1 });
assert.equal(res.ok, false);
if (res.ok) throw new Error("expected fatal");
assert.equal(res.fatal.code, "ZRUI_INVALID_PROPS");
assert.equal(res.fatal.detail, "row.gap must be an int32 >= 0");
});
});
10 changes: 7 additions & 3 deletions packages/core/src/layout/hitTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@
* position. Used for mouse-based focus changes and click routing.
*
* Tie-break rule: When multiple focusable widgets overlap at a point, the
* LAST one in depth-first preorder traversal wins (typically the "topmost"
* visually, though stacking is logical not visual in terminal UI).
* LAST node in depth-first preorder traversal wins.
*
* Explicit direction:
* - Children are traversed left-to-right (tree order).
* - Later tree-order nodes override earlier ones.
* - Among siblings, later siblings win ties.
*
* @see docs/guide/layout.md
*/
Expand Down Expand Up @@ -70,7 +74,7 @@ function isFocusable(v: VNode): string | null {
* Hit test focusable widgets (enabled interactive ids).
*
* Tie-break is deterministic: if multiple focusable widgets contain the point,
* the winner is the LAST focusable widget in depth-first preorder traversal order.
* the winner is the LAST focusable widget in depth-first preorder tree order.
*/
export function hitTestFocusable(
tree: VNode,
Expand Down
63 changes: 45 additions & 18 deletions packages/core/src/layout/validateProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
*
* Validation rules:
* - Numeric props must be int32 >= 0 (pad, gap, size)
* - Spacing props accept int32 >= 0 OR spacing keys ("sm", "md", etc.)
* - Padding props accept int32 >= 0 OR spacing keys ("sm", "md", etc.)
* - Margin props accept signed int32 OR spacing keys ("sm", "md", etc.)
* - String props must be non-empty where required (id)
* - Enum props must be valid values (align, border)
* - Boolean props default to false if undefined
Expand Down Expand Up @@ -157,6 +158,7 @@ function invalid(detail: string): LayoutResult<never> {
return { ok: false, fatal: { code: "ZRUI_INVALID_PROPS", detail } };
}

const I32_MIN = -2147483648;
const I32_MAX = 2147483647;

function requireIntNonNegative(
Expand All @@ -172,6 +174,19 @@ function requireIntNonNegative(
return { ok: true, value: value as number };
}

function requireIntSigned(
kind: string,
name: string,
v: unknown,
def: number,
): LayoutResult<number> {
const value = v === undefined ? def : v;
if (typeof value !== "number" || !Number.isInteger(value) || value < I32_MIN || value > I32_MAX) {
return invalid(`${kind}.${name} must be an int32`);
}
return { ok: true, value: value as number };
}

function requireSpacingIntNonNegative(
kind: string,
name: string,
Expand All @@ -190,6 +205,24 @@ function requireSpacingIntNonNegative(
return requireIntNonNegative(kind, name, value, def);
}

function requireSpacingIntSigned(
kind: string,
name: string,
v: unknown,
def: number,
): LayoutResult<number> {
const value = v === undefined ? def : v;
if (typeof value === "string") {
if (!isSpacingKey(value)) {
return invalid(
`${kind}.${name} must be an int32 or a spacing key ("none" | "xs" | "sm" | "md" | "lg" | "xl" | "2xl")`,
);
}
return { ok: true, value: SPACING_SCALE[value] };
}
return requireIntSigned(kind, name, value, def);
}

function requireOptionalIntNonNegative(
kind: string,
name: string,
Expand Down Expand Up @@ -280,32 +313,26 @@ function validateSpacingProps(
kind: string,
p: Record<string, unknown>,
): LayoutResult<ValidatedSpacingProps> {
const keys = [
"p",
"px",
"py",
"pt",
"pb",
"pl",
"pr",
"m",
"mx",
"my",
"mt",
"mr",
"mb",
"ml",
] as const;
const paddingKeys = ["p", "px", "py", "pt", "pb", "pl", "pr"] as const;
const marginKeys = ["m", "mx", "my", "mt", "mr", "mb", "ml"] as const;
const out: Record<string, number> = {};

for (const k of keys) {
for (const k of paddingKeys) {
const v = p[k];
if (v === undefined) continue;
const r = requireSpacingIntNonNegative(kind, k, v, 0);
if (!r.ok) return r;
out[k] = r.value;
}

for (const k of marginKeys) {
const v = p[k];
if (v === undefined) continue;
const r = requireSpacingIntSigned(kind, k, v, 0);
if (!r.ok) return r;
out[k] = r.value;
}

return { ok: true, value: out as unknown as ValidatedSpacingProps };
}

Expand Down
Loading
Loading