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":