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) {