From 85a5b12f45976a09c91cce9a9713e4e4d31011f4 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sat, 30 May 2026 10:23:53 +0800 Subject: [PATCH] fix(runtime): recurse into nested in squash (Ink parity, G32, MEDIUM) (#54) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(parity): record sweep-4 (G32-G38) + G32 pr-open Co-Authored-By: Claude Opus 4.8 * fix(runtime): recurse into nested in 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 nested directly inside another (inside a ) 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 --------- Co-authored-by: Claude Opus 4.8 --- .agents/docs/parity-ledger.md | 10 +++- .../integration/components/transform.test.tsx | 59 ++++++++++++++++++- packages/runtime/src/host/text-measure.ts | 53 +++++++++-------- packages/runtime/src/paint/paint.ts | 55 ++++++++++------- 4 files changed, 130 insertions(+), 47 deletions(-) diff --git a/.agents/docs/parity-ledger.md b/.agents/docs/parity-ledger.md index 267ad6c..43b590d 100644 --- a/.agents/docs/parity-ledger.md +++ b/.agents/docs/parity-ledger.md @@ -11,8 +11,9 @@ | 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-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 @@ -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 | — | — | | G30 | app-exit-instances-animation-sr | SR: nested inside a box-level 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 | — | — | +| G32 | text-wrap-transform | nested directly inside another (in ) 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 transformers; Ink renders chrome with empty transformer list | P3 | todo | — | — | +| G35 | static-newline-spacer | 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 diff --git a/packages/runtime-tests/integration/components/transform.test.tsx b/packages/runtime-tests/integration/components/transform.test.tsx index 611471c..5e5c2ce 100644 --- a/packages/runtime-tests/integration/components/transform.test.tsx +++ b/packages/runtime-tests/integration/components/transform.test.tsx @@ -1,7 +1,7 @@ import { defineComponent } from "vue"; import { expect, test } from "vite-plus/test"; 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 () => { const { lastFrame } = await render(() => ( @@ -156,6 +156,63 @@ test("nested transforms apply inner-first: outer wraps inner result", async () = expect(lastFrame()).toBe("({x})"); }); +// G32: a nested DIRECTLY inside another (both inside a +// ) 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 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 directly inside in — both apply", async () => { + const { lastFrame } = await render( + defineComponent(() => () => ( + + `[O${s}O]`}> + ``}>x + + + )), + { columns: 100 }, + ); + expect(lastFrame()).toBe("[OO]"); +}); + +test("triple-nested in — all three apply to any depth", async () => { + const { lastFrame } = await render( + defineComponent(() => () => ( + + `A${s}A`}> + `B${s}B`}> + `C${s}C`}>x + + + + )), + { columns: 100 }, + ); + expect(lastFrame()).toBe("ABCxCBA"); +}); + +test("nested -in- reserves correct width (measurement)", async () => { + // The whole box must be wide enough to hold "[OO]" (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(() => () => ( + + + `[O${s}O]`}> + ``}>x + + + | + + )), + { columns: 100 }, + ); + expect(lastFrame()).toBe("[OO]|"); +}); + test("transform with multiple lines", async () => { const { lastFrame } = await render( defineComponent(() => () => ( diff --git a/packages/runtime/src/host/text-measure.ts b/packages/runtime/src/host/text-measure.ts index 8ea7541..487cfa6 100644 --- a/packages/runtime/src/host/text-measure.ts +++ b/packages/runtime/src/host/text-measure.ts @@ -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 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 nested directly inside another +// 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; /** diff --git a/packages/runtime/src/paint/paint.ts b/packages/runtime/src/paint/paint.ts index ff414e7..48fb996 100644 --- a/packages/runtime/src/paint/paint.ts +++ b/packages/runtime/src/paint/paint.ts @@ -298,33 +298,44 @@ function renderTextWithInlineStyles(node: TuiText | TuiVirtualText, acc: TextPro // index)` receives the loop index over `node.childNodes`. A nested // 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 inside . - // 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 renders an ink-text carrying +// internal_transform) and then applies that child's transform. Because a +// nested directly inside another 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 (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(