fix: reorder clip/transform pipeline so transforms are clipped correctly

Horizontal clipping now runs per-line AFTER transforms instead of before,
preventing Transform-widened text from escaping clip boundaries. Left-edge
clipping also uses the actual removed width to position subsequent text
correctly when a wide char straddles the boundary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-05-28 01:14:42 +08:00
parent fe98756666
commit 2c994314e1
2 changed files with 86 additions and 27 deletions
@@ -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(() => () => (
<Box width={4} height={1} overflow="hidden">
<Text>abc</Text>
<Box position="absolute" left={3}>
<Transform transform={() => "中"}>
<Text>x</Text>
</Transform>
</Box>
</Box>
)),
{ 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(() => () => (
<Box width={4} height={1} overflow="hidden">
<Box marginLeft={-1} flexShrink={0}>
<Text>中x</Text>
</Box>
</Box>
)),
{ 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(() => () => (
<Box width={2} height={1} overflow="hidden">
<Box marginLeft={-1} width={6} flexShrink={0}>
<Text>中中中</Text>
</Box>
</Box>
)),
{ columns: 100 },
);
const frame = lastFrame({ trimLines: true })!;
expect(lineWidth(frame)).toBeLessThanOrEqual(2);
});
});
+32 -26
View File
@@ -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) {