From 0d50fb7e40d8dbe6753e08cd857562e098f9066a Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Mon, 8 Jun 2026 22:59:55 +0800 Subject: [PATCH] fix(runtime): paint position:absolute children in zero-content boxes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .agents/docs/ink-divergences.md | 19 +++++--- .../absolute-in-degenerate-box.test.tsx | 46 +++++++++++++++++++ packages/runtime/src/host/layout-guards.ts | 6 +++ packages/runtime/src/paint/paint.ts | 25 +++++++--- 4 files changed, 82 insertions(+), 14 deletions(-) create mode 100644 packages/runtime-tests/integration/layout/absolute-in-degenerate-box.test.tsx diff --git a/.agents/docs/ink-divergences.md b/.agents/docs/ink-divergences.md index e3c4e57..ccb1214 100644 --- a/.agents/docs/ink-divergences.md +++ b/.agents/docs/ink-divergences.md @@ -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 diff --git a/packages/runtime-tests/integration/layout/absolute-in-degenerate-box.test.tsx b/packages/runtime-tests/integration/layout/absolute-in-degenerate-box.test.tsx new file mode 100644 index 0000000..1707068 --- /dev/null +++ b/packages/runtime-tests/integration/layout/absolute-in-degenerate-box.test.tsx @@ -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(() => () => ( + + + + X + + + # + + )), + { 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(() => () => ( + + + Y + + # + + )), + { columns: 100 }, + ); + expect(lastFrame()).not.toContain("Y"); +}); diff --git a/packages/runtime/src/host/layout-guards.ts b/packages/runtime/src/host/layout-guards.ts index 89ca35b..e5c01ac 100644 --- a/packages/runtime/src/host/layout-guards.ts +++ b/packages/runtime/src/host/layout-guards.ts @@ -64,6 +64,12 @@ function applyZeroContentGuards(node: TuiNode, guarded: Map): 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; diff --git a/packages/runtime/src/paint/paint.ts b/packages/runtime/src/paint/paint.ts index cbbac55..bca0dc5 100644 --- a/packages/runtime/src/paint/paint.ts +++ b/packages/runtime/src/paint/paint.ts @@ -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();