fix(runtime): a 0-width box wraps its text onto its own line, not drops it (Ink parity) (#103)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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(() => () => (
|
||||
<Box flexDirection="row" width={6}>
|
||||
@@ -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(() => () => (
|
||||
<Box width={6}>
|
||||
<Box width={0}>
|
||||
<Text>A</Text>
|
||||
</Box>
|
||||
<Text>B</Text>
|
||||
</Box>
|
||||
)),
|
||||
{ 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(() => () => (
|
||||
<Box width={6}>
|
||||
<Box width="0%">
|
||||
<Text>A</Text>
|
||||
</Box>
|
||||
<Text>B</Text>
|
||||
</Box>
|
||||
)),
|
||||
{ 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(() => () => (
|
||||
<Box width={6}>
|
||||
<Box width={0}>
|
||||
<Text>{""}</Text>
|
||||
</Box>
|
||||
<Text>B</Text>
|
||||
</Box>
|
||||
)),
|
||||
{ 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(() => () => (
|
||||
<Box flexDirection="row" width={6}>
|
||||
<Box width={0} backgroundColor="red">
|
||||
<Text>A</Text>
|
||||
</Box>
|
||||
<Text>B</Text>
|
||||
</Box>
|
||||
)),
|
||||
{ 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");
|
||||
});
|
||||
|
||||
@@ -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("AB", 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",
|
||||
"AB", // 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
|
||||
"ab", // soft hyphen
|
||||
"A", // BOM
|
||||
"XY中Z\nPQ", // 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
|
||||
"AB", // 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[41mAB\x1b[49m";
|
||||
const plainStructure = wrapAnsi("AB", 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("👨👩👧👦");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user