fix(runtime): write wide chars at the terminal edge instead of clipping them (Ink parity) (#90)

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) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-05-31 16:09:53 +08:00
committed by GitHub
parent 8c3e97ab47
commit 860980de8a
3 changed files with 44 additions and 16 deletions
@@ -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(() => () => (
@@ -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(() => () => (
<Box width={4} height={1}>
<Text>aa</Text>
<Box position="absolute" left={3}>
<Text>你</Text>
</Box>
</Box>
)),
{ columns: 4 },
);
expect(stripAnsi(output)).toBe("aa 你");
expect(stringWidth(stripAnsi(output))).toBe(5);
});