Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 70 additions & 2 deletions specs/renderer-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -648,7 +685,10 @@ The `open()` constructor currently accepts the following property groups in its
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, expansion, parent
reference, attach target, structured attach points, pointer capture mode, clip
target, z-index)
Expand Down Expand Up @@ -770,7 +810,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"`
Expand All @@ -780,6 +821,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
Expand Down Expand Up @@ -852,6 +896,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
Expand Down
111 changes: 103 additions & 8 deletions src/clayterm.c
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,38 @@

#define MAX_ERRORS 32

/* clip stack depth: nesting beyond this clamps to the deepest rect */
#define CLIP_STACK_MAX 16

/* Clayterm-specific error code, numbered past Clay's error enum (0..8).
* Mirrored by ERROR_TYPES in term.ts. */
#define CLAYTERM_ERR_CLIP_DEPTH_EXCEEDED 9

#define CLAYTERM_STR_(x) #x
#define CLAYTERM_STR(x) CLAYTERM_STR_(x)

typedef struct {
int x, y, w, h;
} ClipRect;

struct Clayterm {
int w, h;
Cell *front;
Cell *back;
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;
/* untracked clip levels open beyond CLIP_STACK_MAX; popped without
* touching the tracked stack so push/pop stays symmetric */
int clipoverflow;
/* set once per frame when nesting first exceeds the tracked depth */
int clip_depth_exceeded;
/* error collection */
Clay_ErrorData errors[MAX_ERRORS];
int error_count;
Expand Down Expand Up @@ -418,6 +440,28 @@ static void clay_error(Clay_ErrorData err) {
}
}

/* Surface a CLIP_DEPTH_EXCEEDED error once per frame. The message is a static
* literal, so its pointer lives in WASM linear memory and is readable by the
* host via error_message_ptr/length. */
static void report_clip_depth_exceeded(struct Clayterm *ct) {
if (ct->clip_depth_exceeded)
return;
ct->clip_depth_exceeded = 1;
if (ct->error_count >= MAX_ERRORS)
return;
static const char msg[] =
"clip nesting exceeds tracked depth limit of " CLAYTERM_STR(
CLIP_STACK_MAX) "; over-deep clips coalesced into the deepest "
"tracked region";
ct->errors[ct->error_count++] = (Clay_ErrorData){
.errorType = (Clay_ErrorType)CLAYTERM_ERR_CLIP_DEPTH_EXCEEDED,
.errorText = {.isStaticallyAllocated = true,
.length = (int32_t)(sizeof(msg) - 1),
.chars = msg},
.userData = ct,
};
}

int error_count(struct Clayterm *ct) { return ct->error_count; }

int error_type(struct Clayterm *ct, int index) {
Expand Down Expand Up @@ -611,6 +655,10 @@ 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->clipoverflow = 0;
ct->clip_depth_exceeded = 0;
ct->clipping = 0;

cells_fill(ct->back, ct->w, ct->h, ' ', ATTR_DEFAULT, ATTR_DEFAULT);

Expand All @@ -633,15 +681,62 @@ 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);
break;
case CLAY_RENDER_COMMAND_TYPE_SCISSOR_START:
ct->clipping = 1;
ct->clipx = x0;
ct->clipy = y0;
ct->clipw = x1 - x0;
ct->cliph = y1 - y0;
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 = nx0;
ct->clipy = ny0;
ct->clipw = nw;
ct->cliph = nh;
} else {
/* Out of tracked slots: coalesce this level into the deepest tracked
* region (leave the active rect untouched) and remember to pop it
* without disturbing the tracked stack. */
ct->clipoverflow++;
report_clip_depth_exceeded(ct);
}
break;
}
case CLAY_RENDER_COMMAND_TYPE_SCISSOR_END:
ct->clipping = 0;
if (ct->clipoverflow > 0) {
/* Closing an untracked level: nothing was pushed, so leave the tracked
* stack and active rect alone. */
ct->clipoverflow--;
break;
}
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;
Expand Down
1 change: 1 addition & 0 deletions term.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const ERROR_TYPES = [
"PERCENTAGE_OVER_1",
"INTERNAL_ERROR",
"UNBALANCED_OPEN_CLOSE",
"CLIP_DEPTH_EXCEEDED",
] as const;

export interface ClayError {
Expand Down
Loading
Loading