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
36 changes: 25 additions & 11 deletions docs/guide/layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -339,22 +339,36 @@ ui.row({ gap: 1 }, [
]);
```

## Overflow
## Overflow and scroll

Rezi clips rendering to each widget’s allocated rect. Overflow is handled per-widget:
For `row`, `column`, and `box`, `overflow` supports:

- `ui.text` supports `textOverflow: "clip" | "ellipsis" | "middle"` (and `maxWidth`)
- Containers clip their children to the padded/bordered content area
- `"visible"` (default)
- `"hidden"`
- `"scroll"`

Example: ellipsis truncation
Layout overflow metadata uses these fields (all non-negative cells):

```typescript
import { ui } from "@rezi-ui/core";
- `scrollX`, `scrollY` - active scroll offset (clamped to available range)
- `contentWidth`, `contentHeight` - measured content footprint
- `viewportWidth`, `viewportHeight` - scrollable viewport size

ui.box({ width: 20, border: "single", p: 1 }, [
ui.text("This is a long line that will truncate", { textOverflow: "ellipsis" }),
]);
```
Clipping semantics (current behavior):

- Rendering clips children to the container content rect (after border/padding).
- `"scroll"` additionally clips to a reduced scroll viewport and applies `scrollX`/`scrollY` offsets.
- Hit-testing keeps legacy `"visible"` overlap behavior (container layout-rect clip), while `"hidden"` and `"scroll"` use stricter content/viewport clipping.

Scrollbar occupancy in `"scroll"` mode:

- Vertical scrollbar consumes 1 column on the right.
- Horizontal scrollbar consumes 1 row at the bottom.
- When both are shown, they share the bottom-right corner cell.

Collection migration behavior (current):

- `VirtualList`, `Table`, and `Tree` still maintain runtime scroll state.
- Their effective runtime scroll values are mirrored into layout metadata (`scrollX`, `scrollY`, `contentWidth`, `contentHeight`, `viewportWidth`, `viewportHeight`) during rendering.

## Overlap hit-testing

Expand Down
114 changes: 114 additions & 0 deletions packages/core/src/layout/__tests__/layout.edgecases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,120 @@ describe("layout edge cases", () => {
);
});

test("row overflow metadata clamps scrollX and shifts children", () => {
const tree: VNode = {
kind: "row",
props: { width: 5, overflow: "scroll", scrollX: 99 },
children: Object.freeze<readonly VNode[]>([
{
kind: "box",
props: { border: "none", mr: -4 },
children: Object.freeze<readonly VNode[]>([
{ kind: "text", text: "123456789", props: {} },
]),
},
]),
};

const laidOut = mustLayout(tree, 5, 2);
assert.deepEqual(laidOut.meta, {
scrollX: 4,
scrollY: 0,
contentWidth: 9,
contentHeight: 1,
viewportWidth: 5,
viewportHeight: 1,
});
assert.deepEqual(laidOut.children[0]?.rect, { x: -4, y: 0, w: 9, h: 1 });
});

test("column overflow metadata clamps scrollY and shifts children", () => {
const tree: VNode = {
kind: "column",
props: { height: 3, overflow: "scroll", scrollY: 99 },
children: Object.freeze<readonly VNode[]>([
{
kind: "box",
props: { border: "none", mb: -1 },
children: Object.freeze<readonly VNode[]>([
{ kind: "text", text: "a", props: {} },
{ kind: "text", text: "b", props: {} },
{ kind: "text", text: "c", props: {} },
{ kind: "text", text: "d", props: {} },
]),
},
]),
};

const laidOut = mustLayout(tree, 6, 3);
assert.deepEqual(laidOut.meta, {
scrollX: 0,
scrollY: 1,
contentWidth: 1,
contentHeight: 4,
viewportWidth: 1,
viewportHeight: 3,
});
assert.deepEqual(laidOut.children[0]?.rect, { x: 0, y: -1, w: 1, h: 4 });
});

test("box overflow metadata clamps both axes and shifts children", () => {
const tree: VNode = {
kind: "box",
props: { border: "none", width: 5, height: 3, overflow: "scroll", scrollX: 99, scrollY: 99 },
children: Object.freeze<readonly VNode[]>([
{
kind: "box",
props: { border: "none", mr: -4, mb: -1 },
children: Object.freeze<readonly VNode[]>([
{ kind: "text", text: "123456789", props: {} },
{ kind: "text", text: "line2", props: {} },
{ kind: "text", text: "line3", props: {} },
{ kind: "text", text: "line4", props: {} },
]),
},
]),
};

const laidOut = mustLayout(tree, 5, 3);
assert.deepEqual(laidOut.meta, {
scrollX: 4,
scrollY: 1,
contentWidth: 9,
contentHeight: 4,
viewportWidth: 5,
viewportHeight: 3,
});
assert.deepEqual(laidOut.children[0]?.rect, { x: -4, y: -1, w: 9, h: 4 });
});

test("scroll clamp uses content-origin extent when children start after origin", () => {
const tree: VNode = {
kind: "row",
props: { width: 5, overflow: "scroll", scrollX: 99 },
children: Object.freeze<readonly VNode[]>([
{
kind: "box",
props: { border: "none", ml: 2, mr: -8 },
children: Object.freeze<readonly VNode[]>([
{ kind: "text", text: "123456789", props: {} },
]),
},
]),
};

const laidOut = mustLayout(tree, 5, 2);
assert.deepEqual(laidOut.meta, {
scrollX: 6,
scrollY: 0,
contentWidth: 11,
contentHeight: 1,
viewportWidth: 5,
viewportHeight: 1,
});
assert.deepEqual(laidOut.children[0]?.rect, { x: -4, y: 0, w: 9, h: 1 });
});

test("button fractional px is handled deterministically", () => {
const button: VNode = {
kind: "button",
Expand Down
Loading
Loading