[READ-ONLY] Mirror of https://github.com/bombshell-dev/tty. Platform independent 2D layout engine for terminal applications based on Clay
5

Configure Feed

Select the types of activity you want to include in your feed.

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>

authored by

Nate Moore
Charles Lowell
and committed by
GitHub
(Jun 23, 2026, 6:21 PM EDT) 12944f08 9de77629

+350 -10
+70 -2
specs/renderer-spec.md
··· 294 294 preparation. A Term instance MAY be used for any number of render transactions. 295 295 The Term retains its cell buffers across frames for diffing purposes. 296 296 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 + 297 334 --- 298 335 299 336 ## 8. Public Rendering API ··· 648 685 color 649 686 - **`cornerRadius`** β€” per-corner radius values, producing rounded box-drawing 650 687 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. 652 692 - **`floating`** β€” floating-element configuration (offset, expansion, parent 653 693 reference, attach target, structured attach points, pointer capture mode, clip 654 694 target, z-index) ··· 770 810 the most recent `render()` call. Each error is a `ClayError` object with: 771 811 772 812 - `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. 774 815 - `"TEXT_MEASUREMENT_FUNCTION_NOT_PROVIDED"` 775 816 - `"ARENA_CAPACITY_EXCEEDED"` 776 817 - `"ELEMENTS_CAPACITY_EXCEEDED"` ··· 780 821 - `"PERCENTAGE_OVER_1"` 781 822 - `"INTERNAL_ERROR"` 782 823 - `"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. 783 827 - `message`: a human-readable string describing the error in detail. 784 828 785 829 Errors are collected per-render; each call to `render()` returns only the errors ··· 851 895 **Border junction resolution.** When bordered elements share edges, the renderer 852 896 accumulates per-cell direction bitmasks and resolves them to correct box-drawing 853 897 junction glyphs in a post-render pass. 898 + 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. 854 922 855 923 --- 856 924
+103 -8
src/clayterm.c
··· 38 38 39 39 #define MAX_ERRORS 32 40 40 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 + 41 55 struct Clayterm { 42 56 int w, h; 43 57 Cell *front; ··· 45 59 Buffer out; 46 60 uint32_t lastfg, lastbg; 47 61 int lastx, lasty; 48 - /* clip region */ 62 + /* clip region (active top mirrored here so setcell stays unchanged) */ 49 63 int clipx, clipy, clipw, cliph; 50 64 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; 51 73 /* error collection */ 52 74 Clay_ErrorData errors[MAX_ERRORS]; 53 75 int error_count; ··· 418 440 } 419 441 } 420 442 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 + 421 465 int error_count(struct Clayterm *ct) { return ct->error_count; } 422 466 423 467 int error_type(struct Clayterm *ct, int index) { ··· 611 655 ct->out.length = 0; 612 656 ct->lastfg = ct->lastbg = 0xffffffff; 613 657 ct->lastx = ct->lasty = -1; 658 + ct->clipdepth = 0; 659 + ct->clipoverflow = 0; 660 + ct->clip_depth_exceeded = 0; 661 + ct->clipping = 0; 614 662 615 663 cells_fill(ct->back, ct->w, ct->h, ' ', ATTR_DEFAULT, ATTR_DEFAULT); 616 664 ··· 633 681 case CLAY_RENDER_COMMAND_TYPE_BORDER: 634 682 render_border(ct, x0, y0, x1, y1, cmd); 635 683 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 + } 642 719 break; 720 + } 643 721 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 + } 645 740 break; 646 741 default: 647 742 break;
+1
term.ts
··· 48 48 "PERCENTAGE_OVER_1", 49 49 "INTERNAL_ERROR", 50 50 "UNBALANCED_OPEN_CLOSE", 51 + "CLIP_DEPTH_EXCEEDED", 51 52 ] as const; 52 53 53 54 export interface ClayError {
+176
test/clip.test.ts
··· 1 + import { describe, expect, it } from "./suite.ts"; 2 + import { createTerm } from "../term.ts"; 3 + import { close, fixed, grow, open, rgba, text } from "../ops.ts"; 4 + import { print } from "./print.ts"; 5 + 6 + const decode = (b: Uint8Array) => new TextDecoder().decode(b); 7 + const trim = (s: string) => s.split("\n").map((l) => l.trimEnd()).join("\n"); 8 + 9 + const white = rgba(255, 255, 255); 10 + const border = { color: white, left: 1, right: 1, top: 1, bottom: 1 }; 11 + const pad = { left: 1, right: 1, top: 1, bottom: 1 }; 12 + 13 + describe("clip", () => { 14 + // rulesr marks the bottom of an invisible 14Γ—4 clip(outer); 15 + // β”Œβ”€β”€β”€β”€β” 16 + // β”‚ β”‚ 17 + // β””β”€β”€β”€β”€β”˜ 18 + // β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” 19 + // ────────────── 20 + // β”‚clipped β”‚ 21 + // β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ 22 + it("restores outer vertical bound for a sibling after a nested clip", async () => { 23 + let term = await createTerm({ width: 14, height: 8 }); 24 + 25 + let out = term.render([ 26 + open("root", { 27 + layout: { width: grow(), height: grow(), direction: "ttb" }, 28 + }), 29 + open("outer", { 30 + layout: { width: fixed(14), height: fixed(4), direction: "ttb" }, 31 + clip: { vertical: true, horizontal: true }, 32 + }), 33 + open("inner", { 34 + layout: { width: fixed(6), height: fixed(3), direction: "ttb" }, 35 + clip: { vertical: true, horizontal: true }, 36 + border, 37 + }), 38 + close(), 39 + open("sibling", { 40 + layout: { 41 + width: fixed(10), 42 + height: fixed(3), 43 + direction: "ttb", 44 + padding: pad, 45 + }, 46 + border, 47 + }), 48 + text("clipped"), 49 + close(), 50 + close(), 51 + open("ruler", { 52 + layout: { width: fixed(14), height: fixed(1), direction: "ttb" }, 53 + }), 54 + text("──────────────"), 55 + close(), 56 + close(), 57 + ]).output; 58 + 59 + expect(trim(print(decode(out), 14, 8)).trim()).toEqual(` 60 + β”Œβ”€β”€β”€β”€β” 61 + β”‚ β”‚ 62 + β””β”€β”€β”€β”€β”˜ 63 + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” 64 + ────────────── 65 + `.trim()); 66 + }); 67 + 68 + // ruler marks the right boundary of an invisible 8Γ—3 ltr clip 69 + // β”Œβ”€β”β”Œβ”€β”€β”€β”€β”‚β”€β”€β”€β” 70 + // β”‚ β”‚β”‚clipβ”‚ed β”‚ 71 + // β””β”€β”˜β””β”€β”€β”€β”€β”‚β”€β”€β”€β”˜ 72 + it("restores outer horizontal bound for a sibling after a nested clip", async () => { 73 + let term = await createTerm({ width: 14, height: 6 }); 74 + 75 + let out = term.render([ 76 + open("root", { 77 + layout: { width: grow(), height: grow(), direction: "ltr" }, 78 + }), 79 + open("outer", { 80 + layout: { width: fixed(8), height: fixed(3), direction: "ltr" }, 81 + clip: { vertical: true, horizontal: true }, 82 + }), 83 + open("inner", { 84 + layout: { width: fixed(3), height: fixed(3), direction: "ttb" }, 85 + clip: { vertical: true, horizontal: true }, 86 + border, 87 + }), 88 + close(), 89 + open("sibling", { 90 + layout: { 91 + width: fixed(10), 92 + height: fixed(3), 93 + direction: "ttb", 94 + padding: pad, 95 + }, 96 + border, 97 + }), 98 + text("clipped"), 99 + close(), 100 + close(), 101 + open("ruler", { 102 + layout: { width: fixed(1), height: fixed(3), direction: "ttb" }, 103 + }), 104 + text("β”‚\nβ”‚\nβ”‚"), 105 + close(), 106 + close(), 107 + ]).output; 108 + 109 + expect(trim(print(decode(out), 14, 6)).trim()).toEqual(` 110 + β”Œβ”€β”β”Œβ”€β”€β”€β”€β”‚ 111 + β”‚ β”‚β”‚clipβ”‚ 112 + β””β”€β”˜β””β”€β”€β”€β”€β”‚ 113 + `.trim()); 114 + }); 115 + 116 + // Nesting clips past the renderer's tracking limit must not break push/pop 117 + // symmetry: the over-deep levels coalesce into the deepest tracked clip, and 118 + // closing them leaves the outer clip intact for a later sibling. With a 119 + // border on the outermost nested clip, the over-deep frame must render 120 + // identically to the depth-2 case above β€” the ruler marks the 8-wide outer 121 + // bound and the sibling is cut there. 122 + // β”Œβ”€β”β”Œβ”€β”€β”€β”€β”‚ 123 + // β”‚ β”‚β”‚clipβ”‚ 124 + // β””β”€β”˜β””β”€β”€β”€β”€β”‚ 125 + it("preserves the outer clip for a sibling after over-deep nesting", async () => { 126 + let term = await createTerm({ width: 14, height: 6 }); 127 + 128 + let nest: ReturnType<typeof open>[] = []; 129 + for (let d = 0; d < 20; d++) { 130 + nest.push(open(`deep${d}`, { 131 + layout: { width: fixed(3), height: fixed(3), direction: "ttb" }, 132 + clip: { vertical: true, horizontal: true }, 133 + ...(d === 0 ? { border } : {}), 134 + })); 135 + } 136 + let unnest = nest.map(() => close()); 137 + 138 + let result = term.render([ 139 + open("root", { 140 + layout: { width: grow(), height: grow(), direction: "ltr" }, 141 + }), 142 + open("outer", { 143 + layout: { width: fixed(8), height: fixed(3), direction: "ltr" }, 144 + clip: { vertical: true, horizontal: true }, 145 + }), 146 + ...nest, 147 + ...unnest, 148 + open("sibling", { 149 + layout: { 150 + width: fixed(10), 151 + height: fixed(3), 152 + direction: "ttb", 153 + padding: pad, 154 + }, 155 + border, 156 + }), 157 + text("clipped"), 158 + close(), 159 + close(), 160 + open("ruler", { 161 + layout: { width: fixed(1), height: fixed(3), direction: "ttb" }, 162 + }), 163 + text("β”‚\nβ”‚\nβ”‚"), 164 + close(), 165 + close(), 166 + ]); 167 + 168 + expect(trim(print(decode(result.output), 14, 6)).trim()).toEqual(` 169 + β”Œβ”€β”β”Œβ”€β”€β”€β”€β”‚ 170 + β”‚ β”‚β”‚clipβ”‚ 171 + β””β”€β”˜β””β”€β”€β”€β”€β”‚ 172 + `.trim()); 173 + 174 + expect(result.errors.map((e) => e.type)).toContain("CLIP_DEPTH_EXCEEDED"); 175 + }); 176 + });