From bbaa77bce1b6f4ab67532afa098f572b9214a81d Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Tue, 26 May 2026 00:04:57 +0800 Subject: [PATCH] feat: support per-axis overflowX/overflowY clipping Add independent horizontal/vertical clip support in the paint pass, matching Ink's per-axis overflow behavior. Add overflowX/overflowY to STYLE_PROPS so they are stored in el.props for the paint pass. Implement 26 new overflow tests translated from Ink. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../integration/layout/overflow.test.tsx | 435 ++++++++++++++++-- packages/runtime/src/host/node-ops.ts | 2 + packages/runtime/src/paint/paint.ts | 27 +- 3 files changed, 425 insertions(+), 39 deletions(-) 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; }