diff --git a/.agents/docs/parity-ledger.md b/.agents/docs/parity-ledger.md index 13c88c2..f26bf0d 100644 --- a/.agents/docs/parity-ledger.md +++ b/.agents/docs/parity-ledger.md @@ -109,7 +109,7 @@ Non-obvious calls made while fixing gaps, recorded for review in the final repor | G61 | stdout-stderr-stdin-size-cursor | Bracketed-paste disable write during stdin dispose() lacks Ink's destroyed/writableEnded (canWriteToStdout) guard — sweep-8 LOW | P3 | todo | — | — | | G62 | render-lifecycle-reconciler | resolveExit()/teardown() writable-stream checks omit the writableLength fallback + stdout.writable flag that Ink's getWritableStreamState uses — sweep-8 LOW | P3 | todo | — | — | | G63 | text-wrap-transform | Transform applied to the FULL line THEN horizontally clipped; Ink clips THEN transforms (output.ts), so a width-sensitive transform (gradient / OSC-8 hyperlink) inside an overflowX:hidden box gets the wrong char span (MEDIUM, sweep-9) | P1 | pr-open | `fix/parity-clip-then-transform` | #63 | -| G64 | static-newline-spacer | `` isolated paint forces terminal width; Ink content-sizes the static box (position:absolute, auto width) so flex-fill children (Spacer/flexGrow/justify/align/percent) collapse to content — refines G44's over-correction (MEDIUM, sweep-9) | P1 | todo | — | — | +| G64 | static-newline-spacer | `` isolated paint forces terminal width; Ink content-sizes the static box (position:absolute, auto width) so flex-fill children (Spacer/flexGrow/justify/align/percent) collapse to content — refines G44's over-correction (MEDIUM, sweep-9) | P1 | pr-open | `fix/parity-static-content-size` | #64 | | G65 | app-exit-instances-animation-sr | SR unchanged-frame skip compares WRAPPED output, not Ink's UNWRAPPED linearization — diverges on resize with identical content — sweep-9 LOW | P3 | todo | — | — | | G66 | app-exit-instances-animation-sr | app.waitUntilRenderFlush() lacks Ink's unmount/unmounting short-circuit to awaitExit() — sweep-9 LOW | P3 | todo | — | — | diff --git a/packages/runtime-tests/integration/components/static.test.tsx b/packages/runtime-tests/integration/components/static.test.tsx index 832a3af..eb72be9 100644 --- a/packages/runtime-tests/integration/components/static.test.tsx +++ b/packages/runtime-tests/integration/components/static.test.tsx @@ -2,7 +2,7 @@ import { PassThrough } from "node:stream"; import { defineComponent, nextTick, onUnmounted, shallowRef } from "vue"; import { expect, test } from "vite-plus/test"; import { render } from "@vue-tui/testing"; -import { Box, Text, Static, createApp } from "@vue-tui/runtime"; +import { Box, Text, Static, Spacer, createApp } from "@vue-tui/runtime"; test("Static appends new items above the dynamic frame", async () => { const items = shallowRef([]); @@ -519,6 +519,232 @@ test("Static honors paddingLeft in the isolated paint (Ink parity, G44)", async expect(staticFrame).toContain(" X"); }); +// Ink reference: src/components/Static.tsx sets the static box style +// `{position:'absolute', flexDirection:'column', ...customStyle}` with NO width, +// and ink.tsx calculateLayout NEVER sets the static node's width — so renderer.ts +// reads node.staticNode.yogaNode.getComputedWidth() of a yoga absolute, +// auto-width node, which shrinks to its CONTENT. So flex-fill children +// (, flexGrow, justifyContent, percent width) inside a Static item +// COLLAPSE to content width rather than expanding to the terminal width. +// (G64 — refines G44, which over-forced the iso root to terminal width.) +// +// Confirmed against the built Ink reference (v7.0.4, /tmp/ink-40b3a75/build, +// cols=80): a Static item Box row [Text LEFT][Spacer][Text RIGHT] renders the +// static frame "LEFTRIGHT" (9 chars; the Spacer collapses to 0), NOT +// "LEFT" + 71 spaces + "RIGHT". +test("Static content-sizes an auto-width item: Spacer collapses (Ink parity, G64)", async () => { + const items = shallowRef([]); + + const App = defineComponent(() => () => ( + + + {{ + default: ({ item }: { item: string }) => ( + + LEFT + + RIGHT + + ), + }} + + [live] + + )); + + const { frames } = await render(App, { columns: 80 }); + + items.value = ["one"]; + await nextTick(); + + // The static frame must content-size to "LEFTRIGHT" (Spacer collapses to 0), + // matching Ink. The buggy path forces the iso root to terminal width (80), so + // the Spacer expands and the line becomes "LEFT" + 71 spaces + "RIGHT". + const staticFrame = frames.find((f) => f.includes("LEFT") && f.includes("RIGHT")); + expect(staticFrame).toBeDefined(); + expect(staticFrame).toContain("LEFTRIGHT"); + expect(staticFrame).not.toMatch(/LEFT {2,}RIGHT/); +}); + +// flexGrow box inside an auto-width Static item also collapses to content width. +// Confirmed against Ink ref (cols=80): row [Text A][Box flexGrow=1][Text B] → +// static frame "AB" (the flexGrow box collapses to 0). +test("Static content-sizes an auto-width item: flexGrow collapses (Ink parity, G64)", async () => { + const items = shallowRef([]); + + const App = defineComponent(() => () => ( + + + {{ + default: ({ item }: { item: string }) => ( + + A + + B + + ), + }} + + [live] + + )); + + const { frames } = await render(App, { columns: 80 }); + + items.value = ["one"]; + await nextTick(); + + const staticFrame = frames.find( + (f) => f.includes("A") && f.includes("B") && !f.includes("[live]"), + ); + expect(staticFrame).toBeDefined(); + expect(staticFrame).toContain("AB"); + expect(staticFrame).not.toMatch(/A {2,}B/); +}); + +// G64 MUST-FIX: an auto-width Static item with CONTENT WIDER than the terminal +// must OVERFLOW to its content width, NOT be clamped to the terminal width. +// +// Ink's static box is `position:absolute` + auto-width: it is a child of the +// terminal-width root, so TEXT wraps against that containing block, but BOXES +// size to their content and overflow past the terminal. The output grid is +// sized from node.staticNode.yogaNode.getComputedWidth() (renderer.ts:48), which +// can exceed the terminal width. +// +// Confirmed against the built Ink reference (/tmp/ink-40b3a75/build, cols=5): an +// explicit-width child Box width:10 flexShrink:0 renders the full "ABCDEFGHIJ" +// (10 cols, overflowing the 5-col terminal), NOT the clamped "ABCDE". The +// b913386 setMaxWidth(columns) path clips this to "ABCDE". +test("Static overflows an explicit-width child wider than the terminal (Ink parity, G64)", async () => { + const items = shallowRef([]); + + const App = defineComponent(() => () => ( + + + {{ + default: ({ item }: { item: string }) => ( + + + ABCDEFGHIJ + + + ), + }} + + [live] + + )); + + const { frames } = await render(App, { columns: 5 }); + + items.value = ["one"]; + await nextTick(); + + const staticFrame = frames.find((f) => f.includes("ABCDE") && !f.includes("[live]")); + expect(staticFrame).toBeDefined(); + // Must overflow to the full content width, NOT clamp to the terminal width. + expect(staticFrame).toContain("ABCDEFGHIJ"); + expect(staticFrame).not.toBe("ABCDE"); +}); + +// G64 MUST-FIX: a non-wrapping multi- row wider than the terminal must +// also overflow to content width. Confirmed against Ink ref (cols=5): a row +// [Text ABC][Text DEF] renders "ABCDEF" (6 cols, overflowing), NOT a clamped / +// char-dropped result. The b913386 setMaxWidth(columns) path produced "ABDEF" +// (a dropped character). +test("Static overflows a non-wrapping two-Text row wider than the terminal (Ink parity, G64)", async () => { + const items = shallowRef([]); + + const App = defineComponent(() => () => ( + + + {{ + default: ({ item }: { item: string }) => ( + + ABC + DEF + + ), + }} + + [live] + + )); + + const { frames } = await render(App, { columns: 5 }); + + items.value = ["one"]; + await nextTick(); + + const staticFrame = frames.find((f) => f.includes("ABC") && !f.includes("[live]")); + expect(staticFrame).toBeDefined(); + // Ink content-width output is exactly "ABCDEF" (Texts do not shrink/wrap here). + expect(staticFrame).toBe("ABCDEF"); +}); + +// G64 (matches): plain wide TEXT must still WRAP to the terminal width, because +// text measures/wraps against the terminal-width containing block. Confirmed +// against Ink ref (cols=5): "ABCDEFGHIJ" → "ABCDE\nFGHIJ". +test("Static wraps a plain wide text to the terminal width (Ink parity, G64)", async () => { + const items = shallowRef([]); + + const App = defineComponent(() => () => ( + + + {{ + default: ({ item }: { item: string }) => ABCDEFGHIJ, + }} + + [live] + + )); + + const { frames } = await render(App, { columns: 5 }); + + items.value = ["one"]; + await nextTick(); + + const staticFrame = frames.find((f) => f.includes("ABCDE") && !f.includes("[live]")); + expect(staticFrame).toBeDefined(); + expect(staticFrame).toBe("ABCDE\nFGHIJ"); +}); + +// G64 (matches): a percent-width child wraps against the terminal-width +// containing block. Confirmed against Ink ref (cols=6): a row of two 50%-width +// boxes [HALF][END] → "HALEND\nF" (each box is 3 cols; HALF wraps to HAL/F). +test("Static lays out percent-width children against the terminal width (Ink parity, G64)", async () => { + const items = shallowRef([]); + + const App = defineComponent(() => () => ( + + + {{ + default: ({ item }: { item: string }) => ( + + + HALF + + + END + + + ), + }} + + [live] + + )); + + const { frames } = await render(App, { columns: 6 }); + + items.value = ["one"]; + await nextTick(); + + const staticFrame = frames.find((f) => f.includes("HAL") && !f.includes("[live]")); + expect(staticFrame).toBeDefined(); + expect(staticFrame).toBe("HALEND\nF"); +}); + test("Static items do not add blank lines to the dynamic frame", async () => { const items = shallowRef([]); diff --git a/packages/runtime/src/paint/paint.ts b/packages/runtime/src/paint/paint.ts index d270925..e922828 100644 --- a/packages/runtime/src/paint/paint.ts +++ b/packages/runtime/src/paint/paint.ts @@ -20,7 +20,7 @@ import type { BoxProps, } from "../host/nodes.ts"; import { transformHasYogaChild } from "../host/yoga.ts"; -import { createRoot as createIsoRoot } from "../host/nodes.ts"; +import { createRoot as createIsoRoot, createBox as createIsoBox } from "../host/nodes.ts"; import { wrapText, safeSliceEnd } from "../host/text-measure.ts"; import { attachYoga, detachYoga } from "../host/yoga.ts"; @@ -625,64 +625,73 @@ export function paintIsolated( width: number, staticNode?: import("../host/nodes.ts").TuiStatic, ): string { - const iso = createIsoRoot({} as never); - attachYoga(iso); - - // Mirror the static node's RESOLVED layout onto the iso root. (G44) - // - // Ink lays the static node out via its OWN yoga node: Static.tsx merges - // `{position:'absolute', flexDirection:'column', ...customStyle}` onto the - // internal_static , and renderer.ts:48-56 reads - // node.staticNode.yogaNode's computed size/layout directly. So every layout - // style prop on `` (flexDirection, padding, margin, - // gap, justifyContent, alignItems, width, ...) must drive the static paint. - // - // Previously this iterated `staticNode.props` and re-applied only the props - // found there — but node-ops only stores VISUAL props (color/border/overflow) - // in `el.props`; LAYOUT props are applied straight to yoga and never land in - // `props`. So flexDirection/padding/etc. were silently dropped and the iso - // root hard-defaulted to FLEX_DIRECTION_COLUMN. Instead we `copyStyle` the - // static node's yoga — which already holds every resolved layout prop - // (including the column default) via node-ops applyYogaProp — onto - // the fresh iso root. This is the read-back equivalent of Ink reusing - // node.staticNode.yogaNode, without reparenting the live static node's own - // yoga children (the main-tree layout/measure stays untouched). - if (staticNode) { - iso.yoga.copyStyle(staticNode.yoga); - // attachYoga() sets the static node's OWN yoga to display:none so it occupies - // no space in the dynamic frame's main-tree layout (yoga.ts:61-63). copyStyle - // drags that display:none onto the iso root — which would make paint() short- - // circuit and emit nothing. The iso root is the standalone paint root and must - // be visible, so force it back to display:flex. - iso.yoga.setDisplay(Yoga.DISPLAY_FLEX); - // The default style is `position:'absolute'` (Static.tsx) — correct - // when the static node is a child of the main tree (Ink lays it out there), - // but here the static node IS the standalone layout root. An absolute root - // with auto size collapses to 0x0 and paints nothing, so force it back to - // the default relative positioning. (We only ever read the children's - // computed positions; the root's own position type is irrelevant otherwise.) - iso.yoga.setPositionType(Yoga.POSITION_TYPE_RELATIVE); - // The iso root is a standalone layout root constrained to the available - // columns. Only force the column width when the static node had no explicit - // width of its own; an explicit `` is copied above - // and must win (it governs how children lay out and wrap). - const w = staticNode.yoga.getWidth(); - if (w.unit !== Yoga.UNIT_POINT && w.unit !== Yoga.UNIT_PERCENT) { - iso.yoga.setWidth(width); - } - } else { - iso.yoga.setWidth(width); + // No staticNode: legacy/simple path — a single iso root sized to the available + // columns, with the nodes parented directly under it. + if (!staticNode) { + return paintUnderRoot(nodes, width, (iso) => iso.yoga.setWidth(width)); } - // Track which nodes we successfully added to iso's yoga tree so we can - // remove them afterwards. Nodes that are already parented in another yoga - // tree are first removed from that parent before insertion. + // TWO-LEVEL structure, mirroring Ink's static layout (renderer.ts:30-37, + // ink.tsx:302-305, Static.tsx). Ink lays the static box out as a + // `position:absolute`, AUTO-width CHILD of the terminal-width root: // - // IMPORTANT: We deliberately do NOT mutate each node's DOM .parent field. - // The children remain logically owned by their original Static parent — only - // yoga parentage is temporarily transferred to iso for layout calculation. - // Mutating .parent would leave the original tree with broken back-links and - // cause renderer.remove() to skip yoga cleanup (seeing parent === null). + // root (yogaNode.setWidth(terminalWidth)) ← containing block for TEXT wrap + // └─ staticNode (position:absolute, auto width, flexDirection:column…) + // └─ + // + // and then sizes the static OUTPUT grid from + // node.staticNode.yogaNode.getComputedWidth()/getComputedHeight() + // (renderer.ts:32-33) — the computed size of that absolute, auto-width node. + // + // Two consequences fall out of this, and BOTH must hold (G64): + // • TEXT measures/wraps against the parent root's content width (= terminal + // width), so a plain wide wraps to the terminal and a percent-width + // child resolves its percent against the terminal. + // • BOXES (explicit width, or a non-shrinking multi-child row) size to their + // CONTENT and OVERFLOW past the terminal — the grid is the static node's + // content width, which can EXCEED the terminal width. + // + // We reproduce this exactly: an outer iso ROOT fixed to `width` (the terminal + // containing block), and an inner iso BOX that copyStyle's the static node's + // resolved yoga (carrying flexDirection/padding/gap/justify/align AND an + // explicit width if one was set — G44) and stays `position:absolute` + + // auto-width so it content-sizes and overflows. The output grid is sized from + // the INNER box (not the root), so it equals the content width. + // + // (Supersedes b913386's single-root setMaxWidth(columns) approach, which + // CLAMPED overflow content to the terminal width instead of overflowing.) + const iso = createIsoRoot({} as never); + attachYoga(iso); + iso.yoga.setWidth(width); + + const staticBox = createIsoBox(); + attachYoga(staticBox); + // copyStyle pulls every resolved layout prop off the static node's yoga + // (flexDirection — incl. the column default — padding, margin, gap, + // justifyContent, alignItems, and an explicit width/height when set). This is + // the read-back equivalent of Ink reusing node.staticNode.yogaNode, without + // reparenting the live static node's own yoga children (the main-tree layout + // stays untouched). (G44) + staticBox.yoga.copyStyle(staticNode.yoga); + // attachYoga() sets the static node's OWN yoga to display:none so it occupies + // no space in the dynamic frame's main-tree layout (yoga.ts:64-68). copyStyle + // drags that display:none onto the iso box — which would collapse it to 0x0 + // and paint nothing. Force it back to display:flex (it IS the painted box). + staticBox.yoga.setDisplay(Yoga.DISPLAY_FLEX); + // Keep position:absolute (Static.tsx's default), the crux of the Ink model: + // as an absolute, auto-width child of the terminal-width root, the box's + // children wrap their TEXT against the root's width while the box itself + // content-sizes and may OVERFLOW the terminal. With no inset, an absolute box + // resolves to top:0/left:0, so its children paint at the grid origin. (G64) + staticBox.yoga.setPositionType(Yoga.POSITION_TYPE_ABSOLUTE); + + iso.yoga.insertChild(staticBox.yoga, 0); + iso.children.push(staticBox); + + // Parent the static content children UNDER the inner box (not the root), so + // they lay out within the absolute, auto-width static node — exactly the tree + // Ink builds. We temporarily move only the yoga parentage (never the DOM + // .parent — see below) and restore it in the finally block. type YogaCarrier = { yoga: import("yoga-layout").Node }; const yogaAdded: Array<{ yc: YogaCarrier; @@ -690,20 +699,99 @@ export function paintIsolated( origIndex: number; }> = []; - // yIdx tracks only yoga-carrying nodes; DOM-only nodes (text-leaf, comment, - // fragment anchors) do not contribute a yoga slot and must not advance it. + // IMPORTANT: We deliberately do NOT mutate each node's DOM .parent field. + // The children remain logically owned by their original Static parent — only + // yoga parentage is temporarily transferred to the inner box for layout. + // Mutating .parent would leave the original tree with broken back-links and + // cause renderer.remove() to skip yoga cleanup (seeing parent === null). let yIdx = 0; for (let i = 0; i < nodes.length; i++) { const node = nodes[i]!; - // Add to iso.children for paint() traversal, but do NOT change node.parent. - iso.children.push(node); + // Add to the inner box's children for paint() traversal; do NOT change + // node.parent. + staticBox.children.push(node); const yCarrier = node as unknown as YogaCarrier; // Skip nodes that carry no yoga node (text-leaf, comment, fragment anchors). if (!yCarrier.yoga || typeof yCarrier.yoga === "symbol") continue; // If the node already has a yoga parent, temporarily remove it so we can - // re-insert it under iso for layout calculation. + // re-insert it under the inner box for layout calculation. + const yParent = (yCarrier.yoga as unknown as { getParent(): import("yoga-layout").Node | null }) + .getParent + ? (yCarrier.yoga as unknown as { getParent(): import("yoga-layout").Node | null }).getParent() + : null; + const origIndex = yParent ? findYogaIndex(yParent, yCarrier.yoga) : 0; + if (yParent) { + yParent.removeChild(yCarrier.yoga); + } + staticBox.yoga.insertChild(yCarrier.yoga, yIdx); + yogaAdded.push({ yc: yCarrier, origParent: yParent, origIndex }); + yIdx++; + } + + try { + iso.yoga.calculateLayout(width, undefined, Yoga.DIRECTION_LTR); + // Size the output grid from the INNER static box (mirroring Ink + // renderer.ts:32-33 reading node.staticNode.yogaNode.getComputed*), NOT the + // root — so the grid equals the content width and can exceed the terminal. + const boxLayout = staticBox.yoga.getComputedLayout(); + const outW = Math.max(1, Math.floor(boxLayout.width)); + const outH = Math.max(1, Math.floor(boxLayout.height)); + const out = new Output(outW, outH); + // Paint the inner box's children at the grid origin. The absolute box itself + // resolves to left:0/top:0; offsetting by -(left/top) keeps children at the + // origin even if yoga ever computes a non-zero inset. + const x0 = -Math.floor(boxLayout.left); + const y0 = -Math.floor(boxLayout.top); + for (const child of staticBox.children) paintNode(child, out, x0, y0, []); + return out.get().output; + } finally { + // Restore yoga parents in reverse order so earlier indices remain stable. + for (const { yc, origParent, origIndex } of yogaAdded.slice().reverse()) { + staticBox.yoga.removeChild(yc.yoga); + if (origParent) { + origParent.insertChild(yc.yoga, origIndex); + } + } + staticBox.children.length = 0; + + // Tear down the temporary two-level iso tree. + iso.yoga.removeChild(staticBox.yoga); + detachYoga(staticBox); + iso.children.length = 0; + detachYoga(iso); + } +} + +// Paint `nodes` under a single fresh iso root configured by `configureRoot`. +// Children's yoga parentage is temporarily moved under the root for layout and +// restored afterward; DOM .parent pointers are never touched. Used by the +// staticNode-less fallback of paintIsolated. +function paintUnderRoot( + nodes: TuiNode[], + width: number, + configureRoot: (iso: import("../host/nodes.ts").TuiRoot) => void, +): string { + const iso = createIsoRoot({} as never); + attachYoga(iso); + configureRoot(iso); + + type YogaCarrier = { yoga: import("yoga-layout").Node }; + const yogaAdded: Array<{ + yc: YogaCarrier; + origParent: import("yoga-layout").Node | null; + origIndex: number; + }> = []; + + let yIdx = 0; + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]!; + iso.children.push(node); + + const yCarrier = node as unknown as YogaCarrier; + if (!yCarrier.yoga || typeof yCarrier.yoga === "symbol") continue; + const yParent = (yCarrier.yoga as unknown as { getParent(): import("yoga-layout").Node | null }) .getParent ? (yCarrier.yoga as unknown as { getParent(): import("yoga-layout").Node | null }).getParent() @@ -721,16 +809,12 @@ export function paintIsolated( iso.yoga.calculateLayout(width, undefined, Yoga.DIRECTION_LTR); return paint(iso); } finally { - // Restore yoga parents in reverse order so earlier indices remain stable. for (const { yc, origParent, origIndex } of yogaAdded.slice().reverse()) { iso.yoga.removeChild(yc.yoga); if (origParent) { origParent.insertChild(yc.yoga, origIndex); } } - - // Remove children from iso without touching their .parent pointers — they - // still belong to the original Static node in the live DOM tree. iso.children.length = 0; detachYoga(iso); }