diff --git a/packages/runtime-tests/integration/components/transform-yoga.test.tsx b/packages/runtime-tests/integration/components/transform-yoga.test.tsx
new file mode 100644
index 0000000..53f0dc3
--- /dev/null
+++ b/packages/runtime-tests/integration/components/transform-yoga.test.tsx
@@ -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(() => () => (
+ `[${idx}: ${s}]`}>
+ {"hello\nworld"}
+
+ )),
+ { 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(() => () => (
+
+ s.toUpperCase()}>
+ hello
+
+ world
+
+ )),
+ { 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");
+});
diff --git a/packages/runtime-tests/integration/components/transform.test.tsx b/packages/runtime-tests/integration/components/transform.test.tsx
index 6c52bbd..c0e0a7c 100644
--- a/packages/runtime-tests/integration/components/transform.test.tsx
+++ b/packages/runtime-tests/integration/components/transform.test.tsx
@@ -46,9 +46,8 @@ test("squash multiple text nodes — inside ", 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 — inside ", async () => {
const { lastFrame } = await render(
diff --git a/packages/runtime/src/host/node-ops.ts b/packages/runtime/src/host/node-ops.ts
index ebb6aeb..a54e463 100644
--- a/packages/runtime/src/host/node-ops.ts
+++ b/packages/runtime/src/host/node-ops.ts
@@ -90,8 +90,11 @@ export function buildNodeOps(options: TtyRendererOptions): RendererOptions 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 string;
}
@@ -146,6 +147,7 @@ export function createTransform(fn: (line: string, lineIndex: number) => string)
type: "transform",
parent: null,
children: [],
+ yoga: UNATTACHED_YOGA,
transform: fn,
};
}
diff --git a/packages/runtime/src/host/yoga.ts b/packages/runtime/src/host/yoga.ts
index c7359d7..0bfc77b 100644
--- a/packages/runtime/src/host/yoga.ts
+++ b/packages/runtime/src/host/yoga.ts
@@ -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);
}
diff --git a/packages/runtime/src/paint/paint.ts b/packages/runtime/src/paint/paint.ts
index 05386a8..e800ad6 100644
--- a/packages/runtime/src/paint/paint.ts
+++ b/packages/runtime/src/paint/paint.ts
@@ -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":