From ea5ef18325c64f8d0ab0d163566c20f2d28f84dc Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Fri, 5 Jun 2026 12:37:43 +0800 Subject: [PATCH] fix(runtime): hard-wrap at width 0 must use wordWrap:false (Ink parity) (#141) `` measured at width 0 dropped one blank row per interior word boundary, so it measured a shorter height than Ink. Ink's wrap-text.ts uses `{hard:true, wordWrap:false}` for `hard` mode and `{hard:true}` for `wrap` mode; vue-tui's width-0 path (wrapZeroWidthAnsi) always used the `wrap` options regardless of mode. Thread the wrap mode into wrapZeroWidthAnsi and select `{hard:true, trim:false, wordWrap:false}` for `hard` (vs `{hard:true, trim:false}` for `wrap`) at width 0, matching Ink. The non-zero `hard` branch already used wordWrap:false, so this makes the width-0 path consistent with it. The re-styling loop is unchanged (extra blank rows pass through as empty strings). width-0 hard "a b c" now measures 8 rows (['','a',' ','','b',' ','','c']) like Ink, not 6. `wrap` mode and all non-zero widths are byte-identical. Co-authored-by: Claude Opus 4.8 (1M context) --- .../integration/components/text.test.tsx | 26 ++++++++ .../runtime/src/host/text-measure.test.ts | 59 +++++++++++++++++++ packages/runtime/src/host/text-measure.ts | 38 ++++++++---- 3 files changed, 111 insertions(+), 12 deletions(-) diff --git a/packages/runtime-tests/integration/components/text.test.tsx b/packages/runtime-tests/integration/components/text.test.tsx index 7b28764..0e72953 100644 --- a/packages/runtime-tests/integration/components/text.test.tsx +++ b/packages/runtime-tests/integration/components/text.test.tsx @@ -346,6 +346,32 @@ test("hard wrap with long word", async () => { expect(lastFrame()).toBe("aaaaa\naaaaa"); }); +test("hard wrap at width 0 measures one row per grapheme PLUS a blank row per interior word boundary (Ink parity)", async () => { + // Ink wrap-text.ts uses wordWrap:false for `hard` mode: at width 0 that inserts an extra + // blank row before each interior word's first grapheme, so "a b c" measures height 8 + // (["","a"," ","","b"," ","","c"]) — NOT the height-6 `wrap`-mode layout. The 0-width + // Box is the tallest child, so the row sibling "X" sits on the FIRST row, and the column + // grows to 8 rows. If `hard` were (wrongly) measured with `wrap` structure, the box would + // be 6 rows tall. + const { lastFrame } = await render( + defineComponent(() => () => ( + + + a b c + + X + + )), + { columns: 100 }, + ); + const lines = stripAnsi(lastFrame()!).split("\n"); + // 8 rows total (the 0-width hard-wrapped Text dictates the column height). + expect(lines.length).toBe(8); + // The 0-width column contributes no visible columns, so each row is just the sibling's + // contribution on row 0 ("X") and empty rows below — confirming height 8, not 6. + expect(lines[0]).toContain("X"); +}); + test("don't hard wrap text if there is enough space", async () => { const { lastFrame } = await render( defineComponent(() => () => ( diff --git a/packages/runtime/src/host/text-measure.test.ts b/packages/runtime/src/host/text-measure.test.ts index cd845af..625524a 100644 --- a/packages/runtime/src/host/text-measure.test.ts +++ b/packages/runtime/src/host/text-measure.test.ts @@ -135,6 +135,65 @@ test("wrapText at width 0 does NOT drop text after a leading zero-width + wide g expect(wrapText("​中", 0, "wrap")).toEqual(["​", "", "中"]); }); +test("wrapText at width 0 in HARD mode drops a blank row at every interior word boundary (Ink parity)", () => { + // Ink's wrap-text.ts uses `wordWrap:false` for `hard` mode but NOT for `wrap`: at width 0 + // that makes wrap-ansi emit an EXTRA blank row before each interior word's first grapheme. + // wrapAnsi("a b c", 0, {hard:true, trim:false, wordWrap:false}) = + // ["","a"," ","","b"," ","","c"] (height 8) + // whereas `wrap` mode (no wordWrap:false) = + // ["","a"," ","b"," ","c"] (height 6). + // Measuring `hard` with `wrap` structure UNDER-counts the height, so a sibling laid out below + // this node lands one row too high vs Ink. The fix threads the wrap mode into wrapZeroWidthAnsi. + expect(wrapText("a b c", 0, "hard")).toEqual(["", "a", " ", "", "b", " ", "", "c"]); + // `wrap` mode at width 0 stays UNCHANGED (no wordWrap:false → no extra blank rows). + expect(wrapText("a b c", 0, "wrap")).toEqual(["", "a", " ", "b", " ", "c"]); +}); + +test("wrapText at width 0 in HARD mode re-styles correctly across the extra blank rows", () => { + // The re-styling slot map must still pair each NON-EMPTY row with the right grapheme/SGR span + // even though hard mode inserts extra "" rows. Red-bg over "a b": hard layout is + // ["","a"," ","","b"] — the "a" and "b" rows keep their SGR span, the blank rows stay empty. + const styled = "\x1b[41ma b\x1b[49m"; + const plainStructure = wrapAnsi("a b", 0, { hard: true, trim: false, wordWrap: false }).split( + "\n", + ); + const got = wrapText(styled, 0, "hard"); + expect(got.length).toBe(plainStructure.length); + expect(got.map(stripAnsi)).toEqual(plainStructure); + expect(got).toEqual(["", "\x1b[41ma\x1b[49m", "\x1b[41m \x1b[49m", "", "\x1b[41mb\x1b[49m"]); +}); + +// Load-bearing lock: in HARD mode wrapZeroWidthAnsi's LINE STRUCTURE must EXACTLY equal +// wrap-ansi's authoritative width-0 layout WITH `wordWrap:false` (Ink wrap-text.ts hard path), +// across the same battery used for `wrap` mode below. +test("wrapText at width 0 in HARD mode matches wrap-ansi's wordWrap:false width-0 layout for the full battery", () => { + const battery = [ + "a b c", // multiple interior word boundaries → multiple extra blank rows + "a b", + "ab cd", + "A", + "AB", + "", + " ", + "A\nB", + "A​B", // ZWSP + "​中", // ZWSP + wide + "中​A", // wide + ZWSP + "áb", // composed acute (NFC form) + "áb", // EXPLICITLY decomposed + "⚠️", // VS16 + "🍔", // emoji + "👨‍👩‍👧", // ZWJ family + "a­b", // soft hyphen + "X​Y中​Z\nP­Q", // mixed multiline + "中​", // trailing zero-width + ]; + for (const input of battery) { + const expected = wrapAnsi(input, 0, { hard: true, trim: false, wordWrap: false }).split("\n"); + expect(wrapText(input, 0, "hard"), `input=${JSON.stringify(input)}`).toEqual(expected); + } +}); + // Load-bearing lock: wrapZeroWidthAnsi's LINE STRUCTURE must EXACTLY equal wrap-ansi's // authoritative width-0 layout for plain text across a battery of zero-width / wide / combining // / emoji / multiline inputs. Imported the same way the source imports wrap-ansi. diff --git a/packages/runtime/src/host/text-measure.ts b/packages/runtime/src/host/text-measure.ts index f8ad769..cc1c79f 100644 --- a/packages/runtime/src/host/text-measure.ts +++ b/packages/runtime/src/host/text-measure.ts @@ -155,15 +155,23 @@ function stripAnsi(text: string): string { /** * Replicate wrap-ansi's width<=0 layout for a (possibly STYLED) string, ANSI-awarely. * - * The output's LINE STRUCTURE exactly equals `wrapAnsi(stripAnsi(text), 0, {hard, trim:false}) - * .split("\n")` — wrap-ansi's authoritative width-0 layout. wrap-ansi breaks BEFORE each - * grapheme it cannot fit, so an interior zero-width grapheme (ZWSP/ZWNJ/ZWJ, combining mark, - * VS16, soft-hyphen, BOM) lands on its OWN row with the surrounding `""` blanks — but a - * TRAILING zero-width run (nothing visible after it) stays glued to the preceding grapheme's - * row (wrapAnsi("中​",0)=["","中​"], not ["","中","​"]). Deriving structure from wrap-ansi on - * the plain string reproduces both cases for free; the old column-stepping `slice(col,col+1)` - * could not (it glued an interior zero-width onto the next grapheme → line-count too low, and - * `break`-ed on a leading zero-width + wide glyph → dropped the rest of the line). + * The output's LINE STRUCTURE exactly equals wrap-ansi's authoritative width-0 layout for the + * given wrap `mode` — `wrap` uses `{hard:true, trim:false}`, `hard` uses + * `{hard:true, trim:false, wordWrap:false}`, mirroring Ink's wrap-text.ts (the SOLE difference + * between the two modes). At width 0 `wordWrap:false` makes wrap-ansi emit an EXTRA blank row + * before each interior word's first grapheme (wrapAnsi("a b c",0,…,wordWrap:false) = + * ["","a"," ","","b"," ","","c"] vs `wrap`'s ["","a"," ","b"," ","c"]), so `hard` measures + * taller than `wrap` — which is exactly Ink's behavior. Threading `mode` here keeps both + * line-counts in lockstep with Ink instead of measuring `hard` with `wrap` structure. + * + * wrap-ansi breaks BEFORE each grapheme it cannot fit, so an interior zero-width grapheme + * (ZWSP/ZWNJ/ZWJ, combining mark, VS16, soft-hyphen, BOM) lands on its OWN row with the + * surrounding `""` blanks — but a TRAILING zero-width run (nothing visible after it) stays glued + * to the preceding grapheme's row (wrapAnsi("中​",0)=["","中​"], not ["","中","​"]). Deriving + * structure from wrap-ansi on the plain string reproduces both cases for free; the old + * column-stepping `slice(col,col+1)` could not (it glued an interior zero-width onto the next + * grapheme → line-count too low, and `break`-ed on a leading zero-width + wide glyph → dropped + * the rest of the line). * * Styling is re-applied in lockstep: the plain and styled strings share an identical grapheme * sequence (SGR/OSC are zero-width), so each NON-EMPTY plain line maps to a contiguous run of @@ -174,7 +182,7 @@ function stripAnsi(text: string): string { * wrap-ansi touch the styled string (it byte-splits the escapes at width<=0); we only ask it * for structure. */ -function wrapZeroWidthAnsi(text: string): string[] { +function wrapZeroWidthAnsi(text: string, mode: "wrap" | "hard"): string[] { // NFC-normalize first: wrap-ansi (and therefore vue's NORMAL-width wrap path, which feeds // the styled string straight to wrapAnsi) composes combining sequences (e.g. "á" → // "á"). Deriving structure from wrapAnsi(stripAnsi(text)) yields composed rows, so the @@ -185,9 +193,15 @@ function wrapZeroWidthAnsi(text: string): string[] { // Process each hard-newline line independently so `\n` never enters the grapheme walk // (wrap-ansi joins line-blocks with `\n`, so each input line contributes its own block). const styledLines = text.split("\n"); + // Pick wrap-ansi's width-0 options per mode, mirroring Ink wrap-text.ts: `hard` adds + // `wordWrap:false` (extra blank row at each interior word boundary), `wrap` does not. Only the + // structural line count changes; the re-styling loop below maps each NON-EMPTY row to one + // grapheme run regardless of how many extra "" rows hard mode interleaves. + const wrapOptions = + mode === "hard" ? { hard: true, trim: false, wordWrap: false } : { hard: true, trim: false }; for (const styledLine of styledLines) { const plainLine = stripAnsi(styledLine); - const plainLines = wrapAnsi(plainLine, 0, { hard: true, trim: false }).split("\n"); + const plainLines = wrapAnsi(plainLine, 0, wrapOptions).split("\n"); // Assign each grapheme of the plain line a slice-ansi slot range: a grapheme occupies // max(1, visibleWidth) slots (a zero-width grapheme gets 1 slot of its own; a wide glyph 2). @@ -256,7 +270,7 @@ export function wrapText(text: string, width: number, mode: WrapMode = "wrap"): // wrapping, so we must reproduce wrap-ansi's plain-text layout ANSI-awarely. slice-ansi // is grapheme-aware and re-emits the active SGR span around each slice, matching Ink's // per-grapheme colored output (e.g. "B\n\x1b[41mA\x1b[49m" for a 0-width bg Box). - if (width <= 0) return wrapZeroWidthAnsi(text); + if (width <= 0) return wrapZeroWidthAnsi(text, mode); if (mode === "wrap") { return wrapAnsi(text, width, { hard: true, trim: false }).split("\n");