Skip to content
Open
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 @@ -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
Expand Down Expand Up @@ -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"`
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
61 changes: 54 additions & 7 deletions src/clayterm.c
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,26 @@

#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;
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;
/* error collection */
Clay_ErrorData errors[MAX_ERRORS];
int error_count;
Expand Down Expand Up @@ -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);

Expand All @@ -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;
Expand Down
115 changes: 115 additions & 0 deletions test/clip.test.ts
Original file line number Diff line number Diff line change
@@ -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());
});
});
Loading