From 5d532f47d144476a93084e0fdde691df93870006 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Thu, 28 May 2026 00:42:39 +0800 Subject: [PATCH] fix: clip wide glyphs that overflow grid/clip boundary (closes #10) (#14) Absolute-positioned wide characters (CJK, emoji) could paint past the right edge of a clipped box or the terminal grid, producing output wider than the column limit. Three fixes: - Safe-slice after sliceAnsi in clip logic to handle wide char overshoot - Bounds check in grid write loop to skip chars exceeding grid width - Width-aware border fill to account for measured corner char widths --- .../integration/components/borders.test.tsx | 4 +- .../integration/layout/overflow.test.tsx | 80 ++++++++++++++++++- packages/runtime/src/paint/paint.ts | 28 +++++-- 3 files changed, 101 insertions(+), 11 deletions(-) diff --git a/packages/runtime-tests/integration/components/borders.test.tsx b/packages/runtime-tests/integration/components/borders.test.tsx index 1a01faf..b96a6ad 100644 --- a/packages/runtime-tests/integration/components/borders.test.tsx +++ b/packages/runtime-tests/integration/components/borders.test.tsx @@ -947,9 +947,9 @@ test("custom border style", async () => { { columns: 100 }, ); expect(lastFrame()).toMatchInlineSnapshot(` - "↘↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↙ + "↘↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↙ →Content ← - ↗↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↖" + ↗↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↖" `); }); diff --git a/packages/runtime-tests/integration/layout/overflow.test.tsx b/packages/runtime-tests/integration/layout/overflow.test.tsx index 185d52a..bce7c1b 100644 --- a/packages/runtime-tests/integration/layout/overflow.test.tsx +++ b/packages/runtime-tests/integration/layout/overflow.test.tsx @@ -1,7 +1,8 @@ import { defineComponent } from "vue"; -import { expect, test } from "vite-plus/test"; +import { describe, expect, test } from "vite-plus/test"; import { render } from "@vue-tui/testing"; -import { Box, Text } from "@vue-tui/runtime"; +import { Box, Text, measureText } from "@vue-tui/runtime"; +import stripAnsi from "strip-ansi"; /** Build a round-border box string like boxen(text, { borderStyle: "round" }) */ function box(text: string): string { @@ -581,3 +582,78 @@ test("out of bounds writes do not crash", async () => { ); expect(lastFrame({ trimLines: true })).toBeDefined(); }); + +// --- absolute overlay wide glyph clipping (issue #10) --- + +function lineWidth(text: string): number { + return measureText(stripAnsi(text), 9999).width; +} + +describe("absolute overlay wide glyph clipping", () => { + test("wide char at right edge of clipped box is omitted", async () => { + const { lastFrame } = await render( + defineComponent(() => () => ( + + abc + + 中 + + + )), + { columns: 100 }, + ); + const frame = lastFrame({ trimLines: true })!; + expect(stripAnsi(frame)).toBe("abc"); + }); + + test("wide emoji at right edge of clipped box is omitted", async () => { + const { lastFrame } = await render( + defineComponent(() => () => ( + + abc + + 🍔 + + + )), + { columns: 100 }, + ); + const frame = lastFrame({ trimLines: true })!; + expect(stripAnsi(frame)).toBe("abc"); + }); + + test("wide char fully inside clipped box is preserved", async () => { + const { lastFrame } = await render( + defineComponent(() => () => ( + + a + + 中 + + + )), + { columns: 100 }, + ); + const frame = lastFrame({ trimLines: true })!; + expect(stripAnsi(frame)).toContain("中"); + expect(lineWidth(frame)).toBeLessThanOrEqual(4); + }); + + test("output does not exceed terminal columns with absolute wide char", async () => { + const { lastFrame } = await render( + defineComponent(() => () => ( + + hello + + 你好 + + + )), + { columns: 8 }, + ); + const frame = lastFrame({ trimLines: true })!; + for (const line of frame.split("\n")) { + expect(lineWidth(line)).toBeLessThanOrEqual(8); + } + }); +}); diff --git a/packages/runtime/src/paint/paint.ts b/packages/runtime/src/paint/paint.ts index ae61806..d2b98e2 100644 --- a/packages/runtime/src/paint/paint.ts +++ b/packages/runtime/src/paint/paint.ts @@ -166,8 +166,15 @@ class Output { lines = lines.map((line) => { const from = x < clip.x1! ? clip.x1! - x : 0; const lineWidth = this.caches.getStringWidth(line); - const to = x + lineWidth > clip.x2! ? clip.x2! - x : lineWidth; - return sliceAnsi(line, from, to); + 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!; } @@ -225,12 +232,17 @@ class Output { } for (const character of characters) { - currentLine[offsetX] = character; + if (offsetX >= this.width) break; - // Determine printed width using string-width to align with measurement const characterWidth = Math.max(1, this.caches.getStringWidth(character.value)); - // For multi-column characters, clear following cells to avoid stray spaces/artifacts + if (offsetX + characterWidth > this.width) { + offsetX += characterWidth; + continue; + } + + currentLine[offsetX] = character; + if (characterWidth > 1) { for (let i = 1; i < characterWidth; i++) { currentLine[offsetX + i] = { @@ -341,15 +353,17 @@ function drawBorder( if (top) { const tl = left ? chars.topLeft : chars.top; const tr = right ? chars.topRight : chars.top; - output.write(x, y, [colorizeEdge(tl + chars.top.repeat(w - 2) + tr, "top")], transformers); + const fill = Math.max(0, w - stringWidth(tl) - stringWidth(tr)); + output.write(x, y, [colorizeEdge(tl + chars.top.repeat(fill) + tr, "top")], transformers); } if (bottom) { const bl = left ? chars.bottomLeft : chars.bottom; const br = right ? chars.bottomRight : chars.bottom; + const fill = Math.max(0, w - stringWidth(bl) - stringWidth(br)); output.write( x, y + h - 1, - [colorizeEdge(bl + chars.bottom.repeat(w - 2) + br, "bottom")], + [colorizeEdge(bl + chars.bottom.repeat(fill) + br, "bottom")], transformers, ); }