From b6dc9a0919afcd8b9fce23e3b0145d19ef46d679 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sun, 31 May 2026 23:54:12 +0800 Subject: [PATCH] fix(runtime): a 0-width box wraps its text onto its own line, not drops it (Ink parity) (#103) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 0-width text container (flexBasis=0, width=0, width="0%", a negative parsed percent) DROPPED its text in vue where Ink wraps it onto its own row. Ink's wrapText has no width<=0 guard: wrapAnsi("A", 0, {hard:true, trim:false}) = "\nA" (height 2), so the glyph occupies a second row and a row-sibling renders "B\nA". vue collapsed it to height 1 (wrapText's `width <= 0 -> [""]` guard), then the paint clamp re-collapsed the wrap, so the sibling overwrote the text -> "B". Fixes, all confined to the width<=0 branch: - text-measure.ts: drop the `width <= 0 -> [""]` guard. A styled string can't go through wrapAnsi at width 0 (wrap-ansi@10 byte-splits SGR codes -> garbage like "B\n["), so the wrap/hard branch routes through a new wrapZeroWidthAnsi that derives its line STRUCTURE from wrapAnsi on the PLAIN (stripped) text — which is correct for zero-width graphemes (ZWSP/ZWNJ/ZWJ/combining/VS16/BOM, interior and trailing) — then re-applies SGR per grapheme via slice-ansi's slot model, keeping wide glyphs whole. Input is NFC-normalized first so combining sequences compose to match wrap-ansi (and vue's own normal-width path), not the decomposed source bytes. - paint.ts: pad the bg to the TRUE wrap width (0), not a >=1-clamped width — a 0-width box pads nothing (Ink getMaxWidth=0); clamping bg-padded the empty leading wrap line into a stray cell that collided with a row-sibling. A comparison-battery test locks wrapZeroWidthAnsi's plain output to wrapAnsi's width-0 layout for ~22 inputs (zero-width, wide, emoji, ZWJ, combining decomposed + composed, multiline). The full layout suite is byte-unchanged for all width>=1 cases. Co-authored-by: Claude Opus 4.8 (1M context) --- .../integration/layout/flex.test.tsx | 103 ++++++++++++--- .../runtime/src/host/text-measure.test.ts | 119 ++++++++++++++++++ packages/runtime/src/host/text-measure.ts | 112 ++++++++++++++++- packages/runtime/src/paint/paint.ts | 17 ++- 4 files changed, 332 insertions(+), 19 deletions(-) diff --git a/packages/runtime-tests/integration/layout/flex.test.tsx b/packages/runtime-tests/integration/layout/flex.test.tsx index 0698516..6d0fbc8 100644 --- a/packages/runtime-tests/integration/layout/flex.test.tsx +++ b/packages/runtime-tests/integration/layout/flex.test.tsx @@ -207,20 +207,13 @@ test("non-number/non-string flexBasis falls back to auto (Ink parity), does not expect(lastFrame({ trimLines: true })).toBe("AB"); }); -// PRE-EXISTING DOWNSTREAM DIVERGENCE — out of scope for the string→percent setter. -// A zero/negative parsed percent ("0"→0%, "-5"→-5%, "0x10"→parseInt=0→0%) produces -// a 0-width inner box. Ink renders "B\nA" (B on the row, A wraps onto the next line); -// vue renders "B" (A dropped). EVIDENCE: with width=6 and this exact tree, the frame is -// input vue-OLD (setFlexBasis(string)) vue-NEW (setFlexBasisPercent) Ink v7.0.4 -// "0" "B" "B" "B\nA" -// "-5" "B" "B" "B\nA" -// "0x10" "B" "B" "B\nA" -// vue-OLD already differed from Ink here, so this PR's setter change neither caused nor -// fixed it — the two setters yield byte-identical yoga COMPUTED layout for these inputs; -// the "B" vs "B\nA" gap is a separate downstream paint/wrap divergence. Skipped (not -// xfail-asserted as "B") so we don't lock vue's current behavior as correct: the target -// is Ink's "B\nA". Tracked separately from the flexBasis-percent setter work. -test.skip("zero/negative flexBasis% wraps the sibling in Ink (downstream divergence)", async () => { +// A zero/negative parsed percent ("0"→0%, "-5"→-5%, "0x10"→parseInt=0→0%) produces a +// 0-width inner box. Ink renders "B\nA" (B on the row, A wraps onto the next line). The +// 0-width text measures via wrapAnsi("A", 0, {hard:true, trim:false}) = "\nA" → height 2, +// so A occupies a second row. vue previously dropped the text ("B") because wrapText's +// `width <= 0 → [""]` guard collapsed the measure to height 1. Verified against Ink v7.0.4 +// (@40b3a75): all four of flexBasis=0/"0%" and width=0/"0%" render "B\nA". +test("zero/negative flexBasis% wraps the sibling in Ink (downstream divergence)", async () => { const { lastFrame } = await render( defineComponent(() => () => ( @@ -232,6 +225,86 @@ test.skip("zero/negative flexBasis% wraps the sibling in Ink (downstream diverge )), { columns: 100 }, ); - // Ink v7.0.4 renders "B\nA"; vue currently renders "B" (see comment above). + // Ink v7.0.4 renders "B\nA". expect(lastFrame({ trimLines: true })).toBe("B\nA"); }); + +test("zero-width Box wraps its text onto its own line (width={0})", async () => { + const { lastFrame } = await render( + defineComponent(() => () => ( + + + A + + B + + )), + { columns: 100 }, + ); + // Ink v7.0.4 renders "B\nA": the 0-width text measures height 2 via + // wrapAnsi("A", 0, {hard:true}) = "\nA", so A wraps below sibling B. + expect(lastFrame({ trimLines: true })).toBe("B\nA"); +}); + +test('zero-percent-width Box wraps its text onto its own line (width="0%")', async () => { + const { lastFrame } = await render( + defineComponent(() => () => ( + + + A + + B + + )), + { columns: 100 }, + ); + // Ink v7.0.4 renders "B\nA" — same as width={0}; a 0% resolved width is also 0px. + expect(lastFrame({ trimLines: true })).toBe("B\nA"); +}); + +test("zero-width Box with EMPTY text adds no spurious row", async () => { + const { lastFrame } = await render( + defineComponent(() => () => ( + + + {""} + + B + + )), + { columns: 100 }, + ); + // Ink v7.0.4 renders "B": empty text measures width 0 (≤ 0), so it never wraps and + // never gains a second row. The 0-width fix must NOT add a blank row here. + expect(lastFrame({ trimLines: true })).toBe("B"); +}); + +test("zero-width Box with backgroundColor wraps cleanly, keeping the bg glyph (Ink parity)", async () => { + // Regression guard for the wrap-ansi width<=0 byte-split: at width 0 the 0-width Box's + // text wraps onto its own row, but vue bakes the bg color INTO the string before wrapping, + // and wrap-ansi@10 byte-splits the SGR escapes of a STYLED string at width<=0 + // (wrapAnsi("\x1b[41mA\x1b[49m", 0) = "\x1b\n[\n4\n1\nm\nA\n…"). That scattered the escape + // bytes across rows and rendered a garbage "B\n[" (the 2nd byte of "\x1b[41m"). wrapText + // now routes width<=0 styled text through an ANSI-aware per-grapheme split, matching Ink, + // which wraps PLAIN text and colorizes per line afterwards. + const { lastFrame } = await render( + defineComponent(() => () => ( + + + A + + B + + )), + { columns: 100 }, + ); + // RAW-byte parity target captured from Ink v7.0.4 (@40b3a75) with chalk level 3: + // "B\n\x1b[41mA\x1b[49m\n" — row 2 keeps the FULL bg-colored glyph (overflow:visible). + // vue trims trailing whitespace/newlines per frame line, so the equivalent raw frame is + // "B\n\x1b[41mA\x1b[49m" (no trailing newline). The bg glyph must survive intact. + expect(lastFrame({ raw: true })).toBe("B\n\x1b[41mA\x1b[49m"); + // And the stripped visible layout is "B\nA" (sanity check on the wrap position). + // eslint-disable-next-line no-control-regex -- strip ANSI to assert the visible layout + const visible = lastFrame({ trimLines: true })!.replace(/\x1b\[[0-9;]*m/g, ""); + expect(visible).toBe("B\nA"); +}); diff --git a/packages/runtime/src/host/text-measure.test.ts b/packages/runtime/src/host/text-measure.test.ts index 0e50a7b..179655e 100644 --- a/packages/runtime/src/host/text-measure.test.ts +++ b/packages/runtime/src/host/text-measure.test.ts @@ -1,6 +1,7 @@ import { defineComponent, h } from "vue"; import { expect, test } from "vite-plus/test"; import stringWidth from "string-width"; +import wrapAnsi from "wrap-ansi"; import { createText, createTextLeaf, createTransform, createVirtualText } from "./nodes.ts"; import { flattenLeaves, measureTextNatural, wrapText } from "./text-measure.ts"; import { renderToString } from "../render-to-string.ts"; @@ -64,6 +65,124 @@ test("wrapText truncate-end cuts with ellipsis", () => { expect(wrapText("abcdefgh", 5, "truncate-end")).toEqual(["abcd…"]); }); +test("wrapText at width 0 wraps non-empty text onto its own line (Ink parity)", () => { + // Ink has NO width<=0 guard: wrapAnsi("A", 0, {hard:true, trim:false}) = "\nA", + // so a 0-width text occupies a SECOND row (height 2). This is what makes Ink + // render "B\nA" for a 0-width Box beside a sibling, instead of dropping the text. + expect(wrapText("A", 0, "wrap")).toEqual(["", "A"]); + expect(wrapText("A", 0, "hard")).toEqual(["", "A"]); +}); + +test("wrapText at width 0 keeps EMPTY text empty (no spurious blank row)", () => { + // Empty text measures width 0 (≤ 0), so wrapAnsi("", 0) = "" → [""] (height 1). + // The 0-width fix must not turn empty text into an extra row. + expect(wrapText("", 0, "wrap")).toEqual([""]); +}); + +test("wrapText at width 0 truncates to empty", () => { + // cliTruncate("A", 0) = "" — matches Ink's truncate path at a 0-width cell. + expect(wrapText("A", 0, "truncate")).toEqual([""]); +}); + +test("wrapText at width 0 keeps SGR codes intact on a styled string (no byte-split)", () => { + // wrap-ansi@10 byte-splits the escapes of a STYLED string at width<=0 + // (wrapAnsi("\x1b[41mA\x1b[49m", 0) = "\x1b\n[\n4\n1\nm\nA\n…"), which corrupted the + // frame to "B\n[". wrapText now splits ANSI-awarely: leading "" + one entry per grapheme + // with its SGR span preserved, matching Ink's per-grapheme colored output. + expect(wrapText("\x1b[41mA\x1b[49m", 0, "wrap")).toEqual(["", "\x1b[41mA\x1b[49m"]); + expect(wrapText("\x1b[41mAB\x1b[49m", 0, "wrap")).toEqual([ + "", + "\x1b[41mA\x1b[49m", + "\x1b[41mB\x1b[49m", + ]); + // hard mode behaves identically at width 0 (every grapheme must break anyway). + expect(wrapText("\x1b[41mAB\x1b[49m", 0, "hard")).toEqual([ + "", + "\x1b[41mA\x1b[49m", + "\x1b[41mB\x1b[49m", + ]); +}); + +test("wrapText at width 0 keeps a wide (CJK) glyph whole and styled", () => { + // A 2-column glyph must NOT be column-sliced in half; slice-ansi keeps it whole and + // re-emits its bg span — matching Ink's "\x1b[41m你\x1b[49m" on its own row. + expect(wrapText("\x1b[41m你好\x1b[49m", 0, "wrap")).toEqual([ + "", + "\x1b[41m你\x1b[49m", + "\x1b[41m好\x1b[49m", + ]); + // Mixed narrow + wide. + expect(wrapText("A你", 0, "wrap")).toEqual(["", "A", "你"]); +}); + +test("wrapText at width 0 splits each hard-newline line independently", () => { + // wrapAnsi("A\nB", 0) = "\nA\n\nB"; each input line gets a leading "" plus its graphemes. + expect(wrapText("A\nB", 0, "wrap")).toEqual(["", "A", "", "B"]); +}); + +test("wrapText at width 0 places a ZERO-WIDTH char on its OWN row (line-count parity)", () => { + // Reviewer reproducer 1: the old column-stepping slice glued the ZWSP (U+200B) onto the + // next grapheme and advanced only 1 column, yielding ["", "A", "​B"] (count 3). wrap-ansi + // places the ZWSP on its own row: ["", "A", "​", "", "B"] (count 5) — required for height + // parity with Ink (wrong line count → wrong yoga height). + expect(wrapText("A​B", 0, "wrap")).toEqual(["", "A", "​", "", "B"]); +}); + +test("wrapText at width 0 does NOT drop text after a leading zero-width + wide glyph", () => { + // Reviewer reproducer 2: the old `if (cellWidth === 0) break` abandoned the rest of the + // line when a zero-width char preceded a wide glyph, so "​中" returned [""] (中 GONE). + // wrap-ansi keeps everything: ["​", "", "中"]. + expect(wrapText("​中", 0, "wrap")).toEqual(["​", "", "中"]); +}); + +// 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. +test("wrapText at width 0 matches wrap-ansi's plain width-0 layout for the full battery", () => { + const battery = [ + "A", + "AB", + "", + " ", + "A\nB", + "A​B", // ZWSP + "​中", // ZWSP + wide + "中​A", // wide + ZWSP + "\u00e1b", // composed acute (NFC form) + "a\u0301b", // EXPLICITLY decomposed (a + U+0301): wrap-ansi NFC-composes, so wrapText must too + "\u0301a", // leading combining mark + "\u4e2d\u0301", // combining mark on a wide glyph + "⚠️", // VS16 + "🍔", // emoji + "👨‍👩‍👧", // ZWJ family + "a­b", // soft hyphen + "A", // BOM + "X​Y中​Z\nP­Q", // mixed multiline + "中​", // TRAILING zero-width: wrap-ansi glues it to the prev row (["","中​"]), not its own row + "AB​", // trailing zero-width after a narrow glyph + "A​​B", // consecutive interior zero-widths (each its own row, no extra leading "") + "中​中", // wide / interior zero-width / wide + ]; + for (const input of battery) { + const expected = wrapAnsi(input, 0, { hard: true, trim: false }).split("\n"); + expect(wrapText(input, 0, "wrap"), `input=${JSON.stringify(input)}`).toEqual(expected); + } +}); + +test("wrapText at width 0 preserves SGR styling per non-empty row with a zero-width char", () => { + // A styled input whose painted span straddles a zero-width char: each non-empty output row + // keeps its SGR span, and the line count matches the PLAIN version (structure parity). + const styled = "\x1b[41mA​B\x1b[49m"; + const plainStructure = wrapAnsi("A​B", 0, { hard: true, trim: false }).split("\n"); + const got = wrapText(styled, 0, "wrap"); + // Line count matches the plain structure exactly. + expect(got.length).toBe(plainStructure.length); + // Each non-empty row carries its red-bg SGR span; empty rows stay empty. + expect(got).toEqual(["", "\x1b[41mA\x1b[49m", "\x1b[41m​\x1b[49m", "", "\x1b[41mB\x1b[49m"]); + // Stripping the SGR from each row reproduces the plain structure. + expect(got.map(stripAnsi)).toEqual(plainStructure); +}); + test("truncate keeps ZWJ emoji whole", () => { const [line] = wrapText("👨‍👩‍👧‍👦abcdefgh", 5, "truncate"); expect(line).toContain("👨‍👩‍👧‍👦"); diff --git a/packages/runtime/src/host/text-measure.ts b/packages/runtime/src/host/text-measure.ts index 210e63b..f8ad769 100644 --- a/packages/runtime/src/host/text-measure.ts +++ b/packages/runtime/src/host/text-measure.ts @@ -2,6 +2,7 @@ import cliTruncate from "cli-truncate"; import sliceAnsi from "slice-ansi"; import stringWidth from "string-width"; import wrapAnsi from "wrap-ansi"; +import { tokenizeAnsi } from "../paint/ansi-tokenizer.ts"; import { sanitizeAnsi } from "../paint/sanitize-ansi.ts"; import type { TextProps, TuiNode, TuiText, TuiTransform, TuiVirtualText } from "./nodes.ts"; @@ -132,8 +133,103 @@ export function safeSliceEnd(text: string, maxCols: number): string { return sliced; } +// Grapheme segmenter shared across calls (constructing one is non-trivial). Locale-independent: +// we only segment, never collate, so the default locale's segmentation rules suffice. +const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); + +/** + * Strip ALL ANSI from `text`, returning only its visible code points. Reuses the paint + * tokenizer (the same one sanitizeAnsi uses) rather than a strip-ansi regex dep: every + * non-`text` token — SGR, OSC hyperlinks, control strings — is dropped, so the result is the + * exact visible string wrap-ansi must lay out. (wrap-ansi recognises SGR/OSC8 and would + * byte-split SGR at width<=0; feeding it the plain string sidesteps that bug entirely.) + */ +function stripAnsi(text: string): string { + let out = ""; + for (const token of tokenizeAnsi(text)) { + if (token.type === "text") out += token.value; + } + return out; +} + +/** + * 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). + * + * 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 + * graphemes (USUALLY one, but a trailing zero-width run makes it several — e.g. "中​"). We slice + * that run out of the STYLED text with slice-ansi via the same slot model wrap-ansi's plain + * layout implies, so slice-ansi re-emits the active SGR span around it (e.g. "\x1b[41mA\x1b[49m") + * and keeps a wide glyph whole — matching Ink's per-grapheme colored output. We never let + * 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[] { + // 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 + // styled slices must be composed too or they'd diverge (same glyph/width/line-count, but + // different code points than the normal-width path + Ink). SGR/OSC bytes are ASCII → NFC-invariant. + text = text.normalize("NFC"); + const result: 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"); + for (const styledLine of styledLines) { + const plainLine = stripAnsi(styledLine); + const plainLines = wrapAnsi(plainLine, 0, { hard: true, trim: false }).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). + // We re-style by mapping each non-empty plain line to the slot range covering its graphemes + // and slicing the STYLED text there (slice-ansi re-emits the active SGR span around it). + const slotEnds: number[] = []; // slotEnds[i] = end slot of the i-th grapheme + let slot = 0; + for (const { segment } of graphemeSegmenter.segment(plainLine)) { + slot += Math.max(1, stringWidth(segment)); + slotEnds.push(slot); + } + + // Walk wrap-ansi's plain layout. An empty row passes through verbatim; a non-empty row + // consumes as many graphemes as it contains (one, or several for a trailing zero-width run), + // and we emit the styled slice over that grapheme run's slot range. + let graphemeIndex = 0; + for (const line of plainLines) { + if (line === "") { + result.push(""); + continue; + } + const startSlot = graphemeIndex === 0 ? 0 : slotEnds[graphemeIndex - 1]!; + const graphemeCount = [...graphemeSegmenter.segment(line)].length; + graphemeIndex += graphemeCount; + const endSlot = slotEnds[graphemeIndex - 1] ?? startSlot; + result.push(sliceAnsi(styledLine, startSlot, endSlot)); + } + } + return result; +} + export function wrapText(text: string, width: number, mode: WrapMode = "wrap"): string[] { - if (width <= 0) return [""]; + // NO `width <= 0` short-circuit — Ink's wrapText (wrap-text.ts) has none either, and + // a 0-width cell is an ordinary in-range value (flexBasis=0, width=0, width="0%", a + // negative parsed percent). A 0-width cell forces non-empty text onto its OWN second + // row (height 2) — that is exactly what makes Ink render "B\nA" beside a sibling instead + // of DROPPING the text (vue's old `[""]` collapsed it to height 1, then paint overwrote + // it with the sibling → "B"). For PLAIN text wrap-ansi already does this; for STYLED text + // it would byte-corrupt the SGR codes at width<=0, so the wrap/hard branches route through + // wrapZeroWidthAnsi (ANSI-safe) instead. Empty/zero-width text is unaffected: the fast-path + // below returns [""] for it (and the yoga measure func short-circuits raw==="" before ever + // calling here), so no spurious blank row appears. Negative widths flow identically. if (mode === "wrap" || mode === "hard") { // Mirror Ink's render-node-to-output.ts:144-150: only invoke wrap-ansi when @@ -148,6 +244,20 @@ export function wrapText(text: string, width: number, mode: WrapMode = "wrap"): // exactly as Ink's `output.write` does for the unwrapped string. if (measureTextNatural(text).width <= width) return text.split("\n"); + // ANSI-safe width<=0 wrap. We reach here only for NON-empty text wider than the + // cell (the fast-path above already returned for empty/fitting text), so width<=0 + // means an undersized cell that forces every grapheme onto its own row. wrap-ansi@10 + // produces exactly that for PLAIN text — wrapAnsi("AB", 0) = "\nA\nB" (leading blank + // line, one grapheme per line) — but it has a width<=0 bug: it cannot recognise the + // SGR codes in a STYLED string and byte-splits them, so wrapAnsi("\x1b[41mA\x1b[49m", 0) + // = "\x1b\n[\n4\n1\nm\nA\n…", scattering the escape bytes across rows and corrupting + // the frame. Ink never hits this because it wraps the PLAIN squashed text and applies + // color via a per-line transform AFTER wrapping; vue bakes color into the string before + // 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 (mode === "wrap") { return wrapAnsi(text, width, { hard: true, trim: false }).split("\n"); } diff --git a/packages/runtime/src/paint/paint.ts b/packages/runtime/src/paint/paint.ts index 9159654..3b0471c 100644 --- a/packages/runtime/src/paint/paint.ts +++ b/packages/runtime/src/paint/paint.ts @@ -650,8 +650,15 @@ function paintNode( // Skip writing empty text — avoids applying line transformers to empty // content, which matches Ink's behavior of not writing empty text nodes. if (text === "") return; - const cellWidth = Math.max(1, Math.floor(layout.width)); - const wrapped = wrapText(text, cellWidth, node.props.wrap ?? "wrap"); + // Wrap at the TRUE cell width (unclamped), matching Ink's paint, which wraps at + // getMaxWidth(yogaNode) — a value that can legitimately be 0 (flexBasis=0, width=0, + // width="0%"). At width 0, wrapText returns the leading-newline wrap "\nA" → ["", "A"], + // pushing the glyph onto its own SECOND row exactly as the measure func reported + // (height 2). Clamping this to 1 would re-collapse to ["A"] on the first row, where a + // row-sibling overwrites it (the text-drop bug). Fitting text is untouched: wrapText's + // fast-path returns it verbatim. + const wrapWidth = Math.floor(layout.width); + const wrapped = wrapText(text, wrapWidth, node.props.wrap ?? "wrap"); // Pad each line to the cell width with the INHERITED Box background only — // this fills the space behind the text with the Box's bg (the Box also fills // it via fillBackground), and is the reason a Box bg pads to full width while @@ -660,10 +667,14 @@ function paintNode( // its OWN glyphs, never the surrounding Box fill. The already-rendered glyphs // in `wrapped[i]` keep their effective bg, so a `backgroundColor=""` Text // stays bare even though we pad the trailing cells with the inherited bg. + // Pad to wrapWidth (NOT a ≥1-clamped width): at width 0 there is nothing to + // pad, matching Ink (getMaxWidth=0 → no padding). Clamping to 1 here would + // bg-pad the empty leading wrap line "" into a stray 1-cell fill that + // collides with a row-sibling at the 0-width box origin. if (inheritedBg) { const padProps: TextProps = { backgroundColor: inheritedBg }; for (let i = 0; i < wrapped.length; i++) { - const pad = cellWidth - stringWidth(wrapped[i]!); + const pad = wrapWidth - stringWidth(wrapped[i]!); if (pad > 0) { wrapped[i] = wrapped[i]! + applyChalk(" ".repeat(pad), padProps); }