From 860980de8a8e0a685d47d22577ec0d8137fee18f Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sun, 31 May 2026 16:09:53 +0800 Subject: [PATCH] fix(runtime): write wide chars at the terminal edge instead of clipping them (Ink parity) (#90) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The paint Output write loop had two x-bounds guards Ink lacks, which dropped a whole wide char -- including its in-bounds leading cell -- when only its trailing cell exceeded the width, so an edge-aligned "aa你" rendered as "aa". Ink's Output write loop has no bounds check: it writes both cells and lets the past-width placeholder be dropped as a sparse hole by line.filter(undefined) + trimEnd. Removes the two guards to match (output.ts:272-308); box-level overflow:hidden clipping is unchanged (the separate clipH sliceAnsi path). Un-skips the non-hyperlink-OSC overflow-wrap test, which this also fixes (the now-visible OSC bytes no longer push the trailing char off a clipped edge) -- verified "abcde\nfghij" against the Ink reference. Adds a wide-char-at-edge test. Co-authored-by: Claude Opus 4.8 (1M context) --- .../integration/components/text.test.tsx | 16 ++++++------ .../integration/paint/text-width.test.tsx | 25 +++++++++++++++++++ packages/runtime/src/paint/paint.ts | 19 ++++++++------ 3 files changed, 44 insertions(+), 16 deletions(-) diff --git a/packages/runtime-tests/integration/components/text.test.tsx b/packages/runtime-tests/integration/components/text.test.tsx index 389f4ea..69f24df 100644 --- a/packages/runtime-tests/integration/components/text.test.tsx +++ b/packages/runtime-tests/integration/components/text.test.tsx @@ -664,15 +664,13 @@ test("do not wrap text with non-hyperlink OSC (ST-terminated) sequences", async // still reaches wrap-ansi. wrap-ansi@10 only protects `]8;;` HYPERLINK OSCs, so it // SPLITS this generic OSC across lines (`["\x1b]0;My ","Title","\x07abcde","fghij"]`). // Ink's wrapText produces the IDENTICAL split lines (verified against Ink v7.0.4) — -// the divergence is downstream in the Output grid: vue clips chars at the grid right -// edge (the issue-#10 wide-glyph clip, paint.ts `if (offsetX >= this.width) break`), -// and the now-visible BEL/broken-OSC bytes consume a grid cell, pushing the trailing -// "e" past column 5 where vue DROPS it ("abcd\nfghij"). Ink never clips overflow, so -// "e" survives ("abcde\nfghij"). Confirmed: disabling vue's grid clip makes this pass, -// and the fix is identical with/without the measure sanitize. This is the issue-#10 -// grid-clip vs control-byte interaction, a DIFFERENT parity gap. The assertion below -// is Ink's correct output; un-skip when that grid-clip gap is addressed. -test.skip("hard-wrap long word after non-hyperlink OSC sequence", async () => { +// the divergence was downstream in the Output grid: vue used to clip chars at the grid +// right edge (the `offsetX + characterWidth > this.width` guard in paint.ts), and the +// now-visible BEL/broken-OSC bytes consumed a grid cell, pushing the trailing "e" past +// column 5 where vue DROPPED it ("abcd\nfghij"). Ink never clips in its Output write +// loop, so "e" survives ("abcde\nfghij"). FIXED by removing vue's two x-bounds guards +// to match Ink's Output loop exactly (the wide-char-at-edge parity fix) — un-skipped. +test("hard-wrap long word after non-hyperlink OSC sequence", async () => { const text = "\x1b]0;My Title\x07abcdefghij"; const output = renderToString( defineComponent(() => () => ( diff --git a/packages/runtime-tests/integration/paint/text-width.test.tsx b/packages/runtime-tests/integration/paint/text-width.test.tsx index 9a1c8c3..e701696 100644 --- a/packages/runtime-tests/integration/paint/text-width.test.tsx +++ b/packages/runtime-tests/integration/paint/text-width.test.tsx @@ -217,3 +217,28 @@ test("clipped empty write does not corrupt existing wide characters", () => { ); expect(stripAnsi(output)).toBe("あい"); }); + +// A wide char whose LEADING cell is in-bounds but TRAILING cell exceeds the +// terminal/box width must still render its leading cell and OVERFLOW the row — +// matching Ink, which has no x-bounds check in its Output write loop. The +// past-width trailing placeholder is dropped later by line.filter + trimEnd. +// Box width 4 (== terminal); 你 (width 2) overlaid at left=3 lands on cols 3,4 — +// trailing col 4 is past width. Ink renders "aa 你" (visible width 5); vue used +// to drop 你 wholesale (its leading col-3 cell included) via the now-removed +// `offsetX + characterWidth > this.width` guard. Verified against the built Ink +// reference (/tmp/ink @ v7.0.4 renderToString of this exact tree → "aa 你"). +test("wide char with in-bounds leading cell but out-of-bounds trailing cell still renders (overflows row, Ink parity)", () => { + const output = renderToString( + defineComponent(() => () => ( + + aa + + 你 + + + )), + { columns: 4 }, + ); + expect(stripAnsi(output)).toBe("aa 你"); + expect(stringWidth(stripAnsi(output))).toBe(5); +}); diff --git a/packages/runtime/src/paint/paint.ts b/packages/runtime/src/paint/paint.ts index 6bef645..df7dbc1 100644 --- a/packages/runtime/src/paint/paint.ts +++ b/packages/runtime/src/paint/paint.ts @@ -246,16 +246,21 @@ class Output { currentLine[offsetX - 1] = spaceCell; } + // NO x-bounds check here — matches Ink's Output write loop + // (output.ts:272-294), which writes `currentLine[offsetX] = character` + // and the trailing placeholder cells regardless of `this.width`. A wide + // char whose LEADING cell is in-bounds but whose TRAILING cell exceeds + // the width still renders its leading cell and OVERFLOWS the row; the + // past-width placeholder is dropped later as a sparse hole by + // `line.filter(item => item !== undefined)` + `.trimEnd()` (see below). + // Guarding on width here (as vue once did) instead DROPPED the whole wide + // char — leading cell included — when only its trailing cell was past the + // edge, so an edge-aligned `aa你` rendered as `aa`. Box-level + // overflow:hidden clipping is handled separately above (the clipH sliceAnsi + // path); this loop must not re-implement a second, glyph-truncating clip. for (const character of characters) { - if (offsetX >= this.width) break; - const characterWidth = Math.max(1, this.caches.getStringWidth(character.value)); - if (offsetX + characterWidth > this.width) { - offsetX += characterWidth; - continue; - } - currentLine[offsetX] = character; if (characterWidth > 1) {