From 5d54e8c4aff237e0d787aa479face16774dc6d6b Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Sun, 23 Aug 2026 00:14:10 -0400 Subject: [PATCH 1/2] fix(layout): GROW elements must use configured min for minDimensions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clay's CloseElement set minDimensions from content-derived size for all element types, including GROW. For a GROW root with right padding, the content-derived minDimensions.width was text_width + padding, which exceeded the terminal width. SizeContainersAlongAxis then refused to compress the root below that minimum, so the root overflowed the terminal and nested grow() spacers were clipped past the parent's right (and bottom) padding. The fix: GROW elements resolve against the parent content box, so their minimum compressible size is the user-configured min (default 0), not the natural size of their content. Non-GROW types are unchanged. Applied via patches/clay-grow-minDimensions.patch at build time, following the same pattern as patches/clay-disable-debug-tools.patch from ref/clay-debug-tools. The Makefile applies the patch idempotently before compilation and restores clay/clay.h on `make clean`. specs/renderer-spec.md §8.4 updated to document the content-box semantics: grow() minimum is the configured min, not content size. --- Makefile | 9 ++++++- patches/clay-grow-minDimensions.patch | 34 +++++++++++++++++++++++++++ specs/renderer-spec.md | 8 ++++++- 3 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 patches/clay-grow-minDimensions.patch diff --git a/Makefile b/Makefile index 05a2dd1..e2e21e3 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ CC = clang TARGET = clayterm.wasm SRC = src/module.c +CLAY_GROW_PATCH = patches/clay-grow-minDimensions.patch CFLAGS = --target=wasm32 -nostdlib -O2 \ -ffunction-sections -fdata-sections \ @@ -47,7 +48,12 @@ all: $(TARGET) wasm.ts DEPS = $(wildcard src/*.c src/*.h) -$(TARGET): $(DEPS) +# Fix GROW element minDimensions so they compress to the parent content box instead of +# expanding beyond the terminal boundary. Idempotent; reverted by `make clean`. +# Drop once the fix ships in upstream clay. +$(TARGET): $(DEPS) $(CLAY_GROW_PATCH) + @grep -q "GROW elements resolve against the parent content box" clay/clay.h || git -C clay apply ../$(CLAY_GROW_PATCH) + @grep -q "GROW elements resolve against the parent content box" clay/clay.h || { echo "ERROR: failed to apply $(CLAY_GROW_PATCH) to clay/clay.h" >&2; exit 1; } $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(SRC) wasm.ts: $(TARGET) @@ -55,5 +61,6 @@ wasm.ts: $(TARGET) clean: rm -f $(TARGET) wasm.ts + -git -C clay checkout -- clay.h .PHONY: all clean diff --git a/patches/clay-grow-minDimensions.patch b/patches/clay-grow-minDimensions.patch new file mode 100644 index 0000000..b2ac0da --- /dev/null +++ b/patches/clay-grow-minDimensions.patch @@ -0,0 +1,34 @@ +diff --git a/clay.h b/clay.h +index 7c967bb..5421621 100644 +--- a/clay.h ++++ b/clay.h +@@ -1921,7 +1921,14 @@ void Clay__CloseElement(void) { + layoutConfig->sizing.width.size.minMax.max = CLAY__MAXFLOAT; + } + openLayoutElement->dimensions.width = CLAY__MIN(CLAY__MAX(openLayoutElement->dimensions.width, layoutConfig->sizing.width.size.minMax.min), layoutConfig->sizing.width.size.minMax.max); +- openLayoutElement->minDimensions.width = CLAY__MIN(CLAY__MAX(openLayoutElement->minDimensions.width, layoutConfig->sizing.width.size.minMax.min), layoutConfig->sizing.width.size.minMax.max); ++ // GROW elements resolve against the parent content box and must be compressible to their ++ // configured min (default 0). Content-derived minDimensions would prevent this compression, ++ // so for GROW we use only the user-configured min, not the content-derived value. ++ if (layoutConfig->sizing.width.type == CLAY__SIZING_TYPE_GROW) { ++ openLayoutElement->minDimensions.width = layoutConfig->sizing.width.size.minMax.min; ++ } else { ++ openLayoutElement->minDimensions.width = CLAY__MIN(CLAY__MAX(openLayoutElement->minDimensions.width, layoutConfig->sizing.width.size.minMax.min), layoutConfig->sizing.width.size.minMax.max); ++ } + } else { + openLayoutElement->dimensions.width = 0; + } +@@ -1932,7 +1939,12 @@ void Clay__CloseElement(void) { + layoutConfig->sizing.height.size.minMax.max = CLAY__MAXFLOAT; + } + openLayoutElement->dimensions.height = CLAY__MIN(CLAY__MAX(openLayoutElement->dimensions.height, layoutConfig->sizing.height.size.minMax.min), layoutConfig->sizing.height.size.minMax.max); +- openLayoutElement->minDimensions.height = CLAY__MIN(CLAY__MAX(openLayoutElement->minDimensions.height, layoutConfig->sizing.height.size.minMax.min), layoutConfig->sizing.height.size.minMax.max); ++ // Same rationale as width: GROW elements use the configured min, not content-derived. ++ if (layoutConfig->sizing.height.type == CLAY__SIZING_TYPE_GROW) { ++ openLayoutElement->minDimensions.height = layoutConfig->sizing.height.size.minMax.min; ++ } else { ++ openLayoutElement->minDimensions.height = CLAY__MIN(CLAY__MAX(openLayoutElement->minDimensions.height, layoutConfig->sizing.height.size.minMax.min), layoutConfig->sizing.height.size.minMax.max); ++ } + } else { + openLayoutElement->dimensions.height = 0; + } diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index 398e78a..2289a99 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -477,10 +477,16 @@ These functions produce sizing-axis values for use in element layout configuration: ``` -grow(): SizingAxis +grow(min?: number, max?: number): SizingAxis ``` The element expands to fill available space in the parent along this axis. +Available space is computed from the **parent's content box** — the parent's +dimension minus its padding — at every nesting level, regardless of whether the +parent's own size was computed from `fixed()`, `grow()`, or any other mode. A +`grow()` element's minimum size is its configured `min` (default 0), not the +natural size of its content; this ensures it can always be compressed to fit the +parent content box when necessary. ``` fixed(value: number): SizingAxis From e7a31bd2960a3ee993590ff97b5800d27b53d9e0 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Sun, 23 Aug 2026 00:14:19 -0400 Subject: [PATCH 2/2] test(layout): regression tests for grow() + padding content-box bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four cases covering the fixed behavior: (a) fixed() and grow() roots produce identical child positions for a right-aligned row (text + grow spacer + text) with symmetric padding. (b) grow() root with asymmetric padding — R lands at the correct column respecting the larger right pad, not overflowing it. (c) Two levels of grow() nesting — each box preserves its own content box so R remains inside the innermost right padding. (d) Height axis — grow() root bounds.height equals terminal height even when content rows + vertical padding exceed it (catches the same minDimensions bug on the Y axis). --- test/grow-padding.test.ts | 156 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 test/grow-padding.test.ts diff --git a/test/grow-padding.test.ts b/test/grow-padding.test.ts new file mode 100644 index 0000000..d7e838a --- /dev/null +++ b/test/grow-padding.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from "./suite.ts"; +import { createTerm } from "../term.ts"; +import { close, fixed, grow, open, type SizingAxis, text } from "../ops.ts"; +import { print } from "./print.ts"; + +const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes); + +describe("grow() padding", () => { + // (a) Exact repro of the A/B bug: fixed() and grow() roots must produce identical layouts. + // Before the fix, a grow() root's minDimensions reflected content width rather than the + // configured min (0), preventing compression to terminal width when content + padding > termW. + it("fixed() and grow() roots produce identical child positions", async () => { + let mkRoot = (w: SizingAxis) => [ + open("root", { + layout: { + width: w, + height: grow(), + padding: { left: 4, right: 4, top: 1, bottom: 1 }, + direction: "ttb", + }, + }), + open("row", { layout: { width: grow(), direction: "ltr" } }), + text("L"), + open("spacer", { layout: { width: grow() } }), + close(), + text("R"), + close(), + close(), + ]; + + let termA = await createTerm({ width: 40, height: 4 }); + let termB = await createTerm({ width: 40, height: 4 }); + + let resultA = termA.render(mkRoot(fixed(40))); + let resultB = termB.render(mkRoot(grow())); + + let gridA = print(decode(resultA.output), 40, 4).split("\n"); + let gridB = print(decode(resultB.output), 40, 4).split("\n"); + + // Root content box: x=4, width=32. L at col 4, R at col 35. + expect(gridA[1][4]).toBe("L"); + expect(gridA[1][35]).toBe("R"); + expect(gridA[1]).toEqual(gridB[1]); // grow() root must match fixed() root + + expect(resultA.info.get("root")!.bounds.width).toBe(40); + expect(resultB.info.get("root")!.bounds.width).toBe(40); + }); + + // (b) Asymmetric padding: R must respect the larger right padding, not overflow it. + it("right-aligned element respects asymmetric padding under grow() root", async () => { + let term = await createTerm({ width: 40, height: 3 }); + let grid = print( + decode( + term.render([ + open("root", { + layout: { + width: grow(), + height: grow(), + padding: { left: 2, right: 6, top: 1, bottom: 1 }, + direction: "ttb", + }, + }), + open("row", { layout: { width: grow(), direction: "ltr" } }), + text("L"), + open("spacer", { layout: { width: grow() } }), + close(), + text("R"), + close(), + close(), + ]).output, + ), + 40, + 3, + ).split("\n"); + + // Content box: 40 - 2(left) - 6(right) = 32 wide, starting at col 2. + // L at col 2, R at col 33. Cols 34-39 are right padding — must be empty. + expect(grid[1][2]).toBe("L"); + expect(grid[1][33]).toBe("R"); + expect(grid[1][34]).toBe(" "); + }); + + // (c) Two levels of nesting: each intermediate grow() box also respects its parent content box. + it("two levels of grow() nesting preserve content-box sizing", async () => { + let term = await createTerm({ width: 40, height: 6 }); + let grid = print( + decode( + term.render([ + open("root", { + layout: { + width: grow(), + height: grow(), + padding: { left: 2, right: 2, top: 1, bottom: 1 }, + direction: "ttb", + }, + }), + open("col", { + layout: { + width: grow(), + height: grow(), + padding: { left: 2, right: 2, top: 1, bottom: 1 }, + direction: "ttb", + }, + }), + open("row", { layout: { width: grow(), direction: "ltr" } }), + text("L"), + open("spacer", { layout: { width: grow() } }), + close(), + text("R"), + close(), + close(), + close(), + ]).output, + ), + 40, + 6, + ).split("\n"); + + // Root pad left=2, col pad left=2 → content starts at col 4, width 32. + // L at col 4, R at col 35. Col right padding at 36-37, root right padding at 38-39. + // Row appears at y=2 (root top=1 + col top=1). + expect(grid[2][4]).toBe("L"); + expect(grid[2][35]).toBe("R"); + expect(grid[2][36]).toBe(" "); + }); + + // (d) Height axis: a grow() root with vertical padding must compress to terminal height, + // not expand to fit content + padding when that sum exceeds the terminal. + it("grow() root height is bounded to terminal height under vertical padding", async () => { + let term = await createTerm({ width: 10, height: 8 }); + + // 5 text rows + padding 4 = 9 > termH 8. + // Before the fix, root.bounds.height was 9; after, it must be 8. + let result = term.render([ + open("root", { + layout: { + width: grow(), + height: grow(), + padding: { top: 2, bottom: 2 }, + direction: "ttb", + }, + }), + text("row1"), + text("row2"), + text("row3"), + text("row4"), + text("row5"), + close(), + ]); + + let root = result.info.get("root"); + expect(root).toBeDefined(); + expect(root!.bounds.height).toBe(8); + expect(root!.bounds.width).toBe(10); + }); +});