fix: make Transform a yoga carrier matching Ink's ink-text behavior

Transform nodes now participate in yoga layout with flexShrink=1,
flexDirection='row', matching Ink's Transform which renders as ink-text.
This fixes multi-line text under Transform not getting proper layout
height. Transform nodes inside Text parents remain inline (excluded
from yoga tree) to preserve renderTextWithInlineStyles behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-05-26 00:59:03 +08:00
parent 8565f41827
commit 8585598d14
6 changed files with 98 additions and 12 deletions
@@ -0,0 +1,37 @@
import { defineComponent } from "vue";
import { expect, test } from "vite-plus/test";
import { render } from "@vue-tui/testing";
import { Box, Text, Transform } from "@vue-tui/runtime";
test("Transform participates in yoga layout (multi-line text)", async () => {
// When Transform is a yoga carrier, the root layout height accounts for
// multi-line text under a Transform node.
const { lastFrame } = await render(
defineComponent(() => () => (
<Transform transform={(s: string, idx: number) => `[${idx}: ${s}]`}>
<Text>{"hello\nworld"}</Text>
</Transform>
)),
{ columns: 100 },
);
// Both lines should be visible with the transform applied
expect(lastFrame()).toBe("[0: hello]\n[1: world]");
});
test("Transform defaults to flexShrink=1 and flexDirection='row'", async () => {
// Transform should behave like Ink's ink-text node with these defaults
const { lastFrame } = await render(
defineComponent(() => () => (
<Box width={20}>
<Transform transform={(s: string) => s.toUpperCase()}>
<Text>hello</Text>
</Transform>
<Text> world</Text>
</Box>
)),
{ columns: 100 },
);
// Transform and Text are siblings in a row-direction Box.
// They should be on the same line.
expect(lastFrame({ trimLines: true })).toBe("HELLO world");
});
@@ -46,9 +46,8 @@ test("squash multiple text nodes — <Transform> inside <Text>", async () => {
expect(lastFrame()).toBe("[0: {0: hello world}]");
});
test.todo(
"transform with multiple lines — transform nodes are not yoga carriers; root yoga height does not account for multi-line text under a transform node",
);
// Resolved: Transform nodes are now yoga carriers, so multi-line text
// under a Transform node is properly laid out. See transform-yoga.test.tsx.
test("squash multiple nested text nodes — <Transform> inside <Text>", async () => {
const { lastFrame } = await render(
+11 -3
View File
@@ -90,8 +90,11 @@ export function buildNodeOps(options: TtyRendererOptions): RendererOptions<TuiNo
attachYoga(n);
return n;
}
case "transform":
return createTransform((line) => line); // overwritten by patchProp
case "transform": {
const n = createTransform((line) => line); // overwritten by patchProp
attachYoga(n);
return n;
}
default:
throw new Error(`Unknown vue-tui element type: ${type}`);
}
@@ -165,7 +168,12 @@ export function buildNodeOps(options: TtyRendererOptions): RendererOptions<TuiNo
freeSubtreeYoga(child);
}
}
if (node.type === "box" || node.type === "text" || node.type === "static") {
if (
node.type === "box" ||
node.type === "text" ||
node.type === "static" ||
node.type === "transform"
) {
detachYoga(node);
}
}
+2
View File
@@ -76,6 +76,7 @@ export interface TuiStatic extends NodeBase {
export interface TuiTransform extends NodeBase {
type: "transform";
children: TuiNode[];
yoga: YogaNodeRef;
transform: (line: string, lineIndex: number) => string;
}
@@ -146,6 +147,7 @@ export function createTransform(fn: (line: string, lineIndex: number) => string)
type: "transform",
parent: null,
children: [],
yoga: UNATTACHED_YOGA,
transform: fn,
};
}
+42 -5
View File
@@ -1,8 +1,16 @@
import Yoga from "yoga-layout";
import type { Node as YogaNode, Align, FlexDirection, Justify, Wrap } from "yoga-layout";
import type { TuiBox, TuiContainer, TuiNode, TuiRoot, TuiStatic, TuiText } from "./nodes.ts";
import type {
TuiBox,
TuiContainer,
TuiNode,
TuiRoot,
TuiStatic,
TuiText,
TuiTransform,
} from "./nodes.ts";
type YogaCarrier = TuiRoot | TuiBox | TuiText | TuiStatic;
type YogaCarrier = TuiRoot | TuiBox | TuiText | TuiStatic | TuiTransform;
// --- yoga node lifecycle seam --------------------------------------------
@@ -37,7 +45,11 @@ export const yogaNodeTracker = {
function hasYoga(node: TuiNode): node is YogaCarrier {
return (
node.type === "root" || node.type === "box" || node.type === "text" || node.type === "static"
node.type === "root" ||
node.type === "box" ||
node.type === "text" ||
node.type === "static" ||
node.type === "transform"
);
}
@@ -67,6 +79,14 @@ export function attachYoga(node: YogaCarrier): void {
(node.yoga as YogaNode).setFlexShrink(1);
(node.yoga as YogaNode).setFlexGrow(0);
}
// Transform nodes match Ink's Transform which renders as ink-text:
// flexShrink=1, flexDirection='row'. This makes transform a yoga carrier
// so it participates in layout (multi-line text gets proper height).
if (node.type === "transform") {
(node.yoga as YogaNode).setFlexDirection(Yoga.FLEX_DIRECTION_ROW);
(node.yoga as YogaNode).setFlexShrink(1);
(node.yoga as YogaNode).setFlexGrow(0);
}
}
export function detachYoga(node: YogaCarrier): void {
@@ -74,24 +94,41 @@ export function detachYoga(node: YogaCarrier): void {
}
// Returns the yoga index a child should occupy when added to `parent`.
// Skips any siblings that don't carry a yoga node (virtual-text, transform).
// Skips any siblings that don't carry a yoga node or that were excluded
// from the yoga tree (e.g., transform nodes inside text parents).
function yogaIndexFor(parent: TuiContainer, child: TuiNode): number {
const isTextParent = parent.type === "text" || parent.type === "virtual-text";
let yIdx = 0;
for (const sibling of parent.children) {
if (sibling === child) return yIdx;
if (hasYoga(sibling)) yIdx++;
if (hasYoga(sibling)) {
// Transform nodes inside text parents are not in the yoga tree.
if (isTextParent && sibling.type === "transform") continue;
yIdx++;
}
}
return yIdx;
}
export function insertYogaChild(parent: TuiContainer, child: TuiNode, _domIndex: number): void {
if (!hasYoga(parent) || !hasYoga(child)) return;
// Transform nodes inside a Text parent are inline: they participate in
// renderTextWithInlineStyles, not in yoga layout. Skip inserting them
// into the yoga tree to avoid corrupting text measurement.
// (VirtualText parents are already excluded by the hasYoga check above.)
if (child.type === "transform" && parent.type === "text") {
return;
}
const yIdx = yogaIndexFor(parent, child);
(parent.yoga as YogaNode).insertChild(child.yoga as YogaNode, yIdx);
}
export function removeYogaChild(parent: TuiContainer, child: TuiNode): void {
if (!hasYoga(parent) || !hasYoga(child)) return;
// Transform nodes inside a text parent were never inserted into yoga.
if (child.type === "transform" && parent.type === "text") {
return;
}
(parent.yoga as YogaNode).removeChild(child.yoga as YogaNode);
}
+4 -1
View File
@@ -394,8 +394,11 @@ function paintNode(
return;
}
case "transform": {
const layout = node.yoga.getComputedLayout();
const x = x0 + layout.left;
const y = y0 + layout.top;
const next = [...transformers, node.transform];
for (const child of node.children) paintNode(child, output, x0, y0, next, inheritedBg);
for (const child of node.children) paintNode(child, output, x, y, next, inheritedBg);
return;
}
case "virtual-text":