diff --git a/packages/runtime-tests/integration/layout/text-measure-parity.test.tsx b/packages/runtime-tests/integration/layout/text-measure-parity.test.tsx new file mode 100644 index 0000000..db50009 --- /dev/null +++ b/packages/runtime-tests/integration/layout/text-measure-parity.test.tsx @@ -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(() => () => ( + + {"x\nyhello"} + + )), + { 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"); +}); diff --git a/packages/runtime/src/host/text-measure.test.ts b/packages/runtime/src/host/text-measure.test.ts index 90937a8..bef8c99 100644 --- a/packages/runtime/src/host/text-measure.test.ts +++ b/packages/runtime/src/host/text-measure.test.ts @@ -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 diff --git a/packages/runtime/src/host/text-measure.ts b/packages/runtime/src/host/text-measure.ts index dc8b45f..8a130b0 100644 --- a/packages/runtime/src/host/text-measure.ts +++ b/packages/runtime/src/host/text-measure.ts @@ -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 }; +} diff --git a/packages/runtime/src/host/yoga.ts b/packages/runtime/src/host/yoga.ts index fccab58..80ad6dc 100644 --- a/packages/runtime/src/host/yoga.ts +++ b/packages/runtime/src/host/yoga.ts @@ -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")); }); } diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index d268822..72e18dd 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -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";