diff --git a/packages/runtime-tests/integration/layout/overflow.test.tsx b/packages/runtime-tests/integration/layout/overflow.test.tsx
index 845e51e..185d52a 100644
--- a/packages/runtime-tests/integration/layout/overflow.test.tsx
+++ b/packages/runtime-tests/integration/layout/overflow.test.tsx
@@ -3,35 +3,414 @@ import { expect, test } from "vite-plus/test";
import { render } from "@vue-tui/testing";
import { Box, Text } from "@vue-tui/runtime";
-// overflowX tests — directional overflow not yet supported
-test.todo("overflowX - single text node in a box inside overflow container");
-test.todo("overflowX - single text node inside overflow container with border");
-test.todo("overflowX - single text node in a box with border inside overflow container");
-test.todo("overflowX - multiple text nodes in a box inside overflow container");
-test.todo("overflowX - multiple text nodes in a box inside overflow container with border");
-test.todo("overflowX - multiple text nodes in a box with border inside overflow container");
-test.todo("overflowX - multiple boxes inside overflow container");
-test.todo("overflowX - multiple boxes inside overflow container with border");
-test.todo("overflowX - box before left edge of overflow container");
-test.todo("overflowX - box before left edge of overflow container with border");
-test.todo("overflowX - box intersecting with left edge of overflow container");
-test.todo("overflowX - box intersecting with left edge of overflow container with border");
-test.todo("overflowX - box after right edge of overflow container");
-test.todo("overflowX - box intersecting with right edge of overflow container");
+/** Build a round-border box string like boxen(text, { borderStyle: "round" }) */
+function box(text: string): string {
+ const lines = text.split("\n");
+ const width = Math.max(...lines.map((l) => l.length));
+ const top = `╭${"─".repeat(width)}╮`;
+ const bottom = `╰${"─".repeat(width)}╯`;
+ const middle = lines.map((l) => `│${l.padEnd(width)}│`).join("\n");
+ return `${top}\n${middle}\n${bottom}`;
+}
-// overflowY tests — directional overflow not yet supported
-test.todo("overflowY - single text node inside overflow container");
-test.todo("overflowY - single text node inside overflow container with border");
-test.todo("overflowY - multiple boxes inside overflow container");
-test.todo("overflowY - multiple boxes inside overflow container with border");
-test.todo("overflowY - box above top edge of overflow container");
-test.todo("overflowY - box above top edge of overflow container with border");
-test.todo("overflowY - box intersecting with top edge of overflow container");
-test.todo("overflowY - box intersecting with top edge of overflow container with border");
-test.todo("overflowY - box below bottom edge of overflow container");
-test.todo("overflowY - box below bottom edge of overflow container with border");
-test.todo("overflowY - box intersecting with bottom edge of overflow container");
-test.todo("overflowY - box intersecting with bottom edge of overflow container with border");
+/** Clip each line to at most `columns` visible characters and trim trailing whitespace */
+function clipX(text: string, columns: number): string {
+ return text
+ .split("\n")
+ .map((line) => line.slice(0, columns).trim())
+ .join("\n");
+}
+
+// --- overflowX tests ---
+
+test("overflowX - single text node in a box inside overflow container", async () => {
+ const { lastFrame } = await render(
+ defineComponent(() => () => (
+
+
+ Hello World
+
+
+ )),
+ { columns: 100 },
+ );
+ expect(lastFrame({ trimLines: true })).toBe("Hello");
+});
+
+test("overflowX - single text node inside overflow container with border", async () => {
+ const { lastFrame } = await render(
+ defineComponent(() => () => (
+
+
+ Hello World
+
+
+ )),
+ { columns: 100 },
+ );
+ expect(lastFrame({ trimLines: true })).toBe(box("Hell"));
+});
+
+test("overflowX - single text node in a box with border inside overflow container", async () => {
+ const { lastFrame } = await render(
+ defineComponent(() => () => (
+
+
+ Hello World
+
+
+ )),
+ { columns: 100 },
+ );
+ expect(lastFrame({ trimLines: true })).toBe(clipX(box("Hello"), 6));
+});
+
+test("overflowX - multiple text nodes in a box inside overflow container", async () => {
+ const { lastFrame } = await render(
+ defineComponent(() => () => (
+
+
+ Hello
+ World
+
+
+ )),
+ { columns: 100 },
+ );
+ expect(lastFrame({ trimLines: true })).toBe("Hello");
+});
+
+test("overflowX - multiple text nodes in a box inside overflow container with border", async () => {
+ const { lastFrame } = await render(
+ defineComponent(() => () => (
+
+
+ Hello
+ World
+
+
+ )),
+ { columns: 100 },
+ );
+ expect(lastFrame({ trimLines: true })).toBe(box("Hello "));
+});
+
+test("overflowX - multiple text nodes in a box with border inside overflow container", async () => {
+ const { lastFrame } = await render(
+ defineComponent(() => () => (
+
+
+ Hello
+ World
+
+
+ )),
+ { columns: 100 },
+ );
+ expect(lastFrame({ trimLines: true })).toBe(clipX(box("HelloWo\n"), 8));
+});
+
+test("overflowX - multiple boxes inside overflow container", async () => {
+ const { lastFrame } = await render(
+ defineComponent(() => () => (
+
+
+ Hello
+
+
+ World
+
+
+ )),
+ { columns: 100 },
+ );
+ expect(lastFrame({ trimLines: true })).toBe("Hello");
+});
+
+test("overflowX - multiple boxes inside overflow container with border", async () => {
+ const { lastFrame } = await render(
+ defineComponent(() => () => (
+
+
+ Hello
+
+
+ World
+
+
+ )),
+ { columns: 100 },
+ );
+ expect(lastFrame({ trimLines: true })).toBe(box("Hello "));
+});
+
+test("overflowX - box before left edge of overflow container", async () => {
+ const { lastFrame } = await render(
+ defineComponent(() => () => (
+
+
+ Hello
+
+
+ )),
+ { columns: 100 },
+ );
+ expect(lastFrame({ trimLines: true })).toBe("");
+});
+
+test("overflowX - box before left edge of overflow container with border", async () => {
+ const { lastFrame } = await render(
+ defineComponent(() => () => (
+
+
+ Hello
+
+
+ )),
+ { columns: 100 },
+ );
+ expect(lastFrame({ trimLines: true })).toBe(box(" ".repeat(4)));
+});
+
+test("overflowX - box intersecting with left edge of overflow container", async () => {
+ const { lastFrame } = await render(
+ defineComponent(() => () => (
+
+
+ Hello World
+
+
+ )),
+ { columns: 100 },
+ );
+ expect(lastFrame({ trimLines: true })).toBe("lo Wor");
+});
+
+test("overflowX - box intersecting with left edge of overflow container with border", async () => {
+ const { lastFrame } = await render(
+ defineComponent(() => () => (
+
+
+ Hello World
+
+
+ )),
+ { columns: 100 },
+ );
+ expect(lastFrame({ trimLines: true })).toBe(box("lo Wor"));
+});
+
+test("overflowX - box after right edge of overflow container", async () => {
+ const { lastFrame } = await render(
+ defineComponent(() => () => (
+
+
+ Hello
+
+
+ )),
+ { columns: 100 },
+ );
+ expect(lastFrame({ trimLines: true })).toBe("");
+});
+
+test("overflowX - box intersecting with right edge of overflow container", async () => {
+ const { lastFrame } = await render(
+ defineComponent(() => () => (
+
+
+ Hello
+
+
+ )),
+ { columns: 100 },
+ );
+ expect(lastFrame({ trimLines: true })).toBe(" Hel");
+});
+
+// --- overflowY tests ---
+
+test("overflowY - single text node inside overflow container", async () => {
+ const { lastFrame } = await render(
+ defineComponent(() => () => (
+
+ Hello{"\n"}World
+
+ )),
+ { columns: 100 },
+ );
+ expect(lastFrame({ trimLines: true })).toBe("Hello");
+});
+
+test("overflowY - single text node inside overflow container with border", async () => {
+ const { lastFrame } = await render(
+ defineComponent(() => () => (
+
+ Hello{"\n"}World
+
+ )),
+ { columns: 100 },
+ );
+ expect(lastFrame({ trimLines: true })).toBe(box("Hello".padEnd(18, " ")));
+});
+
+test("overflowY - multiple boxes inside overflow container", async () => {
+ const { lastFrame } = await render(
+ defineComponent(() => () => (
+
+
+ Line #1
+
+
+ Line #2
+
+
+ Line #3
+
+
+ Line #4
+
+
+ )),
+ { columns: 100 },
+ );
+ expect(lastFrame({ trimLines: true })).toBe("Line #1\nLine #2");
+});
+
+test("overflowY - multiple boxes inside overflow container with border", async () => {
+ const { lastFrame } = await render(
+ defineComponent(() => () => (
+
+
+ Line #1
+
+
+ Line #2
+
+
+ Line #3
+
+
+ Line #4
+
+
+ )),
+ { columns: 100 },
+ );
+ expect(lastFrame({ trimLines: true })).toBe(box("Line #1\nLine #2"));
+});
+
+test("overflowY - box above top edge of overflow container", async () => {
+ const { lastFrame } = await render(
+ defineComponent(() => () => (
+
+
+ Hello{"\n"}World
+
+
+ )),
+ { columns: 100 },
+ );
+ expect(lastFrame({ trimLines: true })).toBe("");
+});
+
+test("overflowY - box above top edge of overflow container with border", async () => {
+ const { lastFrame } = await render(
+ defineComponent(() => () => (
+
+
+ Hello{"\n"}World
+
+
+ )),
+ { columns: 100 },
+ );
+ expect(lastFrame({ trimLines: true })).toBe(box(" ".repeat(5)));
+});
+
+test("overflowY - box intersecting with top edge of overflow container", async () => {
+ const { lastFrame } = await render(
+ defineComponent(() => () => (
+
+
+ Hello{"\n"}World
+
+
+ )),
+ { columns: 100 },
+ );
+ expect(lastFrame({ trimLines: true })).toBe("World");
+});
+
+test("overflowY - box intersecting with top edge of overflow container with border", async () => {
+ const { lastFrame } = await render(
+ defineComponent(() => () => (
+
+
+ Hello{"\n"}World
+
+
+ )),
+ { columns: 100 },
+ );
+ expect(lastFrame({ trimLines: true })).toBe(box("World"));
+});
+
+test("overflowY - box below bottom edge of overflow container", async () => {
+ const { lastFrame } = await render(
+ defineComponent(() => () => (
+
+
+ Hello{"\n"}World
+
+
+ )),
+ { columns: 100 },
+ );
+ expect(lastFrame({ trimLines: true })).toBe("");
+});
+
+test("overflowY - box below bottom edge of overflow container with border", async () => {
+ const { lastFrame } = await render(
+ defineComponent(() => () => (
+
+
+ Hello{"\n"}World
+
+
+ )),
+ { columns: 100 },
+ );
+ expect(lastFrame({ trimLines: true })).toBe(box(" ".repeat(5)));
+});
+
+test("overflowY - box intersecting with bottom edge of overflow container", async () => {
+ const { lastFrame } = await render(
+ defineComponent(() => () => (
+
+
+ Hello{"\n"}World
+
+
+ )),
+ { columns: 100 },
+ );
+ expect(lastFrame({ trimLines: true })).toBe("Hello");
+});
+
+test("overflowY - box intersecting with bottom edge of overflow container with border", async () => {
+ const { lastFrame } = await render(
+ defineComponent(() => () => (
+
+
+ Hello{"\n"}World
+
+
+ )),
+ { columns: 100 },
+ );
+ expect(lastFrame({ trimLines: true })).toBe(box("Hello"));
+});
// unified overflow tests
test("overflow - single text node inside overflow container", async () => {
diff --git a/packages/runtime/src/host/node-ops.ts b/packages/runtime/src/host/node-ops.ts
index eae3dfc..ebb6aeb 100644
--- a/packages/runtime/src/host/node-ops.ts
+++ b/packages/runtime/src/host/node-ops.ts
@@ -63,6 +63,8 @@ const STYLE_PROPS = new Set([
// Overflow is a yoga prop (setOverflow) but also needed by the paint pass
// to set up clip rects for overflow: hidden containers.
"overflow",
+ "overflowX",
+ "overflowY",
]);
export function buildNodeOps(options: TtyRendererOptions): RendererOptions {
diff --git a/packages/runtime/src/paint/paint.ts b/packages/runtime/src/paint/paint.ts
index 0787adf..6ae857d 100644
--- a/packages/runtime/src/paint/paint.ts
+++ b/packages/runtime/src/paint/paint.ts
@@ -324,20 +324,25 @@ function paintNode(
fillBackground(output, x + bl, y + bt, w - bl - br, h - bt - bb, bg, transformers);
}
- // Overflow clipping: when overflow is "hidden", clip children to the box
- // content area (inside borders). Matches Ink's clip/unclip approach.
+ // Overflow clipping: clip children to the box content area (inside
+ // borders) when overflow/overflowX/overflowY is "hidden". Matches Ink's
+ // per-axis clip/unclip approach.
let clipped = false;
const overflow = node.props["overflow"] as string | undefined;
- if (overflow === "hidden") {
- const borderLeft = node.yoga.getComputedBorder(Yoga.EDGE_LEFT);
- const borderRight = node.yoga.getComputedBorder(Yoga.EDGE_RIGHT);
- const borderTop = node.yoga.getComputedBorder(Yoga.EDGE_TOP);
- const borderBottom = node.yoga.getComputedBorder(Yoga.EDGE_BOTTOM);
+ const clipH =
+ overflow === "hidden" || (node.props["overflowX"] as string | undefined) === "hidden";
+ const clipV =
+ overflow === "hidden" || (node.props["overflowY"] as string | undefined) === "hidden";
+ if (clipH || clipV) {
+ const bl = node.yoga.getComputedBorder(Yoga.EDGE_LEFT);
+ const br = node.yoga.getComputedBorder(Yoga.EDGE_RIGHT);
+ const bt = node.yoga.getComputedBorder(Yoga.EDGE_TOP);
+ const bb = node.yoga.getComputedBorder(Yoga.EDGE_BOTTOM);
output.clip({
- x1: x + borderLeft,
- x2: x + w - borderRight,
- y1: y + borderTop,
- y2: y + h - borderBottom,
+ x1: clipH ? x + bl : undefined,
+ x2: clipH ? x + w - br : undefined,
+ y1: clipV ? y + bt : undefined,
+ y2: clipV ? y + h - bb : undefined,
});
clipped = true;
}