fix(runtime): hard-wrap at width 0 must use wordWrap:false (Ink parity) (#141)

`<Text wrap="hard">` 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) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-06-05 12:37:43 +08:00
committed by GitHub
parent f6a552d855
commit ea5ef18325
3 changed files with 111 additions and 12 deletions
@@ -346,6 +346,32 @@ test("hard wrap with long word", async () => {
expect(lastFrame()).toBe("aaaaa\naaaaa"); 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(() => () => (
<Box flexDirection="row">
<Box width={0}>
<Text wrap="hard">a b c</Text>
</Box>
<Text>X</Text>
</Box>
)),
{ 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 () => { test("don't hard wrap text if there is enough space", async () => {
const { lastFrame } = await render( const { lastFrame } = await render(
defineComponent(() => () => ( defineComponent(() => () => (
@@ -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(["​", "", "中"]); 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 // 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 // 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. // / emoji / multiline inputs. Imported the same way the source imports wrap-ansi.
+26 -12
View File
@@ -155,15 +155,23 @@ function stripAnsi(text: string): string {
/** /**
* Replicate wrap-ansi's width<=0 layout for a (possibly STYLED) string, ANSI-awarely. * 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}) * The output's LINE STRUCTURE exactly equals wrap-ansi's authoritative width-0 layout for the
* .split("\n")` — wrap-ansi's authoritative width-0 layout. wrap-ansi breaks BEFORE each * given wrap `mode` — `wrap` uses `{hard:true, trim:false}`, `hard` uses
* grapheme it cannot fit, so an interior zero-width grapheme (ZWSP/ZWNJ/ZWJ, combining mark, * `{hard:true, trim:false, wordWrap:false}`, mirroring Ink's wrap-text.ts (the SOLE difference
* VS16, soft-hyphen, BOM) lands on its OWN row with the surrounding `""` blanks — but a * between the two modes). At width 0 `wordWrap:false` makes wrap-ansi emit an EXTRA blank row
* TRAILING zero-width run (nothing visible after it) stays glued to the preceding grapheme's * before each interior word's first grapheme (wrapAnsi("a b c",0,…,wordWrap:false) =
* row (wrapAnsi("中​",0)=["","中​"], not ["","中","​"]). Deriving structure from wrap-ansi on * ["","a"," ","","b"," ","","c"] vs `wrap`'s ["","a"," ","b"," ","c"]), so `hard` measures
* the plain string reproduces both cases for free; the old column-stepping `slice(col,col+1)` * taller than `wrap` — which is exactly Ink's behavior. Threading `mode` here keeps both
* could not (it glued an interior zero-width onto the next grapheme → line-count too low, and * line-counts in lockstep with Ink instead of measuring `hard` with `wrap` structure.
* `break`-ed on a leading zero-width + wide glyph → dropped the rest of the line). *
* 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 * 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 * 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 * wrap-ansi touch the styled string (it byte-splits the escapes at width<=0); we only ask it
* for structure. * 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 // 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. "á" → // the styled string straight to wrapAnsi) composes combining sequences (e.g. "á" →
// "á"). Deriving structure from wrapAnsi(stripAnsi(text)) yields composed rows, so the // "á"). 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 // 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). // (wrap-ansi joins line-blocks with `\n`, so each input line contributes its own block).
const styledLines = text.split("\n"); 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) { for (const styledLine of styledLines) {
const plainLine = stripAnsi(styledLine); 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 // 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). // 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 // 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 // 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). // 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") { if (mode === "wrap") {
return wrapAnsi(text, width, { hard: true, trim: false }).split("\n"); return wrapAnsi(text, width, { hard: true, trim: false }).split("\n");