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 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; diff --git a/test/clip.test.ts b/test/clip.test.ts new file mode 100644 index 0000000..5d4efee --- /dev/null +++ b/test/clip.test.ts @@ -0,0 +1,115 @@ +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"); + +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 }; + +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 }); + + let out = term.render([ + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + }), + open("outer", { + layout: { width: fixed(14), height: fixed(4), direction: "ttb" }, + clip: { vertical: true, horizontal: true }, + }), + open("inner", { + layout: { width: fixed(6), height: fixed(3), direction: "ttb" }, + clip: { vertical: true, horizontal: true }, + border, + }), + close(), + open("sibling", { + layout: { + width: fixed(10), + height: fixed(3), + direction: "ttb", + padding: pad, + }, + border, + }), + text("clipped"), + close(), + close(), + open("ruler", { + layout: { width: fixed(14), height: fixed(1), direction: "ttb" }, + }), + text("──────────────"), + close(), + close(), + ]).output; + + expect(trim(print(decode(out), 14, 8)).trim()).toEqual(` +┌────┐ +│ │ +└────┘ +┌────────┐ +────────────── +`.trim()); + }); + + // 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(8), height: fixed(3), direction: "ltr" }, + clip: { vertical: true, horizontal: true }, + }), + open("inner", { + layout: { width: fixed(3), height: fixed(3), direction: "ttb" }, + clip: { vertical: true, horizontal: true }, + border, + }), + close(), + open("sibling", { + 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("│\n│\n│"), + close(), + close(), + ]).output; + + expect(trim(print(decode(out), 14, 6)).trim()).toEqual(` +┌─┐┌────│ +│ ││clip│ +└─┘└────│ +`.trim()); + }); +});