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:
@@ -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[]>([]);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user