From 1159e3ec195ecf7c68286486f4b7f8d8d5136fed Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Thu, 4 Jun 2026 00:33:16 -0500 Subject: [PATCH 1/5] test: failing repro for nested-clip --- test/nested-clip.test.ts | 82 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 test/nested-clip.test.ts diff --git a/test/nested-clip.test.ts b/test/nested-clip.test.ts new file mode 100644 index 0000000..e42ff5c --- /dev/null +++ b/test/nested-clip.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it } from "./suite.ts"; +import { createTerm, type Term } from "../term.ts"; +import { close, fixed, open, text } from "../ops.ts"; +import { print } from "./print.ts"; + +const decode = (b: Uint8Array) => new TextDecoder().decode(b); +const trim = (s: string) => s.split("\n").map((l) => l.trimEnd()).join("\n"); + +describe("nested clip stacking", () => { + let term: Term; + + beforeEach(async () => { + term = await createTerm({ width: 8, height: 8 }); + }); + + it("restores the outer clip rect vertically for a sibling after a nested clip", () => { + let out = term.render([ + open("outer", { + layout: { width: fixed(8), height: fixed(4), direction: "ttb" }, + clip: { vertical: true, horizontal: true }, + }), + open("inner", { + layout: { width: fixed(4), height: fixed(2), direction: "ttb" }, + clip: { vertical: true, horizontal: true }, + }), + open("innerContent", { + layout: { width: fixed(4), height: fixed(4), direction: "ttb" }, + }), + text("AAAA\nBBBB\nCCCC\nDDDD"), + close(), // innerContent + close(), // inner + open("sibling", { + layout: { width: fixed(4), height: fixed(4), direction: "ttb" }, + }), + text("XXXX\nYYYY\nZZZZ\nWWWW"), + close(), // sibling + close(), // outer + ]).output; + + let grid = trim(print(decode(out), 8, 8)); + // After the inner clip closes the outer rect must be restored, so the + // sibling rows past the outer clip bottom (ZZZZ, WWWW) are clipped away. + expect(grid).toEqual( + [ + "AAAA", // inner content row 0 (inner clip = rows 0-1) + "BBBB", // inner content row 1 + "XXXX", // sibling starts at row 2 — inside outer clip + "YYYY", // sibling row 3 — last row inside outer clip + "", // row 4: ZZZZ clipped by restored outer rect + "", // row 5: WWWW clipped by restored outer rect + "", + "", + ].join("\n"), + ); + }); + + it("restores the outer clip horizontal bound for a sibling after a nested clip", () => { + let out = term.render([ + open("outer", { + layout: { width: fixed(4), height: fixed(4), direction: "ttb" }, + clip: { vertical: true, horizontal: true }, + }), + open("inner", { + layout: { width: fixed(2), height: fixed(1), direction: "ttb" }, + clip: { vertical: true, horizontal: true }, + }), + text("II"), + close(), // inner + open("sibling", { + layout: { width: fixed(8), height: fixed(1), direction: "ttb" }, + }), + text("SSSSSSSS"), + close(), // sibling + close(), // outer + ]).output; + + let siblingRow = print(decode(out), 8, 4).split("\n")[1]; + // Outer clip width is 4; once the narrower inner clip closes the outer + // horizontal bound must be restored so cols >= 4 of the sibling are clipped. + expect(siblingRow).toEqual("SSSS "); + }); +}); From 4b35dc4693e598995ebff3eb791eedcfafb7a600 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 5 Jun 2026 01:11:13 -0500 Subject: [PATCH 2/5] fix: stack clip regions so nested clips restore the parent rect Clip state was a single rect, so closing an inner clip turned clipping off entirely and later siblings leaked past the outer bounds. Replace it with a fixed-depth stack: SCISSOR_START pushes intersect(parent, child) and SCISSOR_END pops, restoring the parent rect while the stack is non-empty. The active top mirrors into the existing clipx/clipy/clipw/cliph scalars, so setcell is unchanged. Fixes #77 --- src/clayterm.c | 61 ++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 54 insertions(+), 7 deletions(-) diff --git a/src/clayterm.c b/src/clayterm.c index 2af9afd..db24999 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -38,6 +38,13 @@ #define MAX_ERRORS 32 +/* clip stack depth: nesting beyond this clamps to the deepest rect */ +#define CLIP_STACK_MAX 16 + +typedef struct { + int x, y, w, h; +} ClipRect; + struct Clayterm { int w, h; Cell *front; @@ -45,9 +52,12 @@ struct Clayterm { Buffer out; uint32_t lastfg, lastbg; int lastx, lasty; - /* clip region */ + /* clip region (active top mirrored here so setcell stays unchanged) */ int clipx, clipy, clipw, cliph; int clipping; + /* clip stack: nesting pushes intersected rects, leaving pops to restore */ + ClipRect clipstack[CLIP_STACK_MAX]; + int clipdepth; /* error collection */ Clay_ErrorData errors[MAX_ERRORS]; int error_count; @@ -596,6 +606,8 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row) { ct->out.length = 0; ct->lastfg = ct->lastbg = 0xffffffff; ct->lastx = ct->lasty = -1; + ct->clipdepth = 0; + ct->clipping = 0; cells_fill(ct->back, ct->w, ct->h, ' ', ATTR_DEFAULT, ATTR_DEFAULT); @@ -618,15 +630,50 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row) { case CLAY_RENDER_COMMAND_TYPE_BORDER: render_border(ct, x0, y0, x1, y1, &cmd->renderData.border); break; - case CLAY_RENDER_COMMAND_TYPE_SCISSOR_START: + case CLAY_RENDER_COMMAND_TYPE_SCISSOR_START: { + /* intersect the child box with the current active rect (if any) */ + int nx0 = x0, ny0 = y0, nx1 = x1, ny1 = y1; + if (ct->clipdepth > 0) { + ClipRect top = ct->clipstack[ct->clipdepth - 1]; + if (top.x > nx0) + nx0 = top.x; + if (top.y > ny0) + ny0 = top.y; + if (top.x + top.w < nx1) + nx1 = top.x + top.w; + if (top.y + top.h < ny1) + ny1 = top.y + top.h; + } + int nw = nx1 - nx0; + int nh = ny1 - ny0; + if (nw < 0) + nw = 0; + if (nh < 0) + nh = 0; + if (ct->clipdepth < CLIP_STACK_MAX) { + ClipRect r = {nx0, ny0, nw, nh}; + ct->clipstack[ct->clipdepth++] = r; + } ct->clipping = 1; - ct->clipx = x0; - ct->clipy = y0; - ct->clipw = x1 - x0; - ct->cliph = y1 - y0; + ct->clipx = nx0; + ct->clipy = ny0; + ct->clipw = nw; + ct->cliph = nh; break; + } case CLAY_RENDER_COMMAND_TYPE_SCISSOR_END: - ct->clipping = 0; + if (ct->clipdepth > 0) + ct->clipdepth--; + if (ct->clipdepth > 0) { + ClipRect top = ct->clipstack[ct->clipdepth - 1]; + ct->clipping = 1; + ct->clipx = top.x; + ct->clipy = top.y; + ct->clipw = top.w; + ct->cliph = top.h; + } else { + ct->clipping = 0; + } break; default: break; From eece6d88705d50129314cf5d8cd2eb8726a6f56a Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 5 Jun 2026 23:41:28 -0500 Subject: [PATCH 3/5] test: move nested-clip regression into test/clip.test.ts --- test/{nested-clip.test.ts => clip.test.ts} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename test/{nested-clip.test.ts => clip.test.ts} (98%) diff --git a/test/nested-clip.test.ts b/test/clip.test.ts similarity index 98% rename from test/nested-clip.test.ts rename to test/clip.test.ts index e42ff5c..c75ecfc 100644 --- a/test/nested-clip.test.ts +++ b/test/clip.test.ts @@ -6,7 +6,7 @@ import { print } from "./print.ts"; const decode = (b: Uint8Array) => new TextDecoder().decode(b); const trim = (s: string) => s.split("\n").map((l) => l.trimEnd()).join("\n"); -describe("nested clip stacking", () => { +describe("clip", () => { let term: Term; beforeEach(async () => { From 6b5ed1546ff44bfc0858c931e593c66b8a5544e8 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Sat, 6 Jun 2026 12:03:32 +0200 Subject: [PATCH 4/5] =?UTF-8?q?=F0=9F=8C=86=20Use=20visual=20language=20fo?= =?UTF-8?q?r=20clipping=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/clip.test.ts | 131 +++++++++++++++++++++++++++++----------------- 1 file changed, 82 insertions(+), 49 deletions(-) diff --git a/test/clip.test.ts b/test/clip.test.ts index c75ecfc..5d4efee 100644 --- a/test/clip.test.ts +++ b/test/clip.test.ts @@ -1,82 +1,115 @@ -import { beforeEach, describe, expect, it } from "./suite.ts"; -import { createTerm, type Term } from "../term.ts"; -import { close, fixed, open, text } from "../ops.ts"; +import { describe, expect, it } from "./suite.ts"; +import { createTerm } from "../term.ts"; +import { close, fixed, grow, open, rgba, text } from "../ops.ts"; import { print } from "./print.ts"; const decode = (b: Uint8Array) => new TextDecoder().decode(b); const trim = (s: string) => s.split("\n").map((l) => l.trimEnd()).join("\n"); -describe("clip", () => { - let term: Term; +const white = rgba(255, 255, 255); +const border = { color: white, left: 1, right: 1, top: 1, bottom: 1 }; +const pad = { left: 1, right: 1, top: 1, bottom: 1 }; - beforeEach(async () => { - term = await createTerm({ width: 8, height: 8 }); - }); +describe("clip", () => { + // rulesr marks the bottom of an invisible 14×4 clip(outer); + // ┌────┐ + // │ │ + // └────┘ + // ┌────────┐ + // ────────────── + // │clipped │ + // └────────┘ + it("restores outer vertical bound for a sibling after a nested clip", async () => { + let term = await createTerm({ width: 14, height: 8 }); - it("restores the outer clip rect vertically for a sibling after a nested clip", () => { let out = term.render([ + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + }), open("outer", { - layout: { width: fixed(8), height: fixed(4), direction: "ttb" }, + layout: { width: fixed(14), height: fixed(4), direction: "ttb" }, clip: { vertical: true, horizontal: true }, }), open("inner", { - layout: { width: fixed(4), height: fixed(2), direction: "ttb" }, + layout: { width: fixed(6), height: fixed(3), direction: "ttb" }, clip: { vertical: true, horizontal: true }, + border, }), - open("innerContent", { - layout: { width: fixed(4), height: fixed(4), direction: "ttb" }, - }), - text("AAAA\nBBBB\nCCCC\nDDDD"), - close(), // innerContent - close(), // inner + close(), open("sibling", { - layout: { width: fixed(4), height: fixed(4), direction: "ttb" }, + layout: { + width: fixed(10), + height: fixed(3), + direction: "ttb", + padding: pad, + }, + border, }), - text("XXXX\nYYYY\nZZZZ\nWWWW"), - close(), // sibling - close(), // outer + text("clipped"), + close(), + close(), + open("ruler", { + layout: { width: fixed(14), height: fixed(1), direction: "ttb" }, + }), + text("──────────────"), + close(), + close(), ]).output; - let grid = trim(print(decode(out), 8, 8)); - // After the inner clip closes the outer rect must be restored, so the - // sibling rows past the outer clip bottom (ZZZZ, WWWW) are clipped away. - expect(grid).toEqual( - [ - "AAAA", // inner content row 0 (inner clip = rows 0-1) - "BBBB", // inner content row 1 - "XXXX", // sibling starts at row 2 — inside outer clip - "YYYY", // sibling row 3 — last row inside outer clip - "", // row 4: ZZZZ clipped by restored outer rect - "", // row 5: WWWW clipped by restored outer rect - "", - "", - ].join("\n"), - ); + expect(trim(print(decode(out), 14, 8)).trim()).toEqual(` +┌────┐ +│ │ +└────┘ +┌────────┐ +────────────── +`.trim()); }); - it("restores the outer clip horizontal bound for a sibling after a nested clip", () => { + // ruler marks the right boundary of an invisible 8×3 ltr clip + // ┌─┐┌────│───┐ + // │ ││clip│ed │ + // └─┘└────│───┘ + it("restores outer horizontal bound for a sibling after a nested clip", async () => { + let term = await createTerm({ width: 14, height: 6 }); + let out = term.render([ + open("root", { + layout: { width: grow(), height: grow(), direction: "ltr" }, + }), open("outer", { - layout: { width: fixed(4), height: fixed(4), direction: "ttb" }, + layout: { width: fixed(8), height: fixed(3), direction: "ltr" }, clip: { vertical: true, horizontal: true }, }), open("inner", { - layout: { width: fixed(2), height: fixed(1), direction: "ttb" }, + layout: { width: fixed(3), height: fixed(3), direction: "ttb" }, clip: { vertical: true, horizontal: true }, + border, }), - text("II"), - close(), // inner + close(), open("sibling", { - layout: { width: fixed(8), height: fixed(1), direction: "ttb" }, + layout: { + width: fixed(10), + height: fixed(3), + direction: "ttb", + padding: pad, + }, + border, + }), + text("clipped"), + close(), + close(), + open("ruler", { + layout: { width: fixed(1), height: fixed(3), direction: "ttb" }, }), - text("SSSSSSSS"), - close(), // sibling - close(), // outer + text("│\n│\n│"), + close(), + close(), ]).output; - let siblingRow = print(decode(out), 8, 4).split("\n")[1]; - // Outer clip width is 4; once the narrower inner clip closes the outer - // horizontal bound must be restored so cols >= 4 of the sibling are clipped. - expect(siblingRow).toEqual("SSSS "); + expect(trim(print(decode(out), 14, 6)).trim()).toEqual(` +┌─┐┌────│ +│ ││clip│ +└─┘└────│ +`.trim()); }); }); From b5a355d6a3f68902684e60132c70464d0362ca0b Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Sat, 6 Jun 2026 12:18:47 +0200 Subject: [PATCH 5/5] =?UTF-8?q?=F0=9F=93=9D=20Update=20render=20spec=20to?= =?UTF-8?q?=20include=20clipping=20behavior?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- specs/renderer-spec.md | 72 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 70 insertions(+), 2 deletions(-) diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index 6daced8..1df93c9 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -294,6 +294,43 @@ Creation of a Term is asynchronous because it may involve WASM module preparation. A Term instance MAY be used for any number of render transactions. The Term retains its cell buffers across frames for diffing purposes. +### 7.5 Clip semantics + +An element whose `props` include a `clip` group declares a **clip region**: a +rectangular bound on the cells its descendants are permitted to write. Cells +produced by descendants that fall outside this region MUST be suppressed from +the output. The clip region is determined by the element's computed layout box +and the axes selected by the `clip` group (`horizontal`, `vertical`, or both). + +Clip regions stack. When clip elements nest: + +- The effective clip region of an element MUST be the intersection of its own + declared region with the effective clip region of its nearest clipping + ancestor, if any. +- When the renderer finishes processing a clip element's subtree, it MUST + restore the effective clip region of that element's clipping ancestor. Later + siblings drawn within an ancestor clip MUST therefore remain bounded by that + ancestor. +- A `clip` element whose declared region is fully outside its ancestor's + effective region produces an empty effective region; descendants of that + element MUST NOT contribute any cells to the output. + +The renderer MAY impose an implementation-defined limit on the depth of clip +regions it can track. The limit itself is not normatively bounded. When a frame +nests clip regions more deeply than the renderer can track: + +- All clip regions whose entry the renderer successfully tracked MUST continue + to be honored for the remainder of the frame, including for siblings drawn + after the over-deep subtree closes. The renderer MUST maintain push/pop + symmetry so that exiting an untracked clip does not disturb any ancestor's + effective region. +- Content drawn inside an untracked clip region MUST remain bounded by the + deepest successfully-tracked ancestor clip region. The untracked region's own + additional restriction MAY be lost. +- The renderer MUST surface the condition via the render result's error channel + (see §12.3) before returning, so the caller can detect that some clipping was + not applied. + --- ## 8. Public Rendering API @@ -639,7 +676,10 @@ The `open()` constructor currently accepts the following property groups in its - **`border`** — per-side border widths and border color - **`cornerRadius`** — per-corner radius values, producing rounded box-drawing characters -- **`clip`** — clip region configuration for scroll containers +- **`clip`** — Declares the element as a clip region (see §7.5). Currently + accepts `horizontal: boolean` and `vertical: boolean` axis selectors. + Originally added for scroll containers; nesting and standalone use are + supported. - **`floating`** — floating-element configuration (offset, parent reference, attach points, z-index) - **`scroll`** — scroll container configuration @@ -706,7 +746,8 @@ The `errors` field contains any errors reported by the Clay layout engine during the most recent `render()` call. Each error is a `ClayError` object with: - `type`: a string identifying the error category. The following types are - defined, matching Clay's error taxonomy: + defined. Most mirror Clay's error taxonomy; `"CLIP_DEPTH_EXCEEDED"` is + Clayterm-specific. - `"TEXT_MEASUREMENT_FUNCTION_NOT_PROVIDED"` - `"ARENA_CAPACITY_EXCEEDED"` - `"ELEMENTS_CAPACITY_EXCEEDED"` @@ -716,6 +757,9 @@ the most recent `render()` call. Each error is a `ClayError` object with: - `"PERCENTAGE_OVER_1"` - `"INTERNAL_ERROR"` - `"UNBALANCED_OPEN_CLOSE"` + - `"CLIP_DEPTH_EXCEEDED"` — A frame nested clip regions more deeply than the + renderer could track. See §7.5 for the guarantees that still hold in this + case. The `message` SHOULD identify the renderer's tracking limit. - `message`: a human-readable string describing the error in detail. Errors are collected per-render; each call to `render()` returns only the errors @@ -788,6 +832,30 @@ background color. accumulates per-cell direction bitmasks and resolves them to correct box-drawing junction glyphs in a post-render pass. +**Clip stack.** Section 7.5 requires the effective clip region of a nested +`clip` element to be the intersection of its declared region with its clipping +ancestor's effective region. The underlying layout engine (Clay) emits +per-clip-element bounding boxes that are not pre-intersected with any ancestor's +clip, so the renderer maintains an internal stack of effective clip rectangles: +it pushes the intersected rect on each clip-region entry and pops on exit. The +stack capacity is a small fixed value sufficient for realistic UIs; depth beyond +that is handled per §7.5 (prior clips honored, the over-deep level coalesced +into its deepest tracked ancestor, and a `"CLIP_DEPTH_EXCEEDED"` error +surfaced). + +Upstream Clay may eventually flatten nested clip emission so renderers only need +single-rect handling; see +[nicbarker/clay#466](https://github.com/nicbarker/clay/issues/466) (the +underlying issue), +[nicbarker/clay#485](https://github.com/nicbarker/clay/pull/485) (in-flight +Clay-side fix), and +[nicbarker/clay#87](https://github.com/nicbarker/clay/issues/87) (renderer +guidance). When upgrading Clay, check whether a single clip element now produces +multiple `SCISSOR_START`/`SCISSOR_END` pairs across its lifetime (one per +nesting transition rather than just an outer pair); if so, the renderer-side +stack can be removed and replaced with a single rect storing Clay's bounding box +directly. + --- ## 14. Deferred / Future Areas