From 4eccfd0408288e5ec556b46d03f9281af4bbe3d3 Mon Sep 17 00:00:00 2001 From: Ryan Rauh Date: Sat, 11 Apr 2026 08:31:36 -0400 Subject: [PATCH 1/4] feat: add childOffset to clip regions Add childOffset.x and childOffset.y to the clip configuration, allowing clipped containers to scroll their children by an arbitrary pixel offset. This is the foundation for virtual-scroll viewports where visible content is a sliding window over a larger child. --- ops.ts | 10 +++++++++- src/clayterm.c | 2 ++ test/term.test.ts | 37 ++++++++++++++++++++++++++++++++++++- validate.ts | 4 ++++ 4 files changed, 51 insertions(+), 2 deletions(-) diff --git a/ops.ts b/ops.ts index 8f952af..01a024f 100644 --- a/ops.ts +++ b/ops.ts @@ -157,6 +157,10 @@ export function pack( true, ); o += 4; + view.setFloat32(o, op.clip.childOffset?.x ?? 0, true); + o += 4; + view.setFloat32(o, op.clip.childOffset?.y ?? 0, true); + o += 4; } if (op.floating) { @@ -268,7 +272,11 @@ export interface OpenElement { top?: number; bottom?: number; }; - clip?: { horizontal?: boolean; vertical?: boolean }; + clip?: { + horizontal?: boolean; + vertical?: boolean; + childOffset?: { x?: number; y?: number }; + }; floating?: { x?: number; y?: number; diff --git a/src/clayterm.c b/src/clayterm.c index 6fd50c0..1bc55bd 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -458,6 +458,8 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len) { uint32_t cl = rd(buf, len, &i); decl.clip.horizontal = cl & 0xff; decl.clip.vertical = (cl >> 8) & 0xff; + decl.clip.childOffset.x = rdf(buf, len, &i); + decl.clip.childOffset.y = rdf(buf, len, &i); } if (mask & PROP_FLOATING) { diff --git a/test/term.test.ts b/test/term.test.ts index c1ac926..e4941f9 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it } from "./suite.ts"; import { createTerm, type Term } from "../term.ts"; -import { close, grow, open, rgba, text } from "../ops.ts"; +import { close, fixed, grow, open, rgba, text } from "../ops.ts"; import { print } from "./print.ts"; const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes); @@ -89,6 +89,41 @@ describe("term", () => { ╰──────────────────────────────────────╯`.trim()); }); + it("clips children with horizontal child offsets", () => { + let out = print( + decode( + term.render([ + open("root", { + layout: { width: fixed(40), height: fixed(10), direction: "ttb" }, + }), + open("viewport", { + layout: { width: fixed(8), height: fixed(1) }, + clip: { horizontal: true, childOffset: { x: -2 } }, + }), + open("track", { + layout: { width: fixed(12), height: fixed(1), direction: "ltr" }, + }), + open("a", { layout: { width: fixed(4), height: fixed(1) } }), + text("ABCD"), + close(), + open("b", { layout: { width: fixed(4), height: fixed(1) } }), + text("EFGH"), + close(), + open("c", { layout: { width: fixed(4), height: fixed(1) } }), + text("IJKL"), + close(), + close(), + close(), + close(), + ]).output, + ), + 40, + 10, + ); + + expect(out.split("\n")[0]).toBe("CDEFGHIJ "); + }); + describe("row offset", () => { it("renders two frames at the offset position", async () => { let term = await createTerm({ width: 20, height: 5, top: 5 }); diff --git a/validate.ts b/validate.ts index d3b7d45..56381e5 100644 --- a/validate.ts +++ b/validate.ts @@ -74,6 +74,10 @@ const Border = Type.Object({ const Clip = Type.Object({ horizontal: Type.Optional(Type.Boolean()), vertical: Type.Optional(Type.Boolean()), + childOffset: Type.Optional(Type.Object({ + x: Type.Optional(Type.Number()), + y: Type.Optional(Type.Number()), + })), }); const Floating = Type.Object({ From 6fd820209b9e097c970a3c2b1c65a61692a855d1 Mon Sep 17 00:00:00 2001 From: Ryan Rauh Date: Sat, 11 Apr 2026 08:32:51 -0400 Subject: [PATCH 2/4] feat: expand floating attachment parameters Restructure the floating element configuration to support the full range of Clay floating options: expand dimensions, structured attach points (element + parent), pointer capture mode, and clip-to-parent. This enables proper tooltip, popover, and modal positioning relative to parent elements. --- ops.ts | 50 ++++++++++++++++++++++++++++++++++++++++++++--- src/clayterm.c | 8 +++++++- test/term.test.ts | 46 ++++++++++++++++++++++++++++++++++++++++++- validate.ts | 11 ++++++++++- 4 files changed, 109 insertions(+), 6 deletions(-) diff --git a/ops.ts b/ops.ts index 01a024f..3114f03 100644 --- a/ops.ts +++ b/ops.ts @@ -169,12 +169,24 @@ export function pack( o += 4; view.setFloat32(o, f.y ?? 0, true); o += 4; + view.setFloat32(o, f.expand?.width ?? 0, true); + o += 4; + view.setFloat32(o, f.expand?.height ?? 0, true); + o += 4; view.setUint32(o, f.parent ?? 0, true); o += 4; view.setUint32( o, - (f.attachTo ?? 0) | ((f.attachPoints ?? 0) << 8) | - ((f.zIndex ?? 0) << 16), + (f.attachTo ?? 0) | + ((f.attachPoints?.element ?? 0) << 8) | + ((f.attachPoints?.parent ?? 0) << 16) | + ((f.pointerCaptureMode ?? 0) << 24), + true, + ); + o += 4; + view.setUint32( + o, + (f.clipTo ?? 0) | (((f.zIndex ?? 0) & 0xffff) << 8), true, ); o += 4; @@ -280,13 +292,45 @@ export interface OpenElement { floating?: { x?: number; y?: number; + expand?: { width?: number; height?: number }; parent?: number; attachTo?: number; - attachPoints?: number; + attachPoints?: { element?: number; parent?: number }; + pointerCaptureMode?: number; + clipTo?: number; zIndex?: number; }; } +export const ATTACH_POINT = { + LEFT_TOP: 0, + LEFT_CENTER: 1, + LEFT_BOTTOM: 2, + CENTER_TOP: 3, + CENTER_CENTER: 4, + CENTER_BOTTOM: 5, + RIGHT_TOP: 6, + RIGHT_CENTER: 7, + RIGHT_BOTTOM: 8, +} as const; + +export const ATTACH_TO = { + NONE: 0, + PARENT: 1, + ELEMENT_WITH_ID: 2, + ROOT: 3, +} as const; + +export const POINTER_CAPTURE_MODE = { + CAPTURE: 0, + PASSTHROUGH: 1, +} as const; + +export const CLIP_TO = { + NONE: 0, + ATTACHED_PARENT: 1, +} as const; + export interface Text { id: typeof OP_TEXT; content: string; diff --git a/src/clayterm.c b/src/clayterm.c index 1bc55bd..9156464 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -465,13 +465,19 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len) { if (mask & PROP_FLOATING) { decl.floating.offset.x = rdf(buf, len, &i); decl.floating.offset.y = rdf(buf, len, &i); + decl.floating.expand.width = rdf(buf, len, &i); + decl.floating.expand.height = rdf(buf, len, &i); decl.floating.parentId = rd(buf, len, &i); uint32_t fc = rd(buf, len, &i); decl.floating.attachTo = fc & 0xff; decl.floating.attachPoints.element = (fc >> 8) & 0xff; decl.floating.attachPoints.parent = (fc >> 16) & 0xff; - decl.floating.zIndex = (int16_t)((fc >> 24) & 0xff); + decl.floating.pointerCaptureMode = (fc >> 24) & 0xff; + + uint32_t fd = rd(buf, len, &i); + decl.floating.clipTo = fd & 0xff; + decl.floating.zIndex = (int16_t)(fd >> 8); } Clay__ConfigureOpenElement(decl); diff --git a/test/term.test.ts b/test/term.test.ts index e4941f9..5c2b650 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it } from "./suite.ts"; import { createTerm, type Term } from "../term.ts"; -import { close, fixed, grow, open, rgba, text } from "../ops.ts"; +import { ATTACH_POINT, ATTACH_TO, close, fixed, grow, open, rgba, text } from "../ops.ts"; import { print } from "./print.ts"; const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes); @@ -124,6 +124,50 @@ describe("term", () => { expect(out.split("\n")[0]).toBe("CDEFGHIJ "); }); + it("moves a bordered floating frame as one unit", () => { + let out = print( + decode( + term.render([ + open("root", { + layout: { width: fixed(40), height: fixed(10), direction: "ttb" }, + }), + open("frame", { + layout: { + width: fixed(12), + height: fixed(5), + direction: "ttb", + padding: { left: 1, top: 1 }, + }, + border: { + color: rgba(255, 255, 255), + left: 1, + right: 1, + top: 1, + bottom: 1, + }, + floating: { + x: 3, + y: 1, + attachTo: ATTACH_TO.ROOT, + attachPoints: { + element: ATTACH_POINT.CENTER_CENTER, + parent: ATTACH_POINT.CENTER_CENTER, + }, + }, + }), + text("box"), + close(), + close(), + ]).output, + ), + 40, + 10, + ); + + expect(out).toContain("│box │"); + expect(out.split("\n")[3]).toContain("┌──────────┐"); + }); + describe("row offset", () => { it("renders two frames at the offset position", async () => { let term = await createTerm({ width: 20, height: 5, top: 5 }); diff --git a/validate.ts b/validate.ts index 56381e5..8ccb016 100644 --- a/validate.ts +++ b/validate.ts @@ -83,9 +83,18 @@ const Clip = Type.Object({ const Floating = Type.Object({ x: Type.Optional(Type.Number()), y: Type.Optional(Type.Number()), + expand: Type.Optional(Type.Object({ + width: Type.Optional(Type.Number()), + height: Type.Optional(Type.Number()), + })), parent: Type.Optional(Type.Integer({ minimum: 0 })), attachTo: Type.Optional(u8), - attachPoints: Type.Optional(u8), + attachPoints: Type.Optional(Type.Object({ + element: Type.Optional(u8), + parent: Type.Optional(u8), + })), + pointerCaptureMode: Type.Optional(u8), + clipTo: Type.Optional(u8), zIndex: Type.Optional(u16), }); From 330a0ceb6a2900e057299965e174bbab88c9f1e4 Mon Sep 17 00:00:00 2001 From: Ryan Rauh Date: Sat, 11 Apr 2026 08:38:44 -0400 Subject: [PATCH 3/4] feat: add Clay transition support Add full transition lifecycle support to the rendering pipeline: - Transition presets (enter-from-left/right, exit-to-left/right) - Configurable easing handlers, duration, and per-property targeting - Overlay color blending during transitions - has_active_transitions() query for driving animation loops - Delta-time parameter on reduce() for frame-accurate interpolation - Fix stable element hashing (use constant seed instead of incrementing) --- clay | 2 +- ops.ts | 93 +++++++++++++++++++++++++ src/clayterm.c | 156 +++++++++++++++++++++++++++++++++++++++--- src/clayterm.h | 3 +- term-native.ts | 9 ++- term.ts | 11 ++- test/term.test.ts | 114 +++++++++++++++++++++++++++++- test/validate.test.ts | 24 ++++++- validate.ts | 17 +++++ 9 files changed, 413 insertions(+), 16 deletions(-) diff --git a/clay b/clay index 76ec363..cfee7e8 160000 --- a/clay +++ b/clay @@ -1 +1 @@ -Subproject commit 76ec3632d80c145158136fd44db501448e7b17c4 +Subproject commit cfee7e8376ae968ba97ea880d56c33b96493dffc diff --git a/ops.ts b/ops.ts index 3114f03..8d8f995 100644 --- a/ops.ts +++ b/ops.ts @@ -10,6 +10,7 @@ const PROP_CORNER_RADIUS = 0x04; const PROP_BORDER = 0x08; const PROP_CLIP = 0x10; const PROP_FLOATING = 0x20; +const PROP_TRANSITION = 0x40; /* ── Packing ──────────────────────────────────────────────────────── */ @@ -93,6 +94,7 @@ export function pack( if (op.border) mask |= PROP_BORDER; if (op.clip) mask |= PROP_CLIP; if (op.floating) mask |= PROP_FLOATING; + if (op.transition) mask |= PROP_TRANSITION; view.setUint32(o, mask, true); o += 4; @@ -191,6 +193,31 @@ export function pack( ); o += 4; } + + if (op.transition) { + let t = op.transition; + view.setFloat32(o, t.duration ?? 0.25, true); + o += 4; + view.setUint32(o, t.properties ?? 0, true); + o += 4; + view.setUint32( + o, + (t.handler ?? 0) | + ((t.interactionHandling ?? 0) << 8) | + ((t.enter?.preset ?? 0) << 16) | + ((t.enter?.trigger ?? 0) << 24), + true, + ); + o += 4; + view.setUint32( + o, + (t.exit?.preset ?? 0) | + ((t.exit?.trigger ?? 0) << 8) | + ((t.exit?.siblingOrdering ?? 0) << 16), + true, + ); + o += 4; + } break; } @@ -300,6 +327,21 @@ export interface OpenElement { clipTo?: number; zIndex?: number; }; + transition?: { + duration?: number; + properties?: number; + handler?: number; + interactionHandling?: number; + enter?: { + preset?: number; + trigger?: number; + }; + exit?: { + preset?: number; + trigger?: number; + siblingOrdering?: number; + }; + }; } export const ATTACH_POINT = { @@ -331,6 +373,57 @@ export const CLIP_TO = { ATTACHED_PARENT: 1, } as const; +export const TRANSITION_HANDLER = { + NONE: 0, + EASE_OUT: 1, +} as const; + +export const TRANSITION_PROPERTY = { + NONE: 0, + X: 1, + Y: 2, + POSITION: 3, + WIDTH: 4, + HEIGHT: 8, + DIMENSIONS: 12, + BOUNDING_BOX: 15, + BACKGROUND_COLOR: 16, + OVERLAY_COLOR: 32, + CORNER_RADIUS: 64, + BORDER_COLOR: 128, + BORDER_WIDTH: 256, + BORDER: 384, +} as const; + +export const TRANSITION_INTERACTION_HANDLING = { + DISABLE_WHILE_POSITIONING: 0, + ALLOW_WHILE_POSITIONING: 1, +} as const; + +export const TRANSITION_ENTER_TRIGGER = { + SKIP_ON_FIRST_PARENT_FRAME: 0, + TRIGGER_ON_FIRST_PARENT_FRAME: 1, +} as const; + +export const TRANSITION_EXIT_TRIGGER = { + SKIP_WHEN_PARENT_EXITS: 0, + TRIGGER_WHEN_PARENT_EXITS: 1, +} as const; + +export const EXIT_TRANSITION_SIBLING_ORDERING = { + UNDERNEATH_SIBLINGS: 0, + NATURAL_ORDER: 1, + ABOVE_SIBLINGS: 2, +} as const; + +export const TRANSITION_PRESET = { + NONE: 0, + ENTER_FROM_LEFT: 1, + ENTER_FROM_RIGHT: 2, + EXIT_TO_LEFT: 3, + EXIT_TO_RIGHT: 4, +} as const; + export interface Text { id: typeof OP_TEXT; content: string; diff --git a/src/clayterm.c b/src/clayterm.c index 9156464..b380a2d 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -31,6 +31,16 @@ #define PROP_BORDER 0x08 #define PROP_CLIP 0x10 #define PROP_FLOATING 0x20 +#define PROP_TRANSITION 0x40 + +#define TRANSITION_HANDLER_NONE 0 +#define TRANSITION_HANDLER_EASE_OUT 1 + +#define TRANSITION_PRESET_NONE 0 +#define TRANSITION_PRESET_ENTER_FROM_LEFT 1 +#define TRANSITION_PRESET_ENTER_FROM_RIGHT 2 +#define TRANSITION_PRESET_EXIT_TO_LEFT 3 +#define TRANSITION_PRESET_EXIT_TO_RIGHT 4 /* ── Instance state ───────────────────────────────────────────────── */ @@ -44,6 +54,8 @@ struct Clayterm { /* clip region */ int clipx, clipy, clipw, cliph; int clipping; + Clay_Color overlay; + int overlay_active; }; /* Memory layout inside the arena provided by the host: @@ -200,11 +212,91 @@ static uint32_t color(Clay_Color c) { return ((uint32_t)r << 16) | ((uint32_t)g << 8) | (uint32_t)b; } +static Clay_Color blend(Clay_Color base, Clay_Color overlay) { + float alpha = overlay.a / 255.0f; + if (alpha <= 0) + return base; + return (Clay_Color){ + .r = base.r + (overlay.r - base.r) * alpha, + .g = base.g + (overlay.g - base.g) * alpha, + .b = base.b + (overlay.b - base.b) * alpha, + .a = 255, + }; +} + +static Clay_TransitionData transition_preset(Clay_TransitionData state, + Clay_TransitionProperty properties, + uint8_t preset, float distance) { + if (properties & CLAY_TRANSITION_PROPERTY_X) { + if (preset == TRANSITION_PRESET_ENTER_FROM_LEFT || + preset == TRANSITION_PRESET_EXIT_TO_LEFT) { + state.boundingBox.x -= distance; + } else if (preset == TRANSITION_PRESET_ENTER_FROM_RIGHT || + preset == TRANSITION_PRESET_EXIT_TO_RIGHT) { + state.boundingBox.x += distance; + } + } + return state; +} + +static Clay_TransitionData transition_enter_from_left( + Clay_TransitionData target, Clay_TransitionProperty properties) { + return transition_preset(target, properties, TRANSITION_PRESET_ENTER_FROM_LEFT, + 24.0f); +} + +static Clay_TransitionData transition_enter_from_right( + Clay_TransitionData target, Clay_TransitionProperty properties) { + return transition_preset(target, properties, + TRANSITION_PRESET_ENTER_FROM_RIGHT, 24.0f); +} + +static Clay_TransitionData transition_exit_to_left( + Clay_TransitionData initial, Clay_TransitionProperty properties) { + return transition_preset(initial, properties, TRANSITION_PRESET_EXIT_TO_LEFT, + 24.0f); +} + +static Clay_TransitionData transition_exit_to_right( + Clay_TransitionData initial, Clay_TransitionProperty properties) { + return transition_preset(initial, properties, + TRANSITION_PRESET_EXIT_TO_RIGHT, 24.0f); +} + +static bool (*decode_transition_handler(uint8_t value))( + Clay_TransitionCallbackArguments) { + switch (value) { + case TRANSITION_HANDLER_EASE_OUT: + return Clay_EaseOut; + default: + return 0; + } +} + +static Clay_TransitionData (*decode_transition_preset(uint8_t value))( + Clay_TransitionData, Clay_TransitionProperty) { + switch (value) { + case TRANSITION_PRESET_ENTER_FROM_LEFT: + return transition_enter_from_left; + case TRANSITION_PRESET_ENTER_FROM_RIGHT: + return transition_enter_from_right; + case TRANSITION_PRESET_EXIT_TO_LEFT: + return transition_exit_to_left; + case TRANSITION_PRESET_EXIT_TO_RIGHT: + return transition_exit_to_right; + default: + return 0; + } +} + /* ── Clay render backend ──────────────────────────────────────────── */ static void render_rect(struct Clayterm *ct, int x0, int y0, int x1, int y1, Clay_RectangleRenderData *r) { - uint32_t bg = color(r->backgroundColor); + Clay_Color fill = r->backgroundColor; + if (ct->overlay_active) + fill = blend(fill, ct->overlay); + uint32_t bg = color(fill); for (int y = y0; y < y1; y++) for (int x = x0; x < x1; x++) setcell(ct, x, y, ' ', ATTR_DEFAULT, bg); @@ -212,7 +304,10 @@ static void render_rect(struct Clayterm *ct, int x0, int y0, int x1, int y1, static void render_text(struct Clayterm *ct, int x0, int y0, Clay_TextRenderData *t) { - uint32_t fg = color(t->textColor); + Clay_Color textColor = t->textColor; + if (ct->overlay_active) + textColor = blend(textColor, ct->overlay); + uint32_t fg = color(textColor); /* text attrs are packed into the alpha channel by reduce() */ uint32_t attrs = ((uint32_t)(uint8_t)t->textColor.a) << 24; @@ -243,7 +338,10 @@ static void render_text(struct Clayterm *ct, int x0, int y0, static void render_border(struct Clayterm *ct, int x0, int y0, int x1, int y1, Clay_BorderRenderData *b) { - uint32_t fg = color(b->color); + Clay_Color borderColor = b->color; + if (ct->overlay_active) + borderColor = blend(borderColor, ct->overlay); + uint32_t fg = color(borderColor); uint32_t bg = ATTR_DEFAULT; int top = b->width.top > 0; int bot = b->width.bottom > 0; @@ -384,9 +482,8 @@ struct Clayterm *init(void *mem, int w, int h, int row) { return ct; } -void reduce(struct Clayterm *ct, uint32_t *buf, int len) { +void reduce(struct Clayterm *ct, uint32_t *buf, int len, float dt) { int i = 0; - uint32_t idx = 0; Clay_BeginLayout(); @@ -403,7 +500,7 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len) { if (id_len > 0) { Clay_String str = {.length = (int32_t)id_len, .chars = id_chars}; - Clay_ElementId eid = Clay__HashString(str, idx++); + Clay_ElementId eid = Clay__HashString(str, 0); Clay__OpenElementWithId(eid); } else { Clay__OpenElement(); @@ -480,6 +577,24 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len) { decl.floating.zIndex = (int16_t)(fd >> 8); } + if (mask & PROP_TRANSITION) { + decl.transition.duration = rdf(buf, len, &i); + decl.transition.properties = rd(buf, len, &i); + + uint32_t tc = rd(buf, len, &i); + decl.transition.handler = decode_transition_handler(tc & 0xff); + decl.transition.interactionHandling = (tc >> 8) & 0xff; + decl.transition.enter.setInitialState = + decode_transition_preset((tc >> 16) & 0xff); + decl.transition.enter.trigger = (tc >> 24) & 0xff; + + uint32_t td = rd(buf, len, &i); + decl.transition.exit.setFinalState = + decode_transition_preset(td & 0xff); + decl.transition.exit.trigger = (td >> 8) & 0xff; + decl.transition.exit.siblingOrdering = (td >> 16) & 0xff; + } + Clay__ConfigureOpenElement(decl); break; } @@ -502,7 +617,7 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len) { /* attrs byte -> alpha channel for render_text to extract */ config.textColor.a = (float)((cfg >> 24) & 0xff); - Clay__OpenTextElement(text, Clay__StoreTextElementConfig(config)); + Clay__OpenTextElement(text, config); break; } @@ -515,12 +630,14 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len) { } } - Clay_RenderCommandArray cmds = Clay_EndLayout(); + Clay_RenderCommandArray cmds = Clay_EndLayout(dt); /* reset output state */ ct->out.length = 0; ct->lastfg = ct->lastbg = 0xffffffff; ct->lastx = ct->lasty = -1; + ct->overlay_active = 0; + ct->overlay = (Clay_Color){0, 0, 0, 0}; cells_clear(ct->back, ct->w, ct->h); @@ -553,6 +670,14 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len) { case CLAY_RENDER_COMMAND_TYPE_SCISSOR_END: ct->clipping = 0; break; + case CLAY_RENDER_COMMAND_TYPE_OVERLAY_COLOR_START: + ct->overlay_active = 1; + ct->overlay = cmd->renderData.overlayColor.color; + break; + case CLAY_RENDER_COMMAND_TYPE_OVERLAY_COLOR_END: + ct->overlay_active = 0; + ct->overlay = (Clay_Color){0, 0, 0, 0}; + break; default: break; } @@ -582,6 +707,21 @@ int pointer_over_id_string_ptr(int index) { return (int)ids.internalArray[index].stringId.chars; } +int has_active_transitions(void) { + Clay_Context *context = Clay_GetCurrentContext(); + if (!context) + return 0; + for (int i = 0; i < context->transitionDatas.length; ++i) { + Clay__TransitionDataInternal *data = + Clay__TransitionDataInternalArray_Get(&context->transitionDatas, i); + if (data->state != CLAY_TRANSITION_STATE_IDLE || + data->activeProperties != CLAY_TRANSITION_PROPERTY_NONE) { + return 1; + } + } + return 0; +} + void measure(int ret, int txt) { /* Read Clay_StringSlice from txt address. * Clay_StringSlice layout: { int32_t length, const char *chars, ... } diff --git a/src/clayterm.h b/src/clayterm.h index f6bcbd7..d4ac039 100644 --- a/src/clayterm.h +++ b/src/clayterm.h @@ -12,7 +12,7 @@ struct Clayterm; /* WASM exports */ int clayterm_size(int w, int h); struct Clayterm *init(void *mem, int w, int h, int row); -void reduce(struct Clayterm *ct, uint32_t *buf, int len); +void reduce(struct Clayterm *ct, uint32_t *buf, int len, float dt); char *output(struct Clayterm *ct); int length(struct Clayterm *ct); void measure(int ret, int txt); @@ -20,5 +20,6 @@ void measure(int ret, int txt); int pointer_over_count(void); int pointer_over_id_string_length(int index); int pointer_over_id_string_ptr(int index); +int has_active_transitions(void); #endif diff --git a/term-native.ts b/term-native.ts index d17834a..c4a13b7 100644 --- a/term-native.ts +++ b/term-native.ts @@ -2,9 +2,10 @@ export interface Native { memory: WebAssembly.Memory; statePtr: number; opsBuf: number; - reduce(ct: number, buf: number, len: number): void; + reduce(ct: number, buf: number, len: number, dt: number): void; output(ct: number): number; length(ct: number): number; + hasActiveTransitions(): boolean; setPointer(x: number, y: number, down: boolean): void; getPointerOverIds(): string[]; } @@ -48,9 +49,10 @@ export async function createTermNative( __heap_base: WebAssembly.Global; clayterm_size(w: number, h: number): number; init(mem: number, w: number, h: number, row: number): number; - reduce(ct: number, buf: number, len: number): void; + reduce(ct: number, buf: number, len: number, dt: number): void; output(ct: number): number; length(ct: number): number; + has_active_transitions(): number; Clay_SetPointerState(vec: number, down: number): void; pointer_over_count(): number; pointer_over_id_string_length(index: number): number; @@ -78,6 +80,9 @@ export async function createTermNative( reduce: ct.reduce, output: ct.output, length: ct.length, + hasActiveTransitions() { + return ct.has_active_transitions() !== 0; + }, setPointer(x: number, y: number, down: boolean) { let view = new DataView(memory.buffer); view.setFloat32(opsBuf, x, true); diff --git a/term.ts b/term.ts index 8f83c65..9f9ceba 100644 --- a/term.ts +++ b/term.ts @@ -13,6 +13,7 @@ export interface RenderOptions { y: number; down: boolean; }; + deltaTime?: number; } export type PointerEvent = @@ -23,6 +24,7 @@ export type PointerEvent = export interface RenderResult { output: Uint8Array; events: PointerEvent[]; + hasActiveTransitions: boolean; } export interface Term { @@ -37,11 +39,15 @@ export async function createTerm(options: TermOptions): Promise { let prev = new Set(); let pressed = new Set(); let wasDown = false; + let lastRenderTime = performance.now(); return { render(ops: Op[], options?: RenderOptions): RenderResult { let len = pack(ops, memory.buffer, opsBuf, memory.buffer.byteLength); - native.reduce(statePtr, opsBuf, len); + let now = performance.now(); + let dt = options?.deltaTime ?? Math.min((now - lastRenderTime) / 1000, 0.25); + lastRenderTime = now; + native.reduce(statePtr, opsBuf, len, dt); if (options?.pointer) { let { x, y, down } = options.pointer; @@ -57,6 +63,7 @@ export async function createTerm(options: TermOptions): Promise { let current = new Set( options?.pointer ? native.getPointerOverIds() : [], ); + let hasActiveTransitions = native.hasActiveTransitions(); let down = options?.pointer?.down ?? false; let events: PointerEvent[] = []; @@ -89,7 +96,7 @@ export async function createTerm(options: TermOptions): Promise { prev = current; wasDown = down; - return { output, events }; + return { output, events, hasActiveTransitions }; }, }; } diff --git a/test/term.test.ts b/test/term.test.ts index 5c2b650..c844b4a 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -1,6 +1,22 @@ import { beforeEach, describe, expect, it } from "./suite.ts"; import { createTerm, type Term } from "../term.ts"; -import { ATTACH_POINT, ATTACH_TO, close, fixed, grow, open, rgba, text } from "../ops.ts"; +import { + ATTACH_POINT, + ATTACH_TO, + EXIT_TRANSITION_SIBLING_ORDERING, + TRANSITION_ENTER_TRIGGER, + TRANSITION_EXIT_TRIGGER, + TRANSITION_HANDLER, + TRANSITION_INTERACTION_HANDLING, + TRANSITION_PRESET, + TRANSITION_PROPERTY, + close, + fixed, + grow, + open, + rgba, + text, +} from "../ops.ts"; import { print } from "./print.ts"; const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes); @@ -168,6 +184,102 @@ describe("term", () => { expect(out.split("\n")[3]).toContain("┌──────────┐"); }); + it("accepts transition-configured elements across frames", () => { + let ops = [ + open("root", { + layout: { width: fixed(40), height: fixed(10), direction: "ttb" }, + }), + open("box", { + layout: { + width: fixed(12), + height: fixed(5), + direction: "ttb", + padding: { left: 1, top: 1 }, + }, + border: { + color: rgba(255, 255, 255), + left: 1, + right: 1, + top: 1, + bottom: 1, + }, + transition: { + duration: 0.25, + handler: TRANSITION_HANDLER.EASE_OUT, + properties: TRANSITION_PROPERTY.X, + interactionHandling: + TRANSITION_INTERACTION_HANDLING.DISABLE_WHILE_POSITIONING, + enter: { + preset: TRANSITION_PRESET.ENTER_FROM_LEFT, + trigger: TRANSITION_ENTER_TRIGGER.TRIGGER_ON_FIRST_PARENT_FRAME, + }, + exit: { + preset: TRANSITION_PRESET.EXIT_TO_RIGHT, + trigger: TRANSITION_EXIT_TRIGGER.TRIGGER_WHEN_PARENT_EXITS, + siblingOrdering: + EXIT_TRANSITION_SIBLING_ORDERING.NATURAL_ORDER, + }, + }, + }), + text("transition"), + close(), + close(), + ]; + + expect(() => { + term.render(ops, { deltaTime: 0 }); + term.render(ops, { deltaTime: 0.016 }); + }).not.toThrow(); + }); + + it("reports active transitions while a transition is running", () => { + let enterOps = [ + open("root", { + layout: { width: fixed(40), height: fixed(10), direction: "ttb" }, + }), + close(), + ]; + + let transitionOps = [ + open("root", { + layout: { width: fixed(40), height: fixed(10), direction: "ttb" }, + }), + open("box", { + layout: { + width: fixed(12), + height: fixed(5), + direction: "ttb", + padding: { left: 1, top: 1 }, + }, + border: { + color: rgba(255, 255, 255), + left: 1, + right: 1, + top: 1, + bottom: 1, + }, + transition: { + duration: 0.25, + handler: TRANSITION_HANDLER.EASE_OUT, + properties: TRANSITION_PROPERTY.X, + interactionHandling: + TRANSITION_INTERACTION_HANDLING.DISABLE_WHILE_POSITIONING, + enter: { + preset: TRANSITION_PRESET.ENTER_FROM_LEFT, + trigger: TRANSITION_ENTER_TRIGGER.TRIGGER_ON_FIRST_PARENT_FRAME, + }, + }, + }), + text("box"), + close(), + close(), + ]; + + term.render(enterOps, { deltaTime: 0 }); + let result = term.render(transitionOps, { deltaTime: 0.016 }); + expect(result.hasActiveTransitions).toBe(true); + }); + describe("row offset", () => { it("renders two frames at the offset position", async () => { let term = await createTerm({ width: 20, height: 5, top: 5 }); diff --git a/test/validate.test.ts b/test/validate.test.ts index cb98cf2..ed9caa9 100644 --- a/test/validate.test.ts +++ b/test/validate.test.ts @@ -1,6 +1,14 @@ import { beforeEach, describe, expect, it } from "./suite.ts"; import { createTerm, type Term } from "../term.ts"; -import { close, grow, open, text } from "../ops.ts"; +import { + close, + grow, + open, + text, + TRANSITION_HANDLER, + TRANSITION_PRESET, + TRANSITION_PROPERTY, +} from "../ops.ts"; import { assert, validate, validated } from "../validate.ts"; import { print } from "./print.ts"; @@ -78,6 +86,20 @@ describe("validate", () => { it("rejects fractional color", () => { expect(validate([text("hi", { color: 1.5 })])).toBe(false); }); + + it("accepts transition configs", () => { + expect(validate([ + open("x", { + transition: { + duration: 0.3, + handler: TRANSITION_HANDLER.EASE_OUT, + properties: TRANSITION_PROPERTY.X, + enter: { preset: TRANSITION_PRESET.ENTER_FROM_LEFT }, + }, + }), + close(), + ])).toBe(true); + }); }); describe("validated", () => { diff --git a/validate.ts b/validate.ts index 8ccb016..b9424af 100644 --- a/validate.ts +++ b/validate.ts @@ -98,6 +98,22 @@ const Floating = Type.Object({ zIndex: Type.Optional(u16), }); +const Transition = Type.Object({ + duration: Type.Optional(Type.Number()), + properties: Type.Optional(u32), + handler: Type.Optional(u8), + interactionHandling: Type.Optional(u8), + enter: Type.Optional(Type.Object({ + preset: Type.Optional(u8), + trigger: Type.Optional(u8), + })), + exit: Type.Optional(Type.Object({ + preset: Type.Optional(u8), + trigger: Type.Optional(u8), + siblingOrdering: Type.Optional(u8), + })), +}); + /* ── Op types (discriminated on `id`) ─────────────────────────────── */ const CloseElement = Type.Object({ id: Type.Literal(0x04) }); @@ -111,6 +127,7 @@ const OpenElement = Type.Object({ border: Type.Optional(Border), clip: Type.Optional(Clip), floating: Type.Optional(Floating), + transition: Type.Optional(Transition), }); const TextOp = Type.Object({ From 04a892faf97886ab34adac564350f6514211c24d Mon Sep 17 00:00:00 2001 From: Ryan Rauh Date: Sat, 11 Apr 2026 08:41:24 -0400 Subject: [PATCH 4/4] feat: add getElementBounds API and text measurement Add element_bounds() WASM export to query Clay layout bounding boxes by element ID, enabling virtual-scroll viewports to measure visible regions. Add measure.ts with pure-JS text measurement: measureCellWidth (Unicode- aware cell width), wrapText (word/newline/none modes), and measureWrappedHeight for pre-layout height estimation. Export both from mod.ts and add geometry + build-artifact test coverage. --- measure.ts | 155 +++++++++++++++++++++++++++++++ mod.ts | 1 + src/clayterm.c | 11 +++ src/clayterm.h | 1 + term-native.ts | 25 +++++ term.ts | 8 +- test/build-artifacts.bun.test.ts | 42 +++++++++ test/geometry.bun.test.ts | 62 +++++++++++++ validate.ts | 3 + 9 files changed, 307 insertions(+), 1 deletion(-) create mode 100644 measure.ts create mode 100644 test/build-artifacts.bun.test.ts create mode 100644 test/geometry.bun.test.ts diff --git a/measure.ts b/measure.ts new file mode 100644 index 0000000..079883b --- /dev/null +++ b/measure.ts @@ -0,0 +1,155 @@ +const COMBINING_MARK_RE = /\p{Mark}/u; +const EXTENDED_PICTOGRAPHIC_RE = /\p{Extended_Pictographic}/u; + +export type WrapTextMode = "words" | "newlines" | "none"; + +export interface WrapTextOptions { + mode?: WrapTextMode; +} + +export interface WrappedLine { + text: string; + width: number; +} + +export interface TextMeasureApi { + measureCellWidth(text: string): number; + wrapText(text: string, width: number, options?: WrapTextOptions): WrappedLine[]; + measureWrappedHeight(text: string, width: number, options?: WrapTextOptions): number; +} + +function assertWidth(width: number): void { + if (!Number.isFinite(width) || width < 0) { + throw new RangeError(`width must be a finite, non-negative number; received ${width}`); + } +} + +export function measureCellWidth(text: string): number { + let width = 0; + for (const symbol of text) { + width += codePointWidth(symbol); + } + return width; +} + +export function wrapText( + text: string, + width: number, + options: WrapTextOptions = {}, +): WrappedLine[] { + assertWidth(width); + if (text.length === 0 || width === 0) return []; + + const mode = options.mode ?? "words"; + + switch (mode) { + case "newlines": + return text.split("\n").map((line) => ({ text: line, width: measureCellWidth(line) })); + case "none": { + const collapsed = text.replace(/\n/g, ""); + return collapsed.length === 0 + ? [] + : [{ text: collapsed, width: measureCellWidth(collapsed) }]; + } + case "words": + return wrapWords(text, width); + default: + return wrapWords(text, width); + } +} + +export function measureWrappedHeight( + text: string, + width: number, + options: WrapTextOptions = {}, +): number { + assertWidth(width); + if (text.length === 0 || width === 0) return 0; + return wrapText(text, width, options).length; +} + +function wrapWords(text: string, width: number): WrappedLine[] { + const paragraphs = text.split("\n"); + const lines: WrappedLine[] = []; + + for (const paragraph of paragraphs) { + if (paragraph.length === 0) { + lines.push({ text: "", width: 0 }); + continue; + } + + const tokens = paragraph.match(/\S+|\s+/g) ?? [paragraph]; + let currentText = ""; + let currentWidth = 0; + + for (const token of tokens) { + const tokenWidth = measureCellWidth(token); + if (currentText.length === 0) { + currentText = token; + currentWidth = tokenWidth; + continue; + } + + if (currentWidth + tokenWidth <= width) { + currentText += token; + currentWidth += tokenWidth; + continue; + } + + lines.push({ text: currentText, width: currentWidth }); + currentText = token; + currentWidth = tokenWidth; + } + + if (currentText.length > 0) { + lines.push({ text: currentText, width: currentWidth }); + } + } + + return lines; +} + +function codePointWidth(symbol: string): number { + const codePoint = symbol.codePointAt(0); + if (codePoint === undefined) return 0; + + if (isControl(codePoint)) return 0; + if (isZeroWidth(codePoint, symbol)) return 0; + if (isWide(codePoint, symbol)) return 2; + return 1; +} + +function isControl(codePoint: number): boolean { + return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint < 0xa0); +} + +function isZeroWidth(codePoint: number, symbol: string): boolean { + return ( + codePoint === 0x200d || + (codePoint >= 0xfe00 && codePoint <= 0xfe0f) || + (codePoint >= 0xe0100 && codePoint <= 0xe01ef) || + (codePoint >= 0x1f3fb && codePoint <= 0x1f3ff) || + COMBINING_MARK_RE.test(symbol) + ); +} + +function isWide(codePoint: number, symbol: string): boolean { + return ( + EXTENDED_PICTOGRAPHIC_RE.test(symbol) || + codePoint >= 0x1100 && ( + codePoint <= 0x115f || + codePoint === 0x2329 || + codePoint === 0x232a || + (codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f) || + (codePoint >= 0xac00 && codePoint <= 0xd7a3) || + (codePoint >= 0xf900 && codePoint <= 0xfaff) || + (codePoint >= 0xfe10 && codePoint <= 0xfe19) || + (codePoint >= 0xfe30 && codePoint <= 0xfe6f) || + (codePoint >= 0xff00 && codePoint <= 0xff60) || + (codePoint >= 0xffe0 && codePoint <= 0xffe6) || + (codePoint >= 0x1f300 && codePoint <= 0x1f64f) || + (codePoint >= 0x1f900 && codePoint <= 0x1f9ff) || + (codePoint >= 0x20000 && codePoint <= 0x3fffd) + ) + ); +} diff --git a/mod.ts b/mod.ts index 8841400..888784b 100644 --- a/mod.ts +++ b/mod.ts @@ -1,3 +1,4 @@ export * from "./ops.ts"; export * from "./term.ts"; export * from "./input.ts"; +export * from "./measure.ts"; diff --git a/src/clayterm.c b/src/clayterm.c index b380a2d..4940791 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -722,6 +722,17 @@ int has_active_transitions(void) { return 0; } +int element_bounds(int ret, int chars, int len) { + Clay_String id = {.length = len, .chars = (const char *)chars}; + Clay_ElementData data = Clay_GetElementData(Clay_GetElementId(id)); + float *dims = (float *)ret; + dims[0] = data.boundingBox.x; + dims[1] = data.boundingBox.y; + dims[2] = data.boundingBox.width; + dims[3] = data.boundingBox.height; + return data.found ? 1 : 0; +} + void measure(int ret, int txt) { /* Read Clay_StringSlice from txt address. * Clay_StringSlice layout: { int32_t length, const char *chars, ... } diff --git a/src/clayterm.h b/src/clayterm.h index d4ac039..991c202 100644 --- a/src/clayterm.h +++ b/src/clayterm.h @@ -21,5 +21,6 @@ int pointer_over_count(void); int pointer_over_id_string_length(int index); int pointer_over_id_string_ptr(int index); int has_active_transitions(void); +int element_bounds(int ret, int chars, int len); #endif diff --git a/term-native.ts b/term-native.ts index c4a13b7..ffa1a72 100644 --- a/term-native.ts +++ b/term-native.ts @@ -1,3 +1,10 @@ +export interface ElementBounds { + x: number; + y: number; + width: number; + height: number; +} + export interface Native { memory: WebAssembly.Memory; statePtr: number; @@ -8,6 +15,7 @@ export interface Native { hasActiveTransitions(): boolean; setPointer(x: number, y: number, down: boolean): void; getPointerOverIds(): string[]; + getElementBounds(id: string): ElementBounds | undefined; } import { compiled } from "./wasm.ts"; @@ -57,6 +65,7 @@ export async function createTermNative( pointer_over_count(): number; pointer_over_id_string_length(index: number): number; pointer_over_id_string_ptr(index: number): number; + element_bounds(ret: number, chars: number, len: number): number; }; let heap = ct.__heap_base.value as number; @@ -101,5 +110,21 @@ export async function createTermNative( } return ids; }, + getElementBounds(id: string): ElementBounds | undefined { + let encoded = new TextEncoder().encode(id); + let ret = opsBuf; + let ptr = opsBuf + 16; + new Uint8Array(memory.buffer).set(encoded, ptr); + if (ct.element_bounds(ret, ptr, encoded.length) === 0) { + return undefined; + } + let view = new DataView(memory.buffer); + return { + x: view.getFloat32(ret, true), + y: view.getFloat32(ret + 4, true), + width: view.getFloat32(ret + 8, true), + height: view.getFloat32(ret + 12, true), + }; + }, }; } diff --git a/term.ts b/term.ts index 9f9ceba..70ce375 100644 --- a/term.ts +++ b/term.ts @@ -1,5 +1,7 @@ import { type Op, pack } from "./ops.ts"; -import { createTermNative } from "./term-native.ts"; +import { createTermNative, type ElementBounds } from "./term-native.ts"; + +export type { ElementBounds } from "./term-native.ts"; export interface TermOptions { height: number; @@ -29,6 +31,7 @@ export interface RenderResult { export interface Term { render(ops: Op[], options?: RenderOptions): RenderResult; + getElementBounds(id: string): ElementBounds | undefined; } export async function createTerm(options: TermOptions): Promise { @@ -98,5 +101,8 @@ export async function createTerm(options: TermOptions): Promise { return { output, events, hasActiveTransitions }; }, + getElementBounds(id: string): ElementBounds | undefined { + return native.getElementBounds(id); + }, }; } diff --git a/test/build-artifacts.bun.test.ts b/test/build-artifacts.bun.test.ts new file mode 100644 index 0000000..2327281 --- /dev/null +++ b/test/build-artifacts.bun.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "bun:test"; +import { + close, + createTerm, + fixed, + grow, + measureCellWidth, + measureWrappedHeight, + open, + text, + wrapText, +} from "../build/npm/esm/mod.js"; + +describe("built clayterm artifacts", () => { + test("rebuilt npm bundle exports measurement helpers", () => { + expect(measureCellWidth("abc")).toBe(3); + expect(measureCellWidth("e\u0301")).toBe(1); + + const wrapped = wrapText("hello world", 5); + expect(wrapped.length).toBe(measureWrappedHeight("hello world", 5)); + expect(wrapped[0]?.text.length).toBeGreaterThan(0); + }); + + test("rebuilt npm bundle exposes term geometry queries backed by wasm", async () => { + const term = await createTerm({ width: 20, height: 8 }); + + term.render([ + open("root", { layout: { width: grow(), height: grow(), direction: "ttb" } }), + open("viewport", { layout: { width: fixed(10), height: fixed(3) } }), + text("Body"), + close(), + close(), + ]); + + expect(term.getElementBounds("viewport")).toEqual({ + x: 0, + y: 0, + width: 10, + height: 3, + }); + }); +}); diff --git a/test/geometry.bun.test.ts b/test/geometry.bun.test.ts new file mode 100644 index 0000000..49207e8 --- /dev/null +++ b/test/geometry.bun.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test"; +import { createTerm } from "../term.ts"; +import { close, fixed, grow, open, text } from "../ops.ts"; + +describe("term geometry", () => { + test("getElementBounds returns bounds for rendered elements", async () => { + const term = await createTerm({ width: 20, height: 8 }); + + term.render([ + open("root", { layout: { width: grow(), height: grow(), direction: "ttb" } }), + open("header", { layout: { width: grow(), height: fixed(1) } }), + text("Header"), + close(), + open("viewport", { layout: { width: fixed(10), height: fixed(3) } }), + text("Body"), + close(), + close(), + ]); + + expect(term.getElementBounds("header")).toEqual({ + x: 0, + y: 0, + width: 20, + height: 1, + }); + expect(term.getElementBounds("viewport")).toEqual({ + x: 0, + y: 1, + width: 10, + height: 3, + }); + }); + + test("getElementBounds returns undefined for unknown ids and before first render", async () => { + const term = await createTerm({ width: 20, height: 8 }); + + expect(term.getElementBounds("missing")).toBeUndefined(); + + term.render([ + open("root", { layout: { width: grow(), height: grow(), direction: "ttb" } }), + close(), + ]); + + expect(term.getElementBounds("missing")).toBeUndefined(); + }); + + test("getElementBounds updates after later renders", async () => { + const term = await createTerm({ width: 20, height: 8 }); + + term.render([ + open("box", { layout: { width: fixed(5), height: fixed(2) } }), + close(), + ]); + expect(term.getElementBounds("box")).toEqual({ x: 0, y: 0, width: 5, height: 2 }); + + term.render([ + open("box", { layout: { width: fixed(7), height: fixed(4) } }), + close(), + ]); + expect(term.getElementBounds("box")).toEqual({ x: 0, y: 0, width: 7, height: 4 }); + }); +}); diff --git a/validate.ts b/validate.ts index b9424af..e2b2fac 100644 --- a/validate.ts +++ b/validate.ts @@ -169,5 +169,8 @@ export function validated(term: Term): Term { assert(ops); return term.render(ops, options); }, + getElementBounds(id: string) { + return term.getElementBounds(id); + }, }; }