fix: measure text naturally like Ink, wrap only when constrained (closes #21 height/wrap classes)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
import { defineComponent } from "vue";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import { render } from "@vue-tui/testing";
|
||||
import { Box, Text } from "@vue-tui/runtime";
|
||||
import stripAnsi from "strip-ansi";
|
||||
|
||||
test("multi-line truncate text keeps its line count (height)", async () => {
|
||||
const { lastFrame } = await render(
|
||||
defineComponent(() => () => (
|
||||
<Box width={24}>
|
||||
<Text wrap="truncate">{"x\nyhello"}</Text>
|
||||
</Box>
|
||||
)),
|
||||
{ columns: 100 },
|
||||
);
|
||||
const lines = stripAnsi(lastFrame({ trimLines: true })!).split("\n");
|
||||
expect(lines.length).toBe(2);
|
||||
expect(lines[0]).toBe("x");
|
||||
expect(lines[1]).toBe("yhello");
|
||||
});
|
||||
@@ -2,7 +2,7 @@ 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 { flattenLeaves, measureTextNatural, wrapText } from "./text-measure.ts";
|
||||
import { renderToString } from "../render-to-string.ts";
|
||||
import { Box } from "../components/Box.ts";
|
||||
import { Text } from "../components/Text.ts";
|
||||
@@ -267,6 +267,12 @@ test("ZWJ emoji truncation does not exceed requested width", () => {
|
||||
expect(stringWidth(result[0]!)).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("measureTextNatural uses widest line and raw line count", () => {
|
||||
expect(measureTextNatural("x\nyhello")).toEqual({ width: 6, height: 2 });
|
||||
expect(measureTextNatural("中文\nx")).toEqual({ width: 4, height: 2 });
|
||||
expect(measureTextNatural("")).toEqual({ width: 0, height: 1 });
|
||||
});
|
||||
|
||||
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
|
||||
|
||||
@@ -66,6 +66,13 @@ export function wrapText(text: string, width: number, mode: WrapMode = "wrap"):
|
||||
|
||||
// truncate variants — delegate to cli-truncate (grapheme-aware, ellipsis
|
||||
// within budget, preserves \n). Matches Ink's wrapText truncate path.
|
||||
//
|
||||
// Optimisation: if every line already fits within `width`, return lines
|
||||
// as-is. This avoids cliTruncate treating the whole multi-line string as
|
||||
// a single run when no truncation is actually needed (which would collapse
|
||||
// perfectly-fitting multi-line text to one truncated line).
|
||||
const lines = text.split("\n");
|
||||
if (lines.every((l) => stringWidth(l) <= width)) return lines;
|
||||
const position =
|
||||
mode === "truncate-start" ? "start" : mode === "truncate-middle" ? "middle" : "end";
|
||||
return cliTruncate(text, width, { position }).split("\n");
|
||||
@@ -82,3 +89,14 @@ export function measureText(
|
||||
height: wrapped.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Natural (unwrapped) dimensions of `text`, mode-independent. Mirrors Ink's
|
||||
* measure-text.js: width = widest line, height = number of \n-separated lines.
|
||||
*/
|
||||
export function measureTextNatural(text: string): { width: number; height: number } {
|
||||
const lines = text.split("\n");
|
||||
let width = 0;
|
||||
for (const line of lines) width = Math.max(width, stringWidth(line));
|
||||
return { width, height: lines.length };
|
||||
}
|
||||
|
||||
@@ -314,7 +314,7 @@ export function applyYogaProp(node: YogaCarrier, key: string, value: unknown): v
|
||||
|
||||
// --- text measure binding ------------------------------------------------
|
||||
|
||||
import { flattenLeaves, measureText } from "./text-measure.ts";
|
||||
import { flattenLeaves, measureTextNatural, wrapText } from "./text-measure.ts";
|
||||
|
||||
export function bindTextMeasure(text: TuiText): void {
|
||||
text.yoga.setMeasureFunc((availableWidth) => {
|
||||
@@ -325,7 +325,7 @@ export function bindTextMeasure(text: TuiText): void {
|
||||
// so yoga doesn't crash trying to measure an empty string.
|
||||
if (raw === "") return { width: 0, height: 0 };
|
||||
|
||||
const natural = measureText(raw, Infinity, text.props.wrap ?? "wrap");
|
||||
const natural = measureTextNatural(raw);
|
||||
|
||||
// Text fits into container, no need to wrap.
|
||||
if (natural.width <= availableWidth) return natural;
|
||||
@@ -338,7 +338,8 @@ export function bindTextMeasure(text: TuiText): void {
|
||||
return natural;
|
||||
}
|
||||
|
||||
return measureText(raw, availableWidth, text.props.wrap ?? "wrap");
|
||||
const wrapped = wrapText(raw, availableWidth, text.props.wrap ?? "wrap");
|
||||
return measureTextNatural(wrapped.join("\n"));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -38,4 +38,4 @@ export {
|
||||
type KittyKeyboardOptions,
|
||||
type KittyFlagName,
|
||||
} from "./io/kitty-keyboard.ts";
|
||||
export { measureText } from "./host/text-measure.ts";
|
||||
export { measureText, measureTextNatural } from "./host/text-measure.ts";
|
||||
|
||||
Reference in New Issue
Block a user