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/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/ops.ts b/ops.ts index 8f952af..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; @@ -157,6 +159,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) { @@ -165,12 +171,49 @@ 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; + } + + 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; @@ -268,17 +311,119 @@ 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; + expand?: { width?: number; height?: number }; parent?: number; attachTo?: number; - attachPoints?: number; + attachPoints?: { element?: number; parent?: number }; + pointerCaptureMode?: number; + 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 = { + 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 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 6fd50c0..4940791 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(); @@ -458,18 +555,44 @@ 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) { 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); + } + + 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); @@ -494,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; } @@ -507,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); @@ -545,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; } @@ -574,6 +707,32 @@ 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; +} + +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 f6bcbd7..991c202 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,7 @@ 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); +int element_bounds(int ret, int chars, int len); #endif diff --git a/term-native.ts b/term-native.ts index d17834a..ffa1a72 100644 --- a/term-native.ts +++ b/term-native.ts @@ -1,12 +1,21 @@ +export interface ElementBounds { + x: number; + y: number; + width: number; + height: number; +} + 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[]; + getElementBounds(id: string): ElementBounds | undefined; } import { compiled } from "./wasm.ts"; @@ -48,13 +57,15 @@ 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; 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; @@ -78,6 +89,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); @@ -96,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 8f83c65..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; @@ -13,6 +15,7 @@ export interface RenderOptions { y: number; down: boolean; }; + deltaTime?: number; } export type PointerEvent = @@ -23,10 +26,12 @@ export type PointerEvent = export interface RenderResult { output: Uint8Array; events: PointerEvent[]; + hasActiveTransitions: boolean; } export interface Term { render(ops: Op[], options?: RenderOptions): RenderResult; + getElementBounds(id: string): ElementBounds | undefined; } export async function createTerm(options: TermOptions): Promise { @@ -37,11 +42,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 +66,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 +99,10 @@ export async function createTerm(options: TermOptions): Promise { prev = current; wasDown = down; - return { output, events }; + 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/test/term.test.ts b/test/term.test.ts index c1ac926..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 { close, 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); @@ -89,6 +105,181 @@ 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 "); + }); + + 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("┌──────────┐"); + }); + + 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 d3b7d45..e2b2fac 100644 --- a/validate.ts +++ b/validate.ts @@ -74,17 +74,46 @@ 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({ 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), }); +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) }); @@ -98,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({ @@ -139,5 +169,8 @@ export function validated(term: Term): Term { assert(ops); return term.render(ops, options); }, + getElementBounds(id: string) { + return term.getElementBounds(id); + }, }; }