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:
@@ -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(() => () => (
|
||||
<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 () => {
|
||||
const { lastFrame } = await render(
|
||||
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(["", "", "中"]);
|
||||
});
|
||||
|
||||
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",
|
||||
"AB", // ZWSP
|
||||
"中", // ZWSP + wide
|
||||
"中A", // wide + ZWSP
|
||||
"áb", // composed acute (NFC form)
|
||||
"áb", // EXPLICITLY decomposed
|
||||
"⚠️", // VS16
|
||||
"🍔", // emoji
|
||||
"👨👩👧", // ZWJ family
|
||||
"ab", // soft hyphen
|
||||
"XY中Z\nPQ", // 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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user