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:
@@ -1,7 +1,7 @@
|
|||||||
import { defineComponent } from "vue";
|
import { defineComponent } from "vue";
|
||||||
import { describe, expect, test } from "vite-plus/test";
|
import { describe, expect, test } from "vite-plus/test";
|
||||||
import { render } from "@vue-tui/testing";
|
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";
|
import stripAnsi from "strip-ansi";
|
||||||
|
|
||||||
/** Build a round-border box string like boxen(text, { borderStyle: "round" }) */
|
/** 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);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -160,38 +160,15 @@ class Output {
|
|||||||
const clip = clips.at(-1);
|
const clip = clips.at(-1);
|
||||||
|
|
||||||
if (clip) {
|
if (clip) {
|
||||||
const clipH = typeof clip.x1 === "number" && typeof clip.x2 === "number";
|
|
||||||
const clipV = typeof clip.y1 === "number" && typeof clip.y2 === "number";
|
const clipV = typeof clip.y1 === "number" && typeof clip.y2 === "number";
|
||||||
|
|
||||||
// If text is positioned outside of clipping area altogether, skip
|
// Vertical early skip — safe because transforms don't change line count
|
||||||
if (clipH) {
|
|
||||||
const text = lines.join("\n");
|
|
||||||
const width = this.caches.getWidestLine(text);
|
|
||||||
if (x + width < clip.x1! || x > clip.x2!) continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (clipV) {
|
if (clipV) {
|
||||||
const height = lines.length;
|
const height = lines.length;
|
||||||
if (y + height < clip.y1! || y > clip.y2!) continue;
|
if (y + height < clip.y1! || y > clip.y2!) continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (clipH) {
|
// Vertical clip
|
||||||
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!;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (clipV) {
|
if (clipV) {
|
||||||
const from = y < clip.y1! ? clip.y1! - y : 0;
|
const from = y < clip.y1! ? clip.y1! - y : 0;
|
||||||
const height = lines.length;
|
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;
|
let offsetY = 0;
|
||||||
|
|
||||||
for (let [index, line] of lines.entries()) {
|
for (let [index, line] of lines.entries()) {
|
||||||
@@ -211,12 +196,33 @@ class Output {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply transforms BEFORE horizontal clipping
|
||||||
for (const transformer of transformers) {
|
for (const transformer of transformers) {
|
||||||
line = transformer(line, index);
|
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);
|
const characters = this.caches.getStyledChars(line);
|
||||||
let offsetX = x;
|
let offsetX = lineX;
|
||||||
|
|
||||||
// Nothing to write (e.g. line was clipped away)
|
// Nothing to write (e.g. line was clipped away)
|
||||||
if (characters.length === 0) {
|
if (characters.length === 0) {
|
||||||
|
|||||||
Reference in New Issue
Block a user