Skip to content

Commit 12944f0

Browse files
natemoo-recowboyd
andauthored
fix: stack clip regions so nested clips restore the parent rect (#85)
* test: failing repro for nested-clip * 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 * test: move nested-clip regression into test/clip.test.ts * 🌆 Use visual language for clipping tests * 📝 Update render spec to include clipping behavior * fix: honor clip depth limit --------- Co-authored-by: Charles Lowell <cowboyd@frontside.com>
1 parent 9de7762 commit 12944f0

4 files changed

Lines changed: 350 additions & 10 deletions

File tree

specs/renderer-spec.md

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,43 @@ Creation of a Term is asynchronous because it may involve WASM module
294294
preparation. A Term instance MAY be used for any number of render transactions.
295295
The Term retains its cell buffers across frames for diffing purposes.
296296

297+
### 7.5 Clip semantics
298+
299+
An element whose `props` include a `clip` group declares a **clip region**: a
300+
rectangular bound on the cells its descendants are permitted to write. Cells
301+
produced by descendants that fall outside this region MUST be suppressed from
302+
the output. The clip region is determined by the element's computed layout box
303+
and the axes selected by the `clip` group (`horizontal`, `vertical`, or both).
304+
305+
Clip regions stack. When clip elements nest:
306+
307+
- The effective clip region of an element MUST be the intersection of its own
308+
declared region with the effective clip region of its nearest clipping
309+
ancestor, if any.
310+
- When the renderer finishes processing a clip element's subtree, it MUST
311+
restore the effective clip region of that element's clipping ancestor. Later
312+
siblings drawn within an ancestor clip MUST therefore remain bounded by that
313+
ancestor.
314+
- A `clip` element whose declared region is fully outside its ancestor's
315+
effective region produces an empty effective region; descendants of that
316+
element MUST NOT contribute any cells to the output.
317+
318+
The renderer MAY impose an implementation-defined limit on the depth of clip
319+
regions it can track. The limit itself is not normatively bounded. When a frame
320+
nests clip regions more deeply than the renderer can track:
321+
322+
- All clip regions whose entry the renderer successfully tracked MUST continue
323+
to be honored for the remainder of the frame, including for siblings drawn
324+
after the over-deep subtree closes. The renderer MUST maintain push/pop
325+
symmetry so that exiting an untracked clip does not disturb any ancestor's
326+
effective region.
327+
- Content drawn inside an untracked clip region MUST remain bounded by the
328+
deepest successfully-tracked ancestor clip region. The untracked region's own
329+
additional restriction MAY be lost.
330+
- The renderer MUST surface the condition via the render result's error channel
331+
(see §12.3) before returning, so the caller can detect that some clipping was
332+
not applied.
333+
297334
---
298335

299336
## 8. Public Rendering API
@@ -648,7 +685,10 @@ The `open()` constructor currently accepts the following property groups in its
648685
color
649686
- **`cornerRadius`** — per-corner radius values, producing rounded box-drawing
650687
characters
651-
- **`clip`** — clip region configuration for scroll containers
688+
- **`clip`** — Declares the element as a clip region (see §7.5). Currently
689+
accepts `horizontal: boolean` and `vertical: boolean` axis selectors.
690+
Originally added for scroll containers; nesting and standalone use are
691+
supported.
652692
- **`floating`** — floating-element configuration (offset, expansion, parent
653693
reference, attach target, structured attach points, pointer capture mode, clip
654694
target, z-index)
@@ -770,7 +810,8 @@ The `errors` field contains any errors reported by the Clay layout engine during
770810
the most recent `render()` call. Each error is a `ClayError` object with:
771811

772812
- `type`: a string identifying the error category. The following types are
773-
defined, matching Clay's error taxonomy:
813+
defined. Most mirror Clay's error taxonomy; `"CLIP_DEPTH_EXCEEDED"` is
814+
Clayterm-specific.
774815
- `"TEXT_MEASUREMENT_FUNCTION_NOT_PROVIDED"`
775816
- `"ARENA_CAPACITY_EXCEEDED"`
776817
- `"ELEMENTS_CAPACITY_EXCEEDED"`
@@ -780,6 +821,9 @@ the most recent `render()` call. Each error is a `ClayError` object with:
780821
- `"PERCENTAGE_OVER_1"`
781822
- `"INTERNAL_ERROR"`
782823
- `"UNBALANCED_OPEN_CLOSE"`
824+
- `"CLIP_DEPTH_EXCEEDED"` — A frame nested clip regions more deeply than the
825+
renderer could track. See §7.5 for the guarantees that still hold in this
826+
case. The `message` SHOULD identify the renderer's tracking limit.
783827
- `message`: a human-readable string describing the error in detail.
784828

785829
Errors are collected per-render; each call to `render()` returns only the errors
@@ -852,6 +896,30 @@ background color.
852896
accumulates per-cell direction bitmasks and resolves them to correct box-drawing
853897
junction glyphs in a post-render pass.
854898

899+
**Clip stack.** Section 7.5 requires the effective clip region of a nested
900+
`clip` element to be the intersection of its declared region with its clipping
901+
ancestor's effective region. The underlying layout engine (Clay) emits
902+
per-clip-element bounding boxes that are not pre-intersected with any ancestor's
903+
clip, so the renderer maintains an internal stack of effective clip rectangles:
904+
it pushes the intersected rect on each clip-region entry and pops on exit. The
905+
stack capacity is a small fixed value sufficient for realistic UIs; depth beyond
906+
that is handled per §7.5 (prior clips honored, the over-deep level coalesced
907+
into its deepest tracked ancestor, and a `"CLIP_DEPTH_EXCEEDED"` error
908+
surfaced).
909+
910+
Upstream Clay may eventually flatten nested clip emission so renderers only need
911+
single-rect handling; see
912+
[nicbarker/clay#466](https://github.com/nicbarker/clay/issues/466) (the
913+
underlying issue),
914+
[nicbarker/clay#485](https://github.com/nicbarker/clay/pull/485) (in-flight
915+
Clay-side fix), and
916+
[nicbarker/clay#87](https://github.com/nicbarker/clay/issues/87) (renderer
917+
guidance). When upgrading Clay, check whether a single clip element now produces
918+
multiple `SCISSOR_START`/`SCISSOR_END` pairs across its lifetime (one per
919+
nesting transition rather than just an outer pair); if so, the renderer-side
920+
stack can be removed and replaced with a single rect storing Clay's bounding box
921+
directly.
922+
855923
---
856924

857925
## 14. Deferred / Future Areas

src/clayterm.c

Lines changed: 103 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -38,16 +38,38 @@
3838

3939
#define MAX_ERRORS 32
4040

41+
/* clip stack depth: nesting beyond this clamps to the deepest rect */
42+
#define CLIP_STACK_MAX 16
43+
44+
/* Clayterm-specific error code, numbered past Clay's error enum (0..8).
45+
* Mirrored by ERROR_TYPES in term.ts. */
46+
#define CLAYTERM_ERR_CLIP_DEPTH_EXCEEDED 9
47+
48+
#define CLAYTERM_STR_(x) #x
49+
#define CLAYTERM_STR(x) CLAYTERM_STR_(x)
50+
51+
typedef struct {
52+
int x, y, w, h;
53+
} ClipRect;
54+
4155
struct Clayterm {
4256
int w, h;
4357
Cell *front;
4458
Cell *back;
4559
Buffer out;
4660
uint32_t lastfg, lastbg;
4761
int lastx, lasty;
48-
/* clip region */
62+
/* clip region (active top mirrored here so setcell stays unchanged) */
4963
int clipx, clipy, clipw, cliph;
5064
int clipping;
65+
/* clip stack: nesting pushes intersected rects, leaving pops to restore */
66+
ClipRect clipstack[CLIP_STACK_MAX];
67+
int clipdepth;
68+
/* untracked clip levels open beyond CLIP_STACK_MAX; popped without
69+
* touching the tracked stack so push/pop stays symmetric */
70+
int clipoverflow;
71+
/* set once per frame when nesting first exceeds the tracked depth */
72+
int clip_depth_exceeded;
5173
/* error collection */
5274
Clay_ErrorData errors[MAX_ERRORS];
5375
int error_count;
@@ -418,6 +440,28 @@ static void clay_error(Clay_ErrorData err) {
418440
}
419441
}
420442

443+
/* Surface a CLIP_DEPTH_EXCEEDED error once per frame. The message is a static
444+
* literal, so its pointer lives in WASM linear memory and is readable by the
445+
* host via error_message_ptr/length. */
446+
static void report_clip_depth_exceeded(struct Clayterm *ct) {
447+
if (ct->clip_depth_exceeded)
448+
return;
449+
ct->clip_depth_exceeded = 1;
450+
if (ct->error_count >= MAX_ERRORS)
451+
return;
452+
static const char msg[] =
453+
"clip nesting exceeds tracked depth limit of " CLAYTERM_STR(
454+
CLIP_STACK_MAX) "; over-deep clips coalesced into the deepest "
455+
"tracked region";
456+
ct->errors[ct->error_count++] = (Clay_ErrorData){
457+
.errorType = (Clay_ErrorType)CLAYTERM_ERR_CLIP_DEPTH_EXCEEDED,
458+
.errorText = {.isStaticallyAllocated = true,
459+
.length = (int32_t)(sizeof(msg) - 1),
460+
.chars = msg},
461+
.userData = ct,
462+
};
463+
}
464+
421465
int error_count(struct Clayterm *ct) { return ct->error_count; }
422466

423467
int error_type(struct Clayterm *ct, int index) {
@@ -611,6 +655,10 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row) {
611655
ct->out.length = 0;
612656
ct->lastfg = ct->lastbg = 0xffffffff;
613657
ct->lastx = ct->lasty = -1;
658+
ct->clipdepth = 0;
659+
ct->clipoverflow = 0;
660+
ct->clip_depth_exceeded = 0;
661+
ct->clipping = 0;
614662

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

@@ -633,15 +681,62 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row) {
633681
case CLAY_RENDER_COMMAND_TYPE_BORDER:
634682
render_border(ct, x0, y0, x1, y1, cmd);
635683
break;
636-
case CLAY_RENDER_COMMAND_TYPE_SCISSOR_START:
637-
ct->clipping = 1;
638-
ct->clipx = x0;
639-
ct->clipy = y0;
640-
ct->clipw = x1 - x0;
641-
ct->cliph = y1 - y0;
684+
case CLAY_RENDER_COMMAND_TYPE_SCISSOR_START: {
685+
/* intersect the child box with the current active rect (if any) */
686+
int nx0 = x0, ny0 = y0, nx1 = x1, ny1 = y1;
687+
if (ct->clipdepth > 0) {
688+
ClipRect top = ct->clipstack[ct->clipdepth - 1];
689+
if (top.x > nx0)
690+
nx0 = top.x;
691+
if (top.y > ny0)
692+
ny0 = top.y;
693+
if (top.x + top.w < nx1)
694+
nx1 = top.x + top.w;
695+
if (top.y + top.h < ny1)
696+
ny1 = top.y + top.h;
697+
}
698+
int nw = nx1 - nx0;
699+
int nh = ny1 - ny0;
700+
if (nw < 0)
701+
nw = 0;
702+
if (nh < 0)
703+
nh = 0;
704+
if (ct->clipdepth < CLIP_STACK_MAX) {
705+
ClipRect r = {nx0, ny0, nw, nh};
706+
ct->clipstack[ct->clipdepth++] = r;
707+
ct->clipping = 1;
708+
ct->clipx = nx0;
709+
ct->clipy = ny0;
710+
ct->clipw = nw;
711+
ct->cliph = nh;
712+
} else {
713+
/* Out of tracked slots: coalesce this level into the deepest tracked
714+
* region (leave the active rect untouched) and remember to pop it
715+
* without disturbing the tracked stack. */
716+
ct->clipoverflow++;
717+
report_clip_depth_exceeded(ct);
718+
}
642719
break;
720+
}
643721
case CLAY_RENDER_COMMAND_TYPE_SCISSOR_END:
644-
ct->clipping = 0;
722+
if (ct->clipoverflow > 0) {
723+
/* Closing an untracked level: nothing was pushed, so leave the tracked
724+
* stack and active rect alone. */
725+
ct->clipoverflow--;
726+
break;
727+
}
728+
if (ct->clipdepth > 0)
729+
ct->clipdepth--;
730+
if (ct->clipdepth > 0) {
731+
ClipRect top = ct->clipstack[ct->clipdepth - 1];
732+
ct->clipping = 1;
733+
ct->clipx = top.x;
734+
ct->clipy = top.y;
735+
ct->clipw = top.w;
736+
ct->cliph = top.h;
737+
} else {
738+
ct->clipping = 0;
739+
}
645740
break;
646741
default:
647742
break;

term.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ const ERROR_TYPES = [
4848
"PERCENTAGE_OVER_1",
4949
"INTERNAL_ERROR",
5050
"UNBALANCED_OPEN_CLOSE",
51+
"CLIP_DEPTH_EXCEEDED",
5152
] as const;
5253

5354
export interface ClayError {

0 commit comments

Comments
 (0)