fix(runtime): content-size auto-width <Static> isolated paint (Ink parity, G64) (#64)

Ink content-sizes the position:absolute, auto-width static box: Static.tsx
sets `{position:'absolute', flexDirection:'column', ...customStyle}` with no
width, ink.tsx calculateLayout never sets the static node's width, and
renderer.ts reads node.staticNode.yogaNode.getComputedWidth() — the computed
width of a yoga absolute, auto-width node, which shrinks to its CONTENT. So
flex-fill children (Spacer/flexGrow/justifyContent/percent) inside a Static
item collapse to content width instead of expanding to the terminal width.

The G44 fix over-forced the iso root to full terminal width (setWidth(columns)),
so a Spacer/flexGrow child expanded to fill the terminal. This refines G44: for
an AUTO-width static node we now leave the iso root width auto and cap it at the
available columns with setMaxWidth(columns), content-sizing exactly like Ink
while still wrapping anything wider than the terminal. The explicit-width
(POINT/PERCENT) copyStyle path from G44 is unchanged and still wins.

Verified against the built Ink reference (v7.0.4, 40b3a75): a Static item Box
row [LEFT][Spacer][RIGHT] at cols=80 renders "LEFTRIGHT" (Spacer collapses),
and [A][flexGrow][B] renders "AB".

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-05-30 15:42:05 +08:00
committed by GitHub
parent 8cadcca95c
commit e7992bb06b
3 changed files with 377 additions and 67 deletions
+1 -1
View File
@@ -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 | `<Static>` 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 | `<Static>` 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 | — | — |
@@ -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<string[]>([]);
@@ -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
// (<Spacer>, 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<string[]>([]);
const App = defineComponent(() => () => (
<Box flexDirection="column">
<Static items={items.value}>
{{
default: ({ item }: { item: string }) => (
<Box key={item} flexDirection="row">
<Text>LEFT</Text>
<Spacer />
<Text>RIGHT</Text>
</Box>
),
}}
</Static>
<Text>[live]</Text>
</Box>
));
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<string[]>([]);
const App = defineComponent(() => () => (
<Box flexDirection="column">
<Static items={items.value}>
{{
default: ({ item }: { item: string }) => (
<Box key={item} flexDirection="row">
<Text>A</Text>
<Box flexGrow={1} />
<Text>B</Text>
</Box>
),
}}
</Static>
<Text>[live]</Text>
</Box>
));
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<string[]>([]);
const App = defineComponent(() => () => (
<Box flexDirection="column">
<Static items={items.value}>
{{
default: ({ item }: { item: string }) => (
<Box key={item}>
<Box width={10} flexShrink={0}>
<Text>ABCDEFGHIJ</Text>
</Box>
</Box>
),
}}
</Static>
<Text>[live]</Text>
</Box>
));
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-<Text> 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<string[]>([]);
const App = defineComponent(() => () => (
<Box flexDirection="column">
<Static items={items.value}>
{{
default: ({ item }: { item: string }) => (
<Box key={item} flexDirection="row">
<Text>ABC</Text>
<Text>DEF</Text>
</Box>
),
}}
</Static>
<Text>[live]</Text>
</Box>
));
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<string[]>([]);
const App = defineComponent(() => () => (
<Box flexDirection="column">
<Static items={items.value}>
{{
default: ({ item }: { item: string }) => <Text key={item}>ABCDEFGHIJ</Text>,
}}
</Static>
<Text>[live]</Text>
</Box>
));
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<string[]>([]);
const App = defineComponent(() => () => (
<Box flexDirection="column">
<Static items={items.value}>
{{
default: ({ item }: { item: string }) => (
<Box key={item}>
<Box width="50%">
<Text>HALF</Text>
</Box>
<Box width="50%">
<Text>END</Text>
</Box>
</Box>
),
}}
</Static>
<Text>[live]</Text>
</Box>
));
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<string[]>([]);
+149 -65
View File
@@ -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 <ink-box>, and renderer.ts:48-56 reads
// node.staticNode.yogaNode's computed size/layout directly. So every layout
// style prop on `<Static style={{...}}>` (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 <Static> 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 <Static> 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 `<Static style={{width}}>` 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…)
// └─ <static items…>
//
// 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 <Text> 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 <Static> 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);
}