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:
@@ -11,8 +11,9 @@
|
|||||||
| sweep-1 (2026-05-29) | `40b3a75` | 16 verified → 14 confirmed (2 refuted) | recorded |
|
| sweep-1 (2026-05-29) | `40b3a75` | 16 verified → 14 confirmed (2 refuted) | recorded |
|
||||||
| sweep-2 (2026-05-30) | `40b3a75` | 8 confirmed (re-audit after 16 fixes merged) → 6 new gaps (G18-G23) + 1 candidate (G24) | recorded |
|
| sweep-2 (2026-05-30) | `40b3a75` | 8 confirmed (re-audit after 16 fixes merged) → 6 new gaps (G18-G23) + 1 candidate (G24) | recorded |
|
||||||
| sweep-3 (2026-05-30) | `40b3a75` | 8 confirmed (re-audit, 22 fixes merged) → 7 new gaps (G25-G31, all LOW) + 1 refuted | recorded |
|
| sweep-3 (2026-05-30) | `40b3a75` | 8 confirmed (re-audit, 22 fixes merged) → 7 new gaps (G25-G31, all LOW) + 1 refuted | recorded |
|
||||||
|
| sweep-4 (2026-05-30) | `40b3a75` | confirmation re-audit → 7 new (2 MEDIUM: G32-G33; 5 low: G34-G38) + 2 refuted-reversed | recorded |
|
||||||
|
|
||||||
Refuted (NOT gaps — kept for the record): `exit()` second-wins — vue-tui is already guarded, not last-wins as earlier suspected; kitty key-release printable-text suppression — Ink behaves the same.
|
Refuted (NOT gaps): ~~`exit()` second-wins — vue-tui is already guarded~~ **REVERSED by sweep-4: it IS last-wins vs Ink first-wins → now tracked as G33**; kitty key-release printable-text suppression — Ink behaves the same.
|
||||||
|
|
||||||
## Decisions log
|
## Decisions log
|
||||||
|
|
||||||
@@ -68,6 +69,13 @@ Non-obvious calls made while fixing gaps, recorded for review in the final repor
|
|||||||
| G29 | render-lifecycle-reconciler | useCursor()/setCursorPosition never applied during normal render commits (only after console writes) | P3 | todo | — | — |
|
| G29 | render-lifecycle-reconciler | useCursor()/setCursorPosition never applied during normal render commits (only after console writes) | P3 | todo | — | — |
|
||||||
| G30 | app-exit-instances-animation-sr | SR: nested <Transform> inside a box-level <Transform> drops the INNER transform fn (refines G23) | P3 | todo | — | — |
|
| G30 | app-exit-instances-animation-sr | SR: nested <Transform> inside a box-level <Transform> drops the INNER transform fn (refines G23) | P3 | todo | — | — |
|
||||||
| G31 | app-exit-instances-animation-sr | useAnimation `interval` option is not reactive; Ink re-subscribes+resets when interval changes (cf. G08 id-reactivity) | P3 | todo | — | — |
|
| G31 | app-exit-instances-animation-sr | useAnimation `interval` option is not reactive; Ink re-subscribes+resets when interval changes (cf. G08 id-reactivity) | P3 | todo | — | — |
|
||||||
|
| G32 | text-wrap-transform | <Transform> nested directly inside another <Transform> (in <Text>) is silently DROPPED — paint+measure lose all content | P1 | todo | — | — |
|
||||||
|
| G33 | app-exit-instances-animation-sr | exit() resolves last-call-wins; Ink is first-call-wins (REVERSES the sweep-1 exit()-second-wins refutation) | P1 | todo | — | — |
|
||||||
|
| G34 | box-layout-border | Box border glyphs + bg fill are subject to ancestor <Transform> transformers; Ink renders chrome with empty transformer list | P3 | todo | — | — |
|
||||||
|
| G35 | static-newline-spacer | <Static> container's own borderStyle/backgroundColor (non-yoga visual style) not painted | P3 | todo | — | — |
|
||||||
|
| G36 | focus | useFocus autoFocus prop not reactive (captured once); Ink re-registers on autoFocus change | P3 | todo | — | — |
|
||||||
|
| G37 | render-lifecycle-reconciler | onRender renderTime metric includes stdout write + static capture, not just paint | P3 | todo | — | — |
|
||||||
|
| G38 | app-exit-instances-animation-sr | useAnimation/scheduler quantizes interval via Math.round; Ink preserves fractional intervals | P3 | todo | — | — |
|
||||||
|
|
||||||
## Gap details
|
## Gap details
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { defineComponent } from "vue";
|
import { defineComponent } from "vue";
|
||||||
import { expect, test } from "vite-plus/test";
|
import { expect, test } from "vite-plus/test";
|
||||||
import { render } from "@vue-tui/testing";
|
import { render } from "@vue-tui/testing";
|
||||||
import { Text, Transform } from "@vue-tui/runtime";
|
import { Box, Text, Transform } from "@vue-tui/runtime";
|
||||||
|
|
||||||
test("Transform uppercases descendant text", async () => {
|
test("Transform uppercases descendant text", async () => {
|
||||||
const { lastFrame } = await render(() => (
|
const { lastFrame } = await render(() => (
|
||||||
@@ -156,6 +156,63 @@ test("nested transforms apply inner-first: outer wraps inner result", async () =
|
|||||||
expect(lastFrame()).toBe("({x})");
|
expect(lastFrame()).toBe("({x})");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// G32: a <Transform> nested DIRECTLY inside another <Transform> (both inside a
|
||||||
|
// <Text>) must be recursed into during squash so BOTH transforms run. Ink's
|
||||||
|
// squash-text-nodes.ts:22-39 recurses generically into any ink-text/ink-virtual-text
|
||||||
|
// child (a <Transform> renders an ink-text with internal_transform) — inner applied
|
||||||
|
// first, then outer. Previously vue-tui dropped the inner transform's content
|
||||||
|
// entirely (silent total content loss + 0-width measurement).
|
||||||
|
test("nested <Transform> directly inside <Transform> in <Text> — both apply", async () => {
|
||||||
|
const { lastFrame } = await render(
|
||||||
|
defineComponent(() => () => (
|
||||||
|
<Text>
|
||||||
|
<Transform transform={(s: string) => `[O${s}O]`}>
|
||||||
|
<Transform transform={(s: string) => `<I${s}I>`}>x</Transform>
|
||||||
|
</Transform>
|
||||||
|
</Text>
|
||||||
|
)),
|
||||||
|
{ columns: 100 },
|
||||||
|
);
|
||||||
|
expect(lastFrame()).toBe("[O<IxI>O]");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("triple-nested <Transform> in <Text> — all three apply to any depth", async () => {
|
||||||
|
const { lastFrame } = await render(
|
||||||
|
defineComponent(() => () => (
|
||||||
|
<Text>
|
||||||
|
<Transform transform={(s: string) => `A${s}A`}>
|
||||||
|
<Transform transform={(s: string) => `B${s}B`}>
|
||||||
|
<Transform transform={(s: string) => `C${s}C`}>x</Transform>
|
||||||
|
</Transform>
|
||||||
|
</Transform>
|
||||||
|
</Text>
|
||||||
|
)),
|
||||||
|
{ columns: 100 },
|
||||||
|
);
|
||||||
|
expect(lastFrame()).toBe("ABCxCBA");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("nested <Transform>-in-<Transform> reserves correct width (measurement)", async () => {
|
||||||
|
// The whole box must be wide enough to hold "[O<IxI>O]" (9 cols) — if the inner
|
||||||
|
// transform were dropped at measure time the box would reserve width 0/3 and the
|
||||||
|
// sibling marker would overlap. Place a sibling after the text and assert it lands
|
||||||
|
// at the expected column, proving measurement counted the full transformed width.
|
||||||
|
const { lastFrame } = await render(
|
||||||
|
defineComponent(() => () => (
|
||||||
|
<Box flexDirection="row">
|
||||||
|
<Text>
|
||||||
|
<Transform transform={(s: string) => `[O${s}O]`}>
|
||||||
|
<Transform transform={(s: string) => `<I${s}I>`}>x</Transform>
|
||||||
|
</Transform>
|
||||||
|
</Text>
|
||||||
|
<Text>|</Text>
|
||||||
|
</Box>
|
||||||
|
)),
|
||||||
|
{ columns: 100 },
|
||||||
|
);
|
||||||
|
expect(lastFrame()).toBe("[O<IxI>O]|");
|
||||||
|
});
|
||||||
|
|
||||||
test("transform with multiple lines", async () => {
|
test("transform with multiple lines", async () => {
|
||||||
const { lastFrame } = await render(
|
const { lastFrame } = await render(
|
||||||
defineComponent(() => () => (
|
defineComponent(() => () => (
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import cliTruncate from "cli-truncate";
|
|||||||
import sliceAnsi from "slice-ansi";
|
import sliceAnsi from "slice-ansi";
|
||||||
import stringWidth from "string-width";
|
import stringWidth from "string-width";
|
||||||
import wrapAnsi from "wrap-ansi";
|
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 {
|
export function flattenLeaves(node: TuiText | TuiVirtualText): string {
|
||||||
if (!node.children || node.children.length === 0) return "";
|
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
|
// paint.ts renderTextWithInlineStyles so measurement and paint agree on what a
|
||||||
// nested <Transform> receives as its second argument.
|
// nested <Transform> receives as its second argument.
|
||||||
node.children.forEach((child, index) => {
|
node.children.forEach((child, index) => {
|
||||||
if (child.type === "text-leaf") {
|
out += squashTransformChild(child, index);
|
||||||
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;
|
|
||||||
}
|
|
||||||
// Skip comments inserted by Vue for null/undefined renders
|
// Skip comments inserted by Vue for null/undefined renders
|
||||||
});
|
});
|
||||||
return out;
|
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"]>;
|
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>
|
// index)` receives the loop index over `node.childNodes`. A nested <Transform>
|
||||||
// that is the Nth child therefore gets `index = N`, not a hardcoded 0.
|
// that is the Nth child therefore gets `index = N`, not a hardcoded 0.
|
||||||
node.children.forEach((child, index) => {
|
node.children.forEach((child, index) => {
|
||||||
if (child.type === "text-leaf") {
|
out += squashTransformChild(child, index, merged);
|
||||||
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;
|
|
||||||
}
|
|
||||||
// Skip comments inserted by Vue for null/undefined renders
|
// Skip comments inserted by Vue for null/undefined renders
|
||||||
});
|
});
|
||||||
return sanitizeAnsi(out);
|
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];
|
type BoxStyle = (typeof cliBoxes)[keyof cliBoxes.Boxes];
|
||||||
|
|
||||||
function drawBorder(
|
function drawBorder(
|
||||||
|
|||||||
Reference in New Issue
Block a user