fix(runtime): paint position:absolute children in zero-content boxes

The zero-content-area guard (layout-guards + paint.ts) suppressed ALL
children of a Box whose inner content rect collapsed to zero, including
position:"absolute" children. An absolutely-positioned child is placed
against the containing block (border-box), not the content rect, so Ink
v7.0.4 paints it (verified by running real Ink: a w=2 h=2 single-border
box with an absolute child renders "┌┐#\n└X"); vue-tui dropped it,
rendering "┌┐#\n└┘".

- layout-guards: exempt POSITION_TYPE_ABSOLUTE children from the hide loop
  so they keep their layout.
- paint: move overflow-clip setup above the zero-content early-return and,
  in that branch, paint only absolute children (still clipped by
  overflow:hidden, matching Ink) while keeping in-flow children suppressed
  (the blessed degenerate-box divergence).

Flow-child suppression and overflow:hidden clipping both stay Ink-aligned
(verified byte-identical against real Ink). Known limitation: an absolute
descendant nested under a suppressed in-flow child is still dropped (the
flow ancestor is removed from layout) — scoped to direct absolute children.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-06-08 22:59:55 +08:00
parent 9b7e9dfb26
commit 0d50fb7e40
4 changed files with 82 additions and 14 deletions
+12 -7
View File
@@ -388,10 +388,13 @@ different runtime behavior, ownership rule, or out-of-contract handling.
border; and bare `width={0}` text reserving rows through wrap-ansi's width-0 layout. border; and bare `width={0}` text reserving rows through wrap-ansi's width-0 layout.
- **vue-tui:** layout computes each Box's inner content size by subtracting computed border - **vue-tui:** layout computes each Box's inner content size by subtracting computed border
and padding from the outer box size, clamps it to `{width >= 0, height >= 0}`, and and padding from the outer box size, clamps it to `{width >= 0, height >= 0}`, and
temporarily removes that Box's yoga children from the layout when either dimension is temporarily removes that Box's **in-flow** yoga children from the layout when either
zero. Paint applies the same inner-content gate, so the child subtree neither reserves dimension is zero. Paint applies the same inner-content gate, so the in-flow child subtree
invisible rows nor writes glyphs outside a nonexistent content area. Border and background neither reserves invisible rows nor writes glyphs outside a nonexistent content area.
are still painted as far as the outer area permits. Positive-size content areas keep the Border and background are still painted as far as the outer area permits.
**Absolutely-positioned children are exempt** — their region is the containing block
(border-box), not the content rect, so they still lay out and paint (clipped only by
`overflow:hidden`), matching Ink. Positive-size content areas keep the
existing overflow behavior; this is not a blanket `overflow:hidden`. existing overflow behavior; this is not a blanket `overflow:hidden`.
- **Layout model guidance:** primitive `Box` should preserve the Yoga/flexbox model rather - **Layout model guidance:** primitive `Box` should preserve the Yoga/flexbox model rather
than paper over it with ad-hoc layout corrections. Defaults such as `flexShrink: 1` are than paper over it with ad-hoc layout corrections. Defaults such as `flexShrink: 1` are
@@ -400,8 +403,10 @@ different runtime behavior, ownership rule, or out-of-contract handling.
viewport abstractions should keep their content at natural size (`flexShrink: 0`, or an viewport abstractions should keep their content at natural size (`flexShrink: 0`, or an
equivalent encapsulated default) and let a bounded viewport clip or offset what is visible. equivalent encapsulated default) and let a bounded viewport clip or offset what is visible.
Paint containment is the renderer invariant underneath both cases: whatever Yoga resolves, Paint containment is the renderer invariant underneath both cases: whatever Yoga resolves,
children may only paint inside their owning Box's content rectangle; if that rectangle has **in-flow** children may only paint inside their owning Box's content rectangle; if that
no positive width or height, the child subtree does not paint. rectangle has no positive width or height, the in-flow child subtree does not paint.
Absolutely-positioned children are the exception — they paint against the containing block
and are suppressed only by `overflow:hidden`, matching Ink.
- **Why:** children need a real content rectangle to lay out and paint into. If the - **Why:** children need a real content rectangle to lay out and paint into. If the
resolved content width or height is zero, rendering child text or nested borders on top resolved content width or height is zero, rendering child text or nested borders on top
of the frame, outside the box, or on later rows is an implementation artifact, not useful of the frame, outside the box, or on later rows is an implementation artifact, not useful
@@ -412,7 +417,7 @@ different runtime behavior, ownership rule, or out-of-contract handling.
the viewport exposes a bounded visible window and offsets. The behavior also prevents the viewport exposes a bounded visible window and offsets. The behavior also prevents
negative repeat/count math and paint crashes in tiny legal boxes. Maintainer decision negative repeat/count math and paint crashes in tiny legal boxes. Maintainer decision
(2026-06-07): KEEP. Tests: `text-wrap-width.test.tsx`, `flex.test.tsx`, (2026-06-07): KEEP. Tests: `text-wrap-width.test.tsx`, `flex.test.tsx`,
`text.test.tsx`. `text.test.tsx`, `absolute-in-degenerate-box.test.tsx`.
- **Future `content-box`:** this does not block adding an explicit content-box option - **Future `content-box`:** this does not block adding an explicit content-box option
later. That option would change how a requested size is expanded into an outer box size later. That option would change how a requested size is expanded into an outer box size
before layout. Once an outer box exists, the paint invariant remains the same: a child before layout. Once an outer box exists, the paint invariant remains the same: a child
@@ -0,0 +1,46 @@
import { defineComponent } from "vue";
import { expect, test } from "vite-plus/test";
import { render } from "@vue-tui/testing";
import { Box, Text } from "@vue-tui/runtime";
// A Box whose inner content area collapses to zero must still paint its
// position:"absolute" children — an absolutely-positioned child is placed
// against the containing block (border-box), not the (nonexistent) content
// rect, so the zero-content guard must not suppress it. Ink v7.0.4 paints
// these (verified by running real Ink: a w=2 h=2 single-border box with an
// absolute child renders "┌┐#\n└X"); vue-tui previously suppressed ALL
// children, including absolute ones, rendering "┌┐#\n└┘".
test("absolute child paints when border eats the whole content area (w=2 h=2)", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Box>
<Box width={2} height={2} borderStyle="single">
<Box position="absolute" top={0} left={0}>
<Text>X</Text>
</Box>
</Box>
<Text>#</Text>
</Box>
)),
{ columns: 100 },
);
expect(lastFrame()).toContain("X");
});
test("flow (non-absolute) child stays suppressed when the content area is zero", async () => {
// The blessed degenerate-box divergence: a normal flow child in a zero-content
// box does NOT paint (avoids Ink's zero-width-text leak). This must stay true.
const { lastFrame } = await render(
defineComponent(() => () => (
<Box>
<Box width={2} height={2} borderStyle="single">
<Text>Y</Text>
</Box>
<Text>#</Text>
</Box>
)),
{ columns: 100 },
);
expect(lastFrame()).not.toContain("Y");
});
@@ -64,6 +64,12 @@ function applyZeroContentGuards(node: TuiNode, guarded: Map<YogaNode, number>):
const inner = getBoxInnerSize(node); const inner = getBoxInnerSize(node);
if (inner.width === 0 || inner.height === 0) { if (inner.width === 0 || inner.height === 0) {
for (const child of node.children) { for (const child of node.children) {
// Absolutely-positioned children are placed against the containing
// block (border-box), not the content rect, so the zero-content guard
// must not hide them — Ink lays them out and paints them regardless.
if (hasYoga(child) && child.yoga.getPositionType() === Yoga.POSITION_TYPE_ABSOLUTE) {
continue;
}
changed = hideYogaChild(child, guarded) || changed; changed = hideYogaChild(child, guarded) || changed;
} }
return changed; return changed;
+18 -7
View File
@@ -658,15 +658,10 @@ function paintNode(
fillBackground(output, x + bl, y + bt, w - bl - br, h - bt - bb, ownBg, transformers); fillBackground(output, x + bl, y + bt, w - bl - br, h - bt - bb, ownBg, transformers);
} }
const contentMetrics = getBoxContentMetrics(node, w, h);
// A Box with no inner content area has no legal child paint region.
if (contentMetrics.width === 0 || contentMetrics.height === 0) {
return;
}
// Overflow clipping: clip children to the box content area (inside // Overflow clipping: clip children to the box content area (inside
// borders) when overflow/overflowX/overflowY is "hidden". Matches Ink's // borders) when overflow/overflowX/overflowY is "hidden". Matches Ink's
// per-axis clip/unclip approach. // per-axis clip/unclip approach. Applied BEFORE the zero-content decision
// so a degenerate box's absolute children are still clipped by overflow.
let clipped = false; let clipped = false;
const overflow = node.props["overflow"] as string | undefined; const overflow = node.props["overflow"] as string | undefined;
const clipH = const clipH =
@@ -687,6 +682,22 @@ function paintNode(
clipped = true; clipped = true;
} }
const contentMetrics = getBoxContentMetrics(node, w, h);
// A Box with no inner content area has no legal paint region for FLOW
// children. Absolutely-positioned children, though, are placed against
// the containing block (border-box), not the content rect — Ink still
// paints them — so paint just those and keep flow children suppressed.
if (contentMetrics.width === 0 || contentMetrics.height === 0) {
for (const child of node.children) {
const childYoga = (child as { yoga?: { getPositionType?: () => number } }).yoga;
if (childYoga?.getPositionType?.() === Yoga.POSITION_TYPE_ABSOLUTE) {
paintNode(child, output, x, y, transformers, childBg);
}
}
if (clipped) output.unclip();
return;
}
for (const child of node.children) paintNode(child, output, x, y, transformers, childBg); for (const child of node.children) paintNode(child, output, x, y, transformers, childBg);
if (clipped) output.unclip(); if (clipped) output.unclip();