diff --git a/packages/runtime-tests/integration/layout/overflow.test.tsx b/packages/runtime-tests/integration/layout/overflow.test.tsx index bce7c1b..5206fda 100644 --- a/packages/runtime-tests/integration/layout/overflow.test.tsx +++ b/packages/runtime-tests/integration/layout/overflow.test.tsx @@ -1,7 +1,7 @@ import { defineComponent } from "vue"; import { describe, expect, test } from "vite-plus/test"; import { render } from "@vue-tui/testing"; -import { Box, Text, measureText } from "@vue-tui/runtime"; +import { Box, Text, Transform, measureText } from "@vue-tui/runtime"; import stripAnsi from "strip-ansi"; /** Build a round-border box string like boxen(text, { borderStyle: "round" }) */ @@ -656,4 +656,57 @@ describe("absolute overlay wide glyph clipping", () => { expect(lineWidth(line)).toBeLessThanOrEqual(8); } }); + + test("transform returning wide char in clipped box is clipped", async () => { + const { lastFrame } = await render( + defineComponent(() => () => ( + + abc + + "中"}> + x + + + + )), + { columns: 100 }, + ); + const frame = lastFrame({ trimLines: true })!; + expect(stripAnsi(frame)).toBe("abc"); + }); +}); + +describe("left-edge wide glyph clipping", () => { + test("text after clipped left-edge wide char is correctly positioned", async () => { + const { lastFrame } = await render( + defineComponent(() => () => ( + + + 中x + + + )), + { columns: 100 }, + ); + const frame = lastFrame({ trimLines: true })!; + const stripped = stripAnsi(frame); + // "中" (width 2) starts at col -1, straddling the left edge → clipped entirely + // "x" should start at col 1 (not col 0) + expect(stripped.startsWith(" x")).toBe(true); + }); + + test("wide chars clipped on both edges simultaneously", async () => { + const { lastFrame } = await render( + defineComponent(() => () => ( + + + 中中中 + + + )), + { columns: 100 }, + ); + const frame = lastFrame({ trimLines: true })!; + expect(lineWidth(frame)).toBeLessThanOrEqual(2); + }); }); diff --git a/packages/runtime/src/paint/paint.ts b/packages/runtime/src/paint/paint.ts index c8d18df..fddf53e 100644 --- a/packages/runtime/src/paint/paint.ts +++ b/packages/runtime/src/paint/paint.ts @@ -160,38 +160,15 @@ class Output { const clip = clips.at(-1); if (clip) { - const clipH = typeof clip.x1 === "number" && typeof clip.x2 === "number"; const clipV = typeof clip.y1 === "number" && typeof clip.y2 === "number"; - // If text is positioned outside of clipping area altogether, skip - if (clipH) { - const text = lines.join("\n"); - const width = this.caches.getWidestLine(text); - if (x + width < clip.x1! || x > clip.x2!) continue; - } - + // Vertical early skip — safe because transforms don't change line count if (clipV) { const height = lines.length; if (y + height < clip.y1! || y > clip.y2!) continue; } - if (clipH) { - lines = lines.map((line) => { - const from = x < clip.x1! ? clip.x1! - x : 0; - const lineWidth = this.caches.getStringWidth(line); - let to = x + lineWidth > clip.x2! ? clip.x2! - x : lineWidth; - let sliced = sliceAnsi(line, from, to); - let slicedWidth = this.caches.getStringWidth(sliced); - while (slicedWidth > to - from && to > from) { - to--; - sliced = sliceAnsi(line, from, to); - slicedWidth = this.caches.getStringWidth(sliced); - } - return sliced; - }); - if (x < clip.x1!) x = clip.x1!; - } - + // Vertical clip if (clipV) { const from = y < clip.y1! ? clip.y1! - y : 0; const height = lines.length; @@ -201,6 +178,14 @@ class Output { } } + const clipH = + clip && typeof clip.x1 === "number" && typeof clip.x2 === "number" + ? { x1: clip.x1, x2: clip.x2 } + : null; + + // Safe early skip: entire write starts at or past right clip edge + if (clipH && x >= clipH.x2) continue; + let offsetY = 0; for (let [index, line] of lines.entries()) { @@ -211,12 +196,33 @@ class Output { continue; } + // Apply transforms BEFORE horizontal clipping for (const transformer of transformers) { line = transformer(line, index); } + // Horizontal clip (per-line, after transform) + let lineX = x; + if (clipH) { + const lineWidth = this.caches.getStringWidth(line); + // Skip line entirely if outside horizontal clip + if (lineX + lineWidth < clipH.x1 || lineX > clipH.x2) { + offsetY++; + continue; + } + const from = lineX < clipH.x1 ? clipH.x1 - lineX : 0; + const to = lineX + lineWidth > clipH.x2 ? clipH.x2 - lineX : lineWidth; + if (from > 0) { + const leftPrefix = sliceAnsi(line, 0, from); + const leftRemovedWidth = this.caches.getStringWidth(leftPrefix); + lineX = lineX + leftRemovedWidth; + } + const maxWidth = clipH.x2 - lineX; + line = safeSliceEnd(sliceAnsi(line, from, to), maxWidth); + } + const characters = this.caches.getStyledChars(line); - let offsetX = x; + let offsetX = lineX; // Nothing to write (e.g. line was clipped away) if (characters.length === 0) {