fix(runtime): recurse into nested <Transform> in <Text> squash (Ink parity, G32, MEDIUM) (#54)
* chore(parity): record sweep-4 (G32-G38) + G32 pr-open Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(runtime): recurse into nested <Transform> in <Text> squash (Ink parity, G32) paint.ts renderTextWithInlineStyles and text-measure.ts flattenLeaves now factor their per-child text squashing into a recursive squashTransformChild helper that recurses GENERICALLY into transform-typed children to any nesting depth, applying each transform with its positional sibling index and the innerText.length > 0 guard — matching Ink squashTextNodes generic recursion (squash-text-nodes.ts:22-39). Previously a <Transform> nested directly inside another <Transform> (inside a <Text>) was dropped: its grandchild loop only handled text-leaf/virtual-text/text, so a transform grandchild contributed nothing — silent total content loss in paint and 0-width measurement (broken layout). Paint and measure stay behaviourally identical so layout and output agree. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@ import cliTruncate from "cli-truncate";
|
||||
import sliceAnsi from "slice-ansi";
|
||||
import stringWidth from "string-width";
|
||||
import wrapAnsi from "wrap-ansi";
|
||||
import type { TextProps, TuiText, TuiVirtualText } from "./nodes.ts";
|
||||
import type { TextProps, TuiNode, TuiText, TuiVirtualText } from "./nodes.ts";
|
||||
|
||||
export function flattenLeaves(node: TuiText | TuiVirtualText): string {
|
||||
if (!node.children || node.children.length === 0) return "";
|
||||
@@ -13,33 +13,40 @@ export function flattenLeaves(node: TuiText | TuiVirtualText): string {
|
||||
// paint.ts renderTextWithInlineStyles so measurement and paint agree on what a
|
||||
// nested <Transform> receives as its second argument.
|
||||
node.children.forEach((child, index) => {
|
||||
if (child.type === "text-leaf") {
|
||||
out += child.value;
|
||||
} else if (child.type === "virtual-text") {
|
||||
out += flattenLeaves(child);
|
||||
} else if (child.type === "transform") {
|
||||
// Recurse into transform's children for measurement (transforms are
|
||||
// applied at paint time, not measurement time).
|
||||
let innerText = "";
|
||||
for (const grandchild of child.children) {
|
||||
if (grandchild.type === "text-leaf") {
|
||||
innerText += grandchild.value;
|
||||
} else if (grandchild.type === "virtual-text" || grandchild.type === "text") {
|
||||
innerText += flattenLeaves(grandchild);
|
||||
}
|
||||
}
|
||||
// Only apply the transform when there is actual text content — matches
|
||||
// paint.ts `innerText.length > 0` guard and Ink squash-text-nodes.ts:34
|
||||
// (`nodeText.length > 0`). Without this guard, a transform that adds chars
|
||||
// to empty text inflates measured width relative to what paint renders.
|
||||
if (innerText.length > 0 && child.transform) innerText = child.transform(innerText, index);
|
||||
out += innerText;
|
||||
}
|
||||
out += squashTransformChild(child, index);
|
||||
// Skip comments inserted by Vue for null/undefined renders
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// Squash a single child into measured text, recursing GENERICALLY into
|
||||
// transform-typed children to ANY nesting depth — mirroring Ink's
|
||||
// squash-text-nodes.ts:22-39 (the measure path dom.ts:227 uses the SAME
|
||||
// squashTextNodes as paint). A <Transform> nested directly inside another
|
||||
// <Transform> is itself a transform child and MUST be recursed into, or its
|
||||
// content is dropped and the box measures 0 width (G32). This is the measurement
|
||||
// twin of paint.ts squashTransformChild and MUST stay behaviourally identical so
|
||||
// layout and paint agree: same positional `index` (G21) and the same
|
||||
// `innerText.length > 0` guard (Ink squash-text-nodes.ts:34).
|
||||
function squashTransformChild(child: TuiNode, index: number): string {
|
||||
if (child.type === "text-leaf") {
|
||||
return child.value;
|
||||
}
|
||||
if (child.type === "virtual-text" || child.type === "text") {
|
||||
return flattenLeaves(child);
|
||||
}
|
||||
if (child.type === "transform") {
|
||||
let innerText = "";
|
||||
child.children.forEach((grandchild, grandIndex) => {
|
||||
innerText += squashTransformChild(grandchild, grandIndex);
|
||||
});
|
||||
if (innerText.length > 0 && child.transform) innerText = child.transform(innerText, index);
|
||||
return innerText;
|
||||
}
|
||||
// Comments, boxes, etc. contribute nothing to measured text.
|
||||
return "";
|
||||
}
|
||||
|
||||
export type WrapMode = NonNullable<TextProps["wrap"]>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -298,33 +298,44 @@ function renderTextWithInlineStyles(node: TuiText | TuiVirtualText, acc: TextPro
|
||||
// index)` receives the loop index over `node.childNodes`. A nested <Transform>
|
||||
// that is the Nth child therefore gets `index = N`, not a hardcoded 0.
|
||||
node.children.forEach((child, index) => {
|
||||
if (child.type === "text-leaf") {
|
||||
out += applyChalk(child.value, merged);
|
||||
} else if (child.type === "virtual-text") {
|
||||
out += renderTextWithInlineStyles(child, merged);
|
||||
} else if (child.type === "transform") {
|
||||
// Recurse into the transform's children, then apply the transform function.
|
||||
// This mirrors Ink's squashTextNodes behavior for <Transform> inside <Text>.
|
||||
// Only apply the transform when there is actual text content — Ink skips
|
||||
// transforms on empty text to avoid wrapping empty strings.
|
||||
let innerText = "";
|
||||
for (const grandchild of child.children) {
|
||||
if (grandchild.type === "text-leaf") {
|
||||
innerText += applyChalk(grandchild.value, merged);
|
||||
} else if (grandchild.type === "virtual-text" || grandchild.type === "text") {
|
||||
innerText += renderTextWithInlineStyles(grandchild, merged);
|
||||
}
|
||||
}
|
||||
if (innerText.length > 0 && child.transform) {
|
||||
innerText = child.transform(innerText, index);
|
||||
}
|
||||
out += innerText;
|
||||
}
|
||||
out += squashTransformChild(child, index, merged);
|
||||
// Skip comments inserted by Vue for null/undefined renders
|
||||
});
|
||||
return sanitizeAnsi(out);
|
||||
}
|
||||
|
||||
// Squash a single inline child into styled text, recursing GENERICALLY into
|
||||
// transform-typed children to ANY nesting depth. This mirrors Ink's
|
||||
// squash-text-nodes.ts:22-39, where the loop recurses into every ink-text /
|
||||
// ink-virtual-text child (a <Transform> renders an ink-text carrying
|
||||
// internal_transform) and then applies that child's transform. Because a
|
||||
// <Transform> nested directly inside another <Transform> is itself such a child,
|
||||
// it MUST be recursed into — otherwise the inner content is silently dropped
|
||||
// (G32). `index` is the child's positional sibling index (G21), and the transform
|
||||
// is only applied when there is actual text (Ink squash-text-nodes.ts:34).
|
||||
function squashTransformChild(child: TuiNode, index: number, merged: TextProps): string {
|
||||
if (child.type === "text-leaf") {
|
||||
return applyChalk(child.value, merged);
|
||||
}
|
||||
if (child.type === "virtual-text" || child.type === "text") {
|
||||
return renderTextWithInlineStyles(child, merged);
|
||||
}
|
||||
if (child.type === "transform") {
|
||||
let innerText = "";
|
||||
child.children.forEach((grandchild, grandIndex) => {
|
||||
// A grandchild may itself be a <Transform> (or text/virtual-text/text) —
|
||||
// recurse with the SAME logic so nesting works to any depth.
|
||||
innerText += squashTransformChild(grandchild, grandIndex, merged);
|
||||
});
|
||||
if (innerText.length > 0 && child.transform) {
|
||||
innerText = child.transform(innerText, index);
|
||||
}
|
||||
return innerText;
|
||||
}
|
||||
// Comments (null/undefined renders), boxes, etc. contribute nothing.
|
||||
return "";
|
||||
}
|
||||
|
||||
type BoxStyle = (typeof cliBoxes)[keyof cliBoxes.Boxes];
|
||||
|
||||
function drawBorder(
|
||||
|
||||
Reference in New Issue
Block a user