From 0a1e0a8a52d8470d7486caa4ae243e046b8cfb18 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Sat, 6 Jun 2026 11:42:54 -0500 Subject: [PATCH 1/3] ref(opt): compile out Clay's unused debug-tools UI Clay's debug inspector (Clay__RenderDebugView, ~700 lines + ~150 UI strings) is reachable from the exported Clay_EndLayout behind runtime debugModeEnabled branches, so --gc-sections can't drop it; at -O2 clang inlines it into the layout monolith. clayterm never enables debug mode, so it's dead weight. Adds an upstream-compatible CLAY_DEBUG_MODE_ENABLED value macro (default 1; patches/clay-debug-mode-enabled.patch), named to match the runtime context->debugModeEnabled selector. Generated against the submodule's recorded commit (938967a) and covering all four debugModeEnabled sites; opted out via -DCLAY_DEBUG_MODE_ENABLED=0. The Makefile now applies Clay patches generically: every patches/*.patch is applied (in sorted order) after resetting clay/clay.h to pristine, so the applied set always matches the directory exactly and future patches (e.g. #115's grow-minDimensions fix) need no per-patch Makefile wiring. Reverted by make clean. Drop individual patches as they ship upstream. raw wasm 155,877 -> 101,208 (-35.1%); brotli 44,141 -> 29,133. Cold path only (debugModeEnabled always false at runtime); deno task test test/ passes (19 files, 227 steps). --- Makefile | 14 ++++- patches/clay-debug-mode-enabled.patch | 83 +++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 patches/clay-debug-mode-enabled.patch diff --git a/Makefile b/Makefile index 05a2dd1..226c563 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,13 @@ CC = clang TARGET = clayterm.wasm SRC = src/module.c +CLAY_PATCHES = $(sort $(wildcard patches/*.patch)) CFLAGS = --target=wasm32 -nostdlib -O2 \ -ffunction-sections -fdata-sections \ -mbulk-memory \ -DCLAY_IMPLEMENTATION -DCLAY_WASM \ + -DCLAY_DEBUG_MODE_ENABLED=0 \ -Isrc -I. EXPORTS = \ @@ -47,7 +49,16 @@ all: $(TARGET) wasm.ts DEPS = $(wildcard src/*.c src/*.h) -$(TARGET): $(DEPS) +# Apply every patch in patches/ to the clay submodule before compiling. clay.h +# is reset to pristine first, so the applied set always matches patches/*.patch +# exactly (idempotent, applied in sorted order). Reverted by `make clean`. +# Opt-outs like CLAY_DEBUG_MODE_ENABLED=0 are selected via CFLAGS above. +# Drop individual patches as their changes ship in upstream clay. +$(TARGET): $(DEPS) $(CLAY_PATCHES) + @git -C clay checkout -- clay.h + @for p in $(CLAY_PATCHES); do \ + git -C clay apply ../$$p || { echo "ERROR: failed to apply $$p to clay/clay.h" >&2; exit 1; }; \ + done $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(SRC) wasm.ts: $(TARGET) @@ -55,5 +66,6 @@ wasm.ts: $(TARGET) clean: rm -f $(TARGET) wasm.ts + -git -C clay checkout -- clay.h .PHONY: all clean diff --git a/patches/clay-debug-mode-enabled.patch b/patches/clay-debug-mode-enabled.patch new file mode 100644 index 0000000..28c5bef --- /dev/null +++ b/patches/clay-debug-mode-enabled.patch @@ -0,0 +1,83 @@ +diff --git a/clay.h b/clay.h +index 7c967bb..6452cd6 100644 +--- a/clay.h ++++ b/clay.h +@@ -1048,6 +1048,10 @@ extern uint32_t Clay__debugViewWidth; + // IMPLEMENTATION -------------------------- + // ----------------------------------------- + #ifdef CLAY_IMPLEMENTATION ++ ++#ifndef CLAY_DEBUG_MODE_ENABLED ++#define CLAY_DEBUG_MODE_ENABLED 1 ++#endif + #undef CLAY_IMPLEMENTATION + + #ifndef CLAY__NULL +@@ -1805,9 +1809,11 @@ Clay_LayoutElementHashMapItem* Clay__AddHashMapItem(Clay_ElementId elementId, Cl + .errorType = CLAY_ERROR_TYPE_DUPLICATE_ID, + .errorText = CLAY_STRING("An element with this ID was already previously declared during this layout."), + .userData = context->errorHandler.userData }); ++#if CLAY_DEBUG_MODE_ENABLED + if (context->debugModeEnabled) { + hashItem->debugData->collision = true; + } ++#endif // CLAY_DEBUG_MODE_ENABLED + } + return hashItem; + } +@@ -3181,6 +3187,7 @@ CLAY_DLL_EXPORT Clay_ElementIdArray Clay_GetPointerOverIds(void) { + return Clay_GetCurrentContext()->pointerOverIds; + } + ++#if CLAY_DEBUG_MODE_ENABLED + #pragma region DebugTools + Clay_Color CLAY__DEBUGVIEW_COLOR_1 = {58, 56, 52, 255}; + Clay_Color CLAY__DEBUGVIEW_COLOR_2 = {62, 60, 58, 255}; +@@ -3932,6 +3939,7 @@ void Clay__RenderDebugView(void) { + } + } + #pragma endregion ++#endif // CLAY_DEBUG_MODE_ENABLED + + uint32_t Clay__debugViewWidth = 400; + Clay_Color Clay__debugViewHighlightColor = { 168, 66, 28, 100 }; +@@ -4333,9 +4341,11 @@ void Clay_BeginLayout(void) { + context->dynamicElementIndex = 0; + // Set up the root container that covers the entire window + Clay_Dimensions rootDimensions = {context->layoutDimensions.width, context->layoutDimensions.height}; ++#if CLAY_DEBUG_MODE_ENABLED + if (context->debugModeEnabled) { + rootDimensions.width -= (float)Clay__debugViewWidth; + } ++#endif // CLAY_DEBUG_MODE_ENABLED + context->booleanWarnings = CLAY__INIT(Clay_BooleanWarnings) CLAY__DEFAULT_STRUCT; + Clay__OpenElementWithId(CLAY_ID("Clay__RootContainer")); + Clay__ConfigureOpenElement(CLAY__INIT(Clay_ElementDeclaration) { +@@ -4658,11 +4668,13 @@ Clay_RenderCommandArray Clay_EndLayout(float deltaTime) { + } + } + ++#if CLAY_DEBUG_MODE_ENABLED + if (context->debugModeEnabled) { + context->warningsEnabled = false; + Clay__RenderDebugView(); + context->warningsEnabled = true; + } ++#endif // CLAY_DEBUG_MODE_ENABLED + + if (context->booleanWarnings.maxElementsExceeded) { + Clay_String message; +@@ -4677,11 +4689,13 @@ Clay_RenderCommandArray Clay_EndLayout(float deltaTime) { + Clay__CloneElementsWithExitTransition(); + } + } else { ++#if CLAY_DEBUG_MODE_ENABLED + if (context->debugModeEnabled) { + context->warningsEnabled = false; + Clay__RenderDebugView(); + context->warningsEnabled = true; + } ++#endif // CLAY_DEBUG_MODE_ENABLED + + if (context->booleanWarnings.maxElementsExceeded) { + Clay_String message; From 94c9eab88ebafff572b3a751e48c7c21e74de7af Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Sun, 23 Aug 2026 00:14:10 -0400 Subject: [PATCH 2/3] 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. Shipped as patches/clay-grow-minDimensions.patch, picked up by the Makefile's generic patches/*.patch apply step (from ref/clay-debug-tools) — no build wiring in this commit. Generated against the submodule's recorded commit (938967a); reverted by make clean. Long-term destination is upstream nicbarker/clay. specs/renderer-spec.md §8.4 updated to document the content-box semantics: grow() minimum is the configured min, not content size. --- patches/clay-grow-minDimensions.patch | 34 +++++++++++++++++++++++++++ specs/renderer-spec.md | 8 ++++++- 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 patches/clay-grow-minDimensions.patch 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 04d4efd..4517e18 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -546,10 +546,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 96d248cbb0d86f5ffba2ec0114ce0fa48d00d569 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Sun, 23 Aug 2026 00:14:19 -0400 Subject: [PATCH 3/3] 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); + }); +});