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.
|
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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user