test: add text-width parity tests from Ink (+9)

Port 9 text-width tests from Ink's test suite covering wide characters
in fixed-width Boxes, CJK width calculation, mixed ASCII+wide chars,
ANSI styled text layout, empty Text siblings, and CJK truncation
(end/middle/start/box-width). Also fix CJK truncation bug where
slice-ansi would overshoot on wide character boundaries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-05-27 15:23:25 +08:00
parent f5de77a426
commit ec67193250
2 changed files with 257 additions and 4 deletions
@@ -1,6 +1,17 @@
import { defineComponent, h } from "vue";
import { expect, test } from "vite-plus/test";
import stringWidth from "string-width";
import { createText, createTextLeaf, createVirtualText } from "./nodes.ts";
import { flattenLeaves, wrapText } from "./text-measure.ts";
import { renderToString } from "../render-to-string.ts";
import { Box } from "../components/Box.ts";
import { Text } from "../components/Text.ts";
// Minimal ANSI-stripping helper for test assertions (avoids strip-ansi dep).
function stripAnsi(s: string): string {
// eslint-disable-next-line no-control-regex
return s.replace(/\x1B\[[0-9;]*[A-Za-z]/g, "");
}
test("flattenLeaves concatenates a flat text node", () => {
const t = createText();
@@ -32,3 +43,210 @@ test("wrapText splits on width", () => {
test("wrapText truncate-end cuts with ellipsis", () => {
expect(wrapText("abcdefgh", 5, "truncate-end")).toEqual(["abcd…"]);
});
// --- Text-width parity tests ported from Ink ---
test("wide characters do not add extra space inside fixed-width Box", () => {
const App = defineComponent(
() => () =>
h(Box, { flexDirection: "column" }, () => [
h(Box, null, () => [
h(Box, { width: 2 }, () => h(Text, null, () => "\u{1F354}")),
h(Text, null, () => "|"),
]),
h(Box, null, () => [
h(Box, { width: 2 }, () => h(Text, null, () => "⏳")),
h(Text, null, () => "|"),
]),
]),
);
const output = renderToString(App, { columns: 40 });
const lines = output.split("\n");
expect(lines.length).toBe(2);
expect(lines[0]).toBe("\u{1F354}|");
expect(lines[1]).toBe("⏳|");
});
test("CJK characters occupy correct width in fixed-width Box", () => {
const App = defineComponent(
() => () =>
h(Box, null, () => [
h(Box, { width: 4 }, () => h(Text, null, () => "你好")),
h(Text, null, () => "|"),
]),
);
const output = renderToString(App, { columns: 40 });
expect(output).toBe("你好|");
});
test("mixed ASCII and wide characters align correctly", () => {
const App = defineComponent(
() => () =>
h(Box, { flexDirection: "column" }, () => [
h(Box, null, () => [
h(Box, { width: 6 }, () => h(Text, null, () => "ab\u{1F354}cd")),
h(Text, null, () => "|"),
]),
h(Box, null, () => [
h(Box, { width: 6 }, () => h(Text, null, () => "abcdef")),
h(Text, null, () => "|"),
]),
]),
);
const output = renderToString(App, { columns: 40 });
const lines = output.split("\n");
expect(lines.length).toBe(2);
expect(lines[0]).toBe("ab\u{1F354}cd|");
expect(lines[1]).toBe("abcdef|");
});
test("ANSI styled text does not affect layout width", () => {
const App = defineComponent(
() => () =>
h(Box, null, () => [
h(Box, { width: 5 }, () => h(Text, { color: "red" }, () => "hello")),
h(Text, null, () => "|"),
]),
);
const output = renderToString(App, { columns: 40 });
const stripped = stripAnsi(output);
expect(stripped).toBe("hello|");
});
test("empty Text does not affect sibling layout", () => {
const App = defineComponent(
() => () => h(Box, null, () => [h(Text), h(Text, null, () => "hello")]),
);
const output = renderToString(App, { columns: 40 });
expect(output).toBe("hello");
});
test("truncate CJK text at end", () => {
const App = defineComponent(
() => () =>
h(Box, { width: 20 }, () => h(Text, { wrap: "truncate" }, () => "あいうえおかきくけこ|end")),
);
const output = renderToString(App, { columns: 40 });
const stripped = stripAnsi(output);
expect(stringWidth(stripped)).toBeLessThanOrEqual(20);
});
test("truncate CJK text in the middle", () => {
const App = defineComponent(
() => () =>
h(Box, { width: 20 }, () =>
h(Text, { wrap: "truncate-middle" }, () => "あいうえおかきくけこ|end"),
),
);
const output = renderToString(App, { columns: 40 });
const stripped = stripAnsi(output);
expect(stringWidth(stripped)).toBeLessThanOrEqual(20);
});
test("truncate CJK text at start", () => {
const App = defineComponent(
() => () =>
h(Box, { width: 20 }, () =>
h(Text, { wrap: "truncate-start" }, () => "あいうえおかきくけこ|end"),
),
);
const output = renderToString(App, { columns: 40 });
const stripped = stripAnsi(output);
expect(stringWidth(stripped)).toBeLessThanOrEqual(20);
});
test("truncate CJK text does not exceed Box width", () => {
const App = defineComponent(
() => () =>
h(Box, null, () => [
h(Box, { width: 20 }, () =>
h(Text, { wrap: "truncate" }, () => "あいうえおかきくけこ|end"),
),
h(Text, null, () => "|"),
]),
);
const output = renderToString(App, { columns: 40 });
const lines = output.split("\n");
expect(lines.length).toBe(1);
const stripped = stripAnsi(lines[0]!);
expect(stripped.endsWith("|")).toBe(true);
});
test("overlay on 2nd cell of CJK character clears the full character", () => {
// Absolute overlay at left=9 lands on the 2nd cell of お (columns 8-9).
// お should be replaced by a space so the terminal doesn't render
// a half-visible wide character.
const App = defineComponent(
() => () =>
h(Box, { width: 20, height: 1 }, () => [
h(Text, null, () => "あいうえおかきくけこ"),
h(Box, { position: "absolute", left: 9 }, () => h(Text, null, () => "XYZ")),
]),
);
const output = renderToString(App, { columns: 20 });
const lines = output.split("\n");
expect(stringWidth(lines[0]!)).toBe(20);
expect(stripAnsi(lines[0]!)).toBe("あいうえ XYZきくけこ");
});
test("overlay on 1st cell of CJK character clears trailing placeholder", () => {
// Absolute overlay at left=10 lands on the 1st cell of か (columns 10-11).
// か's trailing placeholder at column 11 should be cleared to a space.
const App = defineComponent(
() => () =>
h(Box, { width: 20, height: 1 }, () => [
h(Text, null, () => "あいうえおかきくけこ"),
h(Box, { position: "absolute", left: 10 }, () => h(Text, null, () => "X")),
]),
);
const output = renderToString(App, { columns: 20 });
const lines = output.split("\n");
expect(stringWidth(lines[0]!)).toBe(20);
expect(stripAnsi(lines[0]!)).toBe("あいうえおX きくけこ");
});
test("CJK overlay on 2nd cell of CJK clears both sides", () => {
// Absolute overlay at left=5 (2nd cell of う at columns 4-5).
// 漢字テスト (10 cols) also ends at column 14, overwriting the 1st cell
// of く (14-15), so く's trailing placeholder must be cleaned too.
const App = defineComponent(
() => () =>
h(Box, { width: 20, height: 1 }, () => [
h(Text, null, () => "あいうえおかきくけこ"),
h(Box, { position: "absolute", left: 5 }, () => h(Text, null, () => "漢字テスト")),
]),
);
const output = renderToString(App, { columns: 20 });
const lines = output.split("\n");
expect(stringWidth(lines[0]!)).toBe(20);
expect(stripAnsi(lines[0]!)).toBe("あい 漢字テスト けこ");
});
test("clipped empty write does not corrupt existing wide characters", () => {
// When a write is clipped to an empty string, the boundary cleanup
// must not run, otherwise it would destroy a wide character that
// isn't actually being overwritten.
const App = defineComponent(
() => () =>
h(Box, { width: 4, height: 1, overflowX: "hidden" }, () => [
h(Text, null, () => "あい"),
h(Box, { position: "absolute", left: -1, width: 1 }, () => h(Text, null, () => "Z")),
]),
);
const output = renderToString(App, { columns: 4 });
expect(stripAnsi(output)).toBe("あい");
});
+39 -4
View File
@@ -32,6 +32,40 @@ export function flattenLeaves(node: TuiText | TuiVirtualText): string {
export type WrapMode = NonNullable<TextProps["wrap"]>;
/**
* Slice `text` from the start so the result is at most `maxCols` columns wide.
* `slice-ansi` can overshoot when a wide character straddles the boundary, so
* we reduce the slice position until the result fits.
*/
function safeSliceEnd(text: string, maxCols: number): string {
if (maxCols <= 0) return "";
let sliced = sliceAnsi(text, 0, maxCols);
let w = stringWidth(sliced);
while (w > maxCols && maxCols > 0) {
maxCols--;
sliced = sliceAnsi(text, 0, maxCols);
w = stringWidth(sliced);
}
return sliced;
}
/**
* Slice `text` from the end so the result is at most `maxCols` columns wide.
*/
function safeSliceStart(text: string, maxCols: number): string {
if (maxCols <= 0) return "";
const totalWidth = stringWidth(text);
let start = totalWidth - maxCols;
let sliced = sliceAnsi(text, start);
let w = stringWidth(sliced);
while (w > maxCols && start < totalWidth) {
start++;
sliced = sliceAnsi(text, start);
w = stringWidth(sliced);
}
return sliced;
}
export function wrapText(text: string, width: number, mode: WrapMode = "wrap"): string[] {
if (width <= 0) return [""];
@@ -54,13 +88,14 @@ export function wrapText(text: string, width: number, mode: WrapMode = "wrap"):
switch (mode) {
case "truncate":
case "truncate-end":
return [sliceAnsi(single, 0, room) + ellipsis];
return [safeSliceEnd(single, room) + ellipsis];
case "truncate-start":
return [ellipsis + sliceAnsi(single, stringWidth(single) - room)];
return [ellipsis + safeSliceStart(single, room)];
case "truncate-middle": {
const half = Math.floor(room / 2);
const left = sliceAnsi(single, 0, half);
const right = sliceAnsi(single, stringWidth(single) - (room - half));
const left = safeSliceEnd(single, half);
const rightCols = room - stringWidth(left);
const right = safeSliceStart(single, rightCols);
return [left + ellipsis + right];
}
}