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:
@@ -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.
|
||||
- **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
|
||||
temporarily removes that Box's yoga children from the layout when either dimension is
|
||||
zero. Paint applies the same inner-content gate, so the child subtree neither reserves
|
||||
invisible rows nor writes glyphs outside a nonexistent content area. Border and background
|
||||
are still painted as far as the outer area permits. Positive-size content areas keep the
|
||||
temporarily removes that Box's **in-flow** yoga children from the layout when either
|
||||
dimension is zero. Paint applies the same inner-content gate, so the in-flow child subtree
|
||||
neither reserves invisible rows nor writes glyphs outside a nonexistent content area.
|
||||
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`.
|
||||
- **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
|
||||
@@ -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
|
||||
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,
|
||||
children may only paint inside their owning Box's content rectangle; if that rectangle has
|
||||
no positive width or height, the child subtree does not paint.
|
||||
**in-flow** children may only paint inside their owning Box's content rectangle; if that
|
||||
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
|
||||
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
|
||||
@@ -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
|
||||
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`,
|
||||
`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
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
if (inner.width === 0 || inner.height === 0) {
|
||||
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;
|
||||
}
|
||||
return changed;
|
||||
|
||||
@@ -658,15 +658,10 @@ function paintNode(
|
||||
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
|
||||
// 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;
|
||||
const overflow = node.props["overflow"] as string | undefined;
|
||||
const clipH =
|
||||
@@ -687,6 +682,22 @@ function paintNode(
|
||||
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);
|
||||
|
||||
if (clipped) output.unclip();
|
||||
|
||||
Reference in New Issue
Block a user