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:
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user