diff --git a/clay b/clay index 76ec363..938967a 160000 --- a/clay +++ b/clay @@ -1 +1 @@ -Subproject commit 76ec3632d80c145158136fd44db501448e7b17c4 +Subproject commit 938967ac9a62d3115bc25f8e4827cd46567f4bca diff --git a/demo/clay-transitions.ts b/demo/clay-transitions.ts new file mode 100644 index 0000000..77f7ce8 --- /dev/null +++ b/demo/clay-transitions.ts @@ -0,0 +1,430 @@ +/** + * Clay-transitions demo — a port of the raylib-transitions example to clayterm. + * + * A grid of colored boxes that animate position, size, and background color. + * Press 's' to shuffle (animates position), 'c' to recolor (animates bg). + * Hover any box to see a bg-tint transition on mouse over. + * Press 'q' or Ctrl+C to quit. + * + * Omits enter/exit transitions and "Add Box" (v1 constraints). + * Overlay-color field is not yet in the v1 command buffer; hover tint is + * achieved by blending the bg color toward a highlight shade instead. + */ + +import { + createChannel, + each, + ensure, + main, + race, + resource, + sleep, + spawn, + type Stream, + until, +} from "effection"; +import { + close, + createTerm, + fixed, + grow, + type InputEvent, + type Op, + open, + type PointerEvent, + rgba, + text, +} from "../mod.ts"; +import { + alternateBuffer, + cursor, + mouseTracking, + settings, +} from "../settings.ts"; +import { useInput } from "./use-input.ts"; +import { useStdin } from "./use-stdin.ts"; + +const DEFAULT_PALETTE = [ + rgba(225, 138, 50), + rgba(111, 173, 162), + rgba(184, 87, 134), + rgba(87, 134, 184), + rgba(134, 184, 87), + rgba(184, 134, 87), + rgba(87, 184, 134), + rgba(134, 87, 184), + rgba(200, 100, 100), + rgba(100, 200, 100), + rgba(100, 100, 200), + rgba(200, 200, 100), + rgba(200, 100, 200), + rgba(100, 200, 200), + rgba(180, 160, 80), + rgba(80, 160, 180), +]; + +const PINK_PALETTE = DEFAULT_PALETTE.map((c) => { + let r = (c >> 24) & 0xff; + let g = (c >> 16) & 0xff; + let b = (c >> 8) & 0xff; + let a = c & 0xff; + let pr = Math.min(255, r + 80); + let pg = Math.max(0, g - 60); + let pb = Math.max(0, Math.min(255, b + 40)); + return rgba(pr, pg, pb, a); +}); + +// Blend a packed rgba color toward white by ratio [0,1]. +function lighten(color: number, ratio: number): number { + let r = (color >> 24) & 0xff; + let g = (color >> 16) & 0xff; + let b = (color >> 8) & 0xff; + let a = color & 0xff; + return rgba( + Math.round(r + (255 - r) * ratio), + Math.round(g + (255 - g) * ratio), + Math.round(b + (255 - b) * ratio), + a, + ); +} + +// Lighten ratio applied to bg when box is hovered (blends toward white). +const HOVER_LIGHTEN = 0.35; + +const ROOT_BG = rgba(18, 18, 22); +const TOPBAR_BG = rgba(40, 40, 55); +const BTN_DEFAULT = rgba(60, 60, 80); +const BTN_HOVER = rgba(90, 90, 120); +const KEY_COLOR = rgba(255, 220, 120); +const LABEL_COLOR = rgba(200, 200, 220); + +const COLS = 4; + +interface Box { + id: number; + color: number; +} + +interface State { + boxes: Box[]; + palette: "default" | "pink"; + entered: Set; + pointer: { x: number; y: number; down: boolean } | undefined; +} + +function fisherYates(arr: T[]): T[] { + let out = arr.slice(); + for (let i = out.length - 1; i > 0; i--) { + let j = Math.floor(Math.random() * (i + 1)); + let tmp = out[i]; + out[i] = out[j]; + out[j] = tmp; + } + return out; +} + +function recolor(boxes: Box[], palette: "default" | "pink"): Box[] { + let pal = palette === "pink" ? PINK_PALETTE : DEFAULT_PALETTE; + return boxes.map((b, i) => ({ ...b, color: pal[i % pal.length] })); +} + +function button( + id: string, + label: string, + hovered: boolean, + key: string, +): Op[] { + return [ + open(id, { + layout: { + direction: "ltr", + padding: { left: 2, right: 2, top: 0, bottom: 0 }, + alignX: 2, + alignY: 2, + height: grow(), + }, + bg: hovered ? BTN_HOVER : BTN_DEFAULT, + border: hovered + ? { color: KEY_COLOR, left: 1, right: 1, top: 1, bottom: 1 } + : undefined, + }), + text(key, { color: KEY_COLOR }), + text(` ${label}`, { color: LABEL_COLOR }), + close(), + ]; +} + +function view(state: State): Op[] { + let ops: Op[] = []; + + ops.push( + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + bg: ROOT_BG, + }), + ); + + ops.push( + open("topbar", { + layout: { + width: grow(), + height: fixed(3), + direction: "ltr", + padding: { left: 2, right: 2, top: 0, bottom: 0 }, + gap: 2, + alignY: 2, + }, + bg: TOPBAR_BG, + }), + ); + + ops.push( + ...button( + "btn:shuffle", + "shuffle", + state.entered.has("btn:shuffle"), + "s", + ), + ...button( + "btn:recolor", + "recolor", + state.entered.has("btn:recolor"), + "c", + ), + ...button("btn:quit", "quit", state.entered.has("btn:quit"), "q"), + ); + + ops.push(close()); + + ops.push( + open("grid", { + layout: { + width: grow(), + height: grow(), + direction: "ttb", + padding: { left: 1, right: 1, top: 1, bottom: 1 }, + gap: 1, + }, + }), + ); + + let boxes = state.boxes; + let rows = Math.ceil(boxes.length / COLS); + + for (let r = 0; r < rows; r++) { + ops.push( + open(`row:${r}`, { + layout: { + width: grow(), + height: grow(), + direction: "ltr", + gap: 1, + }, + }), + ); + + for (let c = 0; c < COLS; c++) { + let i = r * COLS + c; + if (i >= boxes.length) { + break; + } + let b = boxes[i]; + let bid = `box:${b.id}`; + let hov = state.entered.has(bid); + let borderColor = hov ? lighten(b.color, HOVER_LIGHTEN) : b.color; + ops.push( + open(bid, { + layout: { + width: grow(), + height: grow(), + alignX: 2, + alignY: 2, + }, + border: { + color: borderColor, + left: 1, + right: 1, + top: 1, + bottom: 1, + }, + transition: { + duration: 0.4, + easing: "easeInOut", + properties: ["width", "position", "borderColor"], + interactive: true, + }, + }), + text(`${b.id < 10 ? "0" : ""}${b.id}`, { color: b.color }), + close(), + ); + } + + ops.push(close()); + } + + ops.push(close()); + + ops.push(close()); + + return ops; +} + +function ticker(flag: { animating: boolean }): Stream { + return resource(function* (provide) { + let ch = createChannel(); + yield* spawn(function* () { + while (true) { + if (flag.animating) { + yield* sleep(2); + yield* ch.send(); + } else { + yield* sleep(50); + } + } + }); + let sub = yield* ch; + yield* race([provide(sub), drain(ch)]); + }); +} + +function merge( + a: Stream, + b: Stream, +): Stream { + return resource(function* (provide) { + let sub = { + a: yield* a, + b: yield* b, + }; + return yield* provide({ + *next() { + return yield* race([sub.a.next(), sub.b.next()]); + }, + }); + }); +} + +function* drain(stream: Stream) { + for (let _ of yield* each(stream)) { + yield* each.next(); + } +} + +await main(function* () { + let { columns, rows } = Deno.stdout.isTerminal() + ? Deno.consoleSize() + : { columns: 80, rows: 24 }; + + Deno.stdin.setRaw(true); + yield* ensure(() => Deno.stdin.setRaw(false)); + + let stdin = yield* useStdin(); + let input = useInput(stdin); + + let term = yield* until(createTerm({ width: columns, height: rows })); + + let tty = settings(alternateBuffer(), cursor(false), mouseTracking()); + Deno.stdout.writeSync(tty.apply); + yield* ensure(() => { + Deno.stdout.writeSync(tty.revert); + }); + + let count = 16; + let pal = DEFAULT_PALETTE; + let initialBoxes: Box[] = Array.from({ length: count }, (_, i) => ({ + id: i, + color: pal[i % pal.length], + })); + + let state: State = { + boxes: initialBoxes, + palette: "default", + entered: new Set(), + pointer: undefined, + }; + + let flag = { animating: false }; + + function draw(): void { + let { output, animating, events } = term.render(view(state), { + pointer: state.pointer, + }); + flag.animating = animating; + for (let ev of events) { + if (ev.type === "pointerenter") { + state = { ...state, entered: new Set([...state.entered, ev.id]) }; + } else if (ev.type === "pointerleave") { + let next = new Set(state.entered); + next.delete(ev.id); + state = { ...state, entered: next }; + } + } + Deno.stdout.writeSync(output); + } + + draw(); + + let pointer = createChannel(); + let ticks = ticker(flag); + let events = merge(merge(input, pointer), ticks); + + for (let ev of yield* each(events)) { + if (ev !== undefined && typeof ev === "object" && "type" in ev) { + let e = ev as InputEvent | PointerEvent; + + if (e.type === "keydown") { + if (e.ctrl && e.key === "c") { + break; + } + if (e.key === "q") { + break; + } + if (e.key === "s") { + state = { ...state, boxes: fisherYates(state.boxes) }; + } + if (e.key === "c") { + let next: "default" | "pink" = state.palette === "default" + ? "pink" + : "default"; + state = { + ...state, + palette: next, + boxes: recolor(state.boxes, next), + }; + } + } + + if ("x" in e && "y" in e) { + let me = e as { x: number; y: number; type: string }; + state = { + ...state, + pointer: { + x: me.x, + y: me.y, + down: me.type === "mousedown", + }, + }; + } + } + + let { output, animating, events: pevents } = term.render(view(state), { + pointer: state.pointer, + }); + flag.animating = animating; + + for (let pev of pevents) { + if (pev.type === "pointerenter") { + state = { ...state, entered: new Set([...state.entered, pev.id]) }; + } else if (pev.type === "pointerleave") { + let next = new Set(state.entered); + next.delete(pev.id); + state = { ...state, entered: next }; + } + yield* pointer.send(pev); + } + + Deno.stdout.writeSync(output); + + yield* each.next(); + } +}); diff --git a/demo/transitions.ts b/demo/transitions.ts new file mode 100644 index 0000000..96a8620 --- /dev/null +++ b/demo/transitions.ts @@ -0,0 +1,288 @@ +/** + * Interactive transitions demo — a sidebar that smoothly expands and collapses. + * + * Press Enter to open the menu sidebar, Esc to close it, q or Ctrl+C to quit. + * Exercises v1 transitions: width + bg animated simultaneously. + */ + +import { + createChannel, + each, + ensure, + main, + race, + resource, + sleep, + spawn, + type Stream, + until, +} from "effection"; +import { + close, + createTerm, + fixed, + grow, + type InputEvent, + type Op, + open, + percent, + rgba, + text, +} from "../mod.ts"; +import { alternateBuffer, cursor, settings } from "../settings.ts"; +import { useInput } from "./use-input.ts"; +import { useStdin } from "./use-stdin.ts"; + +const SIDEBAR_BG_OPEN = rgba(80, 80, 140); +const SIDEBAR_BG_CLOSED = rgba(30, 30, 50); +const CONTENT_BG = rgba(18, 18, 22); +const MODELINE_BG = rgba(40, 40, 55); +const TEXT = rgba(220, 220, 220); +const DIM = rgba(130, 130, 150); +const HEADING = rgba(255, 220, 120); +const MENU_ITEM = rgba(180, 200, 240); +const KEY_LABEL = rgba(255, 220, 120); + +const MENU_ITEMS = [ + "New file", + "Open file…", + "Save", + "Save as…", + "—", + "Preferences", + "Quit (q)", +]; + +const BODY = [ + { kind: "h1", text: "Lorem Ipsum" }, + { + kind: "p", + text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", + }, + { + kind: "p", + text: "Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + }, + { kind: "h2", text: "Section" }, + { + kind: "p", + text: "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.", + }, + { + kind: "p", + text: "Duis aute irure dolor in reprehenderit in voluptate velit esse.", + }, + { + kind: "p", + text: "Excepteur sint occaecat cupidatat non proident, sunt in culpa qui.", + }, +]; + +interface State { + menuOpen: boolean; +} + +function view(state: State): Op[] { + let ops: Op[] = []; + + ops.push( + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + }), + ); + + ops.push( + open("main-row", { + layout: { width: grow(), height: grow(), direction: "ltr" }, + }), + ); + + ops.push( + open("sidebar", { + layout: { + width: state.menuOpen ? percent(0.2) : fixed(2), + height: grow(), + direction: "ttb", + padding: { left: 1, right: 1, top: 1, bottom: 1 }, + gap: 1, + }, + bg: state.menuOpen ? SIDEBAR_BG_OPEN : SIDEBAR_BG_CLOSED, + clip: { horizontal: true }, + transition: { + duration: 0.2, + easing: "easeInOut", + properties: ["width", "bg"], + }, + }), + ); + + if (state.menuOpen) { + ops.push( + open("menu-title", { layout: { height: fixed(1) } }), + text("Menu", { color: HEADING, wrap: 2 }), + close(), + ); + for (let item of MENU_ITEMS) { + ops.push( + open(`menu:${item}`, { layout: { height: fixed(1) } }), + text(item, { color: item === "—" ? DIM : MENU_ITEM, wrap: 2 }), + close(), + ); + } + } + + ops.push(close()); // sidebar + + ops.push( + open("content", { + layout: { + width: grow(), + height: grow(), + direction: "ttb", + padding: { left: 3, right: 3, top: 1, bottom: 1 }, + gap: 1, + }, + bg: CONTENT_BG, + }), + ); + + for (let { kind, text: t } of BODY) { + ops.push(open(`body:${t.slice(0, 8)}`, { layout: { height: fixed(1) } })); + let color = kind === "h1" ? HEADING : kind === "h2" ? KEY_LABEL : TEXT; + ops.push(text(t, { color })); + ops.push(close()); + } + + ops.push(close()); // content + + ops.push(close()); // main-row + + ops.push( + open("modeline", { + layout: { + width: grow(), + height: fixed(1), + direction: "ltr", + padding: { left: 1, right: 1 }, + gap: 2, + }, + bg: MODELINE_BG, + }), + open("mod:quit", { layout: { direction: "ltr", gap: 0 } }), + text("q", { color: KEY_LABEL }), + text(" quit", { color: TEXT }), + close(), + open("mod:menu", { layout: { direction: "ltr", gap: 0 } }), + text("enter", { color: KEY_LABEL }), + text(" show menu", { color: TEXT }), + close(), + open("mod:hide", { layout: { direction: "ltr", gap: 0 } }), + text("esc", { color: KEY_LABEL }), + text(" hide menu", { color: TEXT }), + close(), + close(), // modeline + ); + + ops.push(close()); // root + + return ops; +} + +// A stream that emits at ~60fps intervals, but only while the shared flag is true. +function ticker(flag: { animating: boolean }): Stream { + return resource(function* (provide) { + let ch = createChannel(); + yield* spawn(function* () { + while (true) { + if (flag.animating) { + yield* sleep(16); + yield* ch.send(); + } else { + // Park until animating becomes true; check every 50ms. + yield* sleep(50); + } + } + }); + let sub = yield* ch; + yield* race([provide(sub), drain(ch)]); + }); +} + +function merge( + a: Stream, + b: Stream, +): Stream { + return resource(function* (provide) { + let sub = { + a: yield* a, + b: yield* b, + }; + return yield* provide({ + *next() { + return yield* race([sub.a.next(), sub.b.next()]); + }, + }); + }); +} + +function* drain(stream: Stream) { + for (let _ of yield* each(stream)) { + yield* each.next(); + } +} + +await main(function* () { + let { columns, rows } = Deno.stdout.isTerminal() + ? Deno.consoleSize() + : { columns: 80, rows: 24 }; + + Deno.stdin.setRaw(true); + yield* ensure(() => Deno.stdin.setRaw(false)); + + let stdin = yield* useStdin(); + let input = useInput(stdin); + + let term = yield* until(createTerm({ width: columns, height: rows })); + + let tty = settings(alternateBuffer(), cursor(false)); + Deno.stdout.writeSync(tty.apply); + yield* ensure(() => { + Deno.stdout.writeSync(tty.revert); + }); + + let state: State = { menuOpen: false }; + let flag = { animating: false }; + + function draw(): void { + let { output, animating } = term.render(view(state)); + flag.animating = animating; + Deno.stdout.writeSync(output); + } + + draw(); + + let ticks = ticker(flag); + let events = merge(input, ticks); + + for (let _ of yield* each(events)) { + if (_ !== undefined && typeof _ === "object" && "type" in _) { + let event = _ as InputEvent; + if (event.type === "keydown") { + if (event.ctrl && event.key === "c") { + break; + } + if (event.key === "q") { + break; + } + if (event.key === "Enter") { + state = { ...state, menuOpen: true }; + } + if (event.key === "Escape") { + state = { ...state, menuOpen: false }; + } + } + } + draw(); + yield* each.next(); + } +}); diff --git a/mod.ts b/mod.ts index 8862d13..4a5f09a 100644 --- a/mod.ts +++ b/mod.ts @@ -1,4 +1,5 @@ export * from "./ops.ts"; +export * from "./ops-transitions.ts"; export * from "./term.ts"; export * from "./input.ts"; export * from "./settings.ts"; diff --git a/ops-transitions.ts b/ops-transitions.ts new file mode 100644 index 0000000..f3e2cd5 --- /dev/null +++ b/ops-transitions.ts @@ -0,0 +1,80 @@ +export type TransitionProperty = + | "x" + | "y" + | "position" + | "width" + | "height" + | "size" + | "bg" + | "overlay" + | "borderColor" + | "borderWidth" + | "all"; + +export const TP_X = 1; +export const TP_Y = 2; +export const TP_WIDTH = 4; +export const TP_HEIGHT = 8; +export const TP_BG = 16; +export const TP_OVERLAY = 32; +export const TP_BORDER_COLOR = 128; +export const TP_BORDER_WIDTH = 256; + +export const TP_POSITION = TP_X | TP_Y; +export const TP_SIZE = TP_WIDTH | TP_HEIGHT; +export const TP_ALL = TP_X | TP_Y | TP_WIDTH | TP_HEIGHT | + TP_BG | TP_OVERLAY | TP_BORDER_COLOR | TP_BORDER_WIDTH; + +export function propertyMask(name: TransitionProperty): number { + switch (name) { + case "x": + return TP_X; + case "y": + return TP_Y; + case "position": + return TP_POSITION; + case "width": + return TP_WIDTH; + case "height": + return TP_HEIGHT; + case "size": + return TP_SIZE; + case "bg": + return TP_BG; + case "overlay": + return TP_OVERLAY; + case "borderColor": + return TP_BORDER_COLOR; + case "borderWidth": + return TP_BORDER_WIDTH; + case "all": + return TP_ALL; + } +} + +export type Easing = "linear" | "easeIn" | "easeOut" | "easeInOut"; + +export const EASING_LINEAR = 0; +export const EASING_EASE_IN = 1; +export const EASING_EASE_OUT = 2; +export const EASING_EASE_IN_OUT = 3; + +export function easingByte(easing: Easing): number { + switch (easing) { + case "linear": + return EASING_LINEAR; + case "easeIn": + return EASING_EASE_IN; + case "easeOut": + return EASING_EASE_OUT; + case "easeInOut": + return EASING_EASE_IN_OUT; + } +} + +export interface Transition { + duration: number; + easing?: Easing; + properties: TransitionProperty[]; + interactive?: boolean; +} diff --git a/ops.ts b/ops.ts index 3344eea..bb363fb 100644 --- a/ops.ts +++ b/ops.ts @@ -1,3 +1,6 @@ +import type { Transition } from "./ops-transitions.ts"; +import { easingByte, propertyMask } from "./ops-transitions.ts"; + /* Command buffer opcodes — mirrors ops.h */ const OP_OPEN_ELEMENT = 0x02; const OP_TEXT = 0x03; @@ -10,6 +13,7 @@ const PROP_CORNER_RADIUS = 0x04; const PROP_BORDER = 0x08; const PROP_CLIP = 0x10; const PROP_FLOATING = 0x20; +const PROP_TRANSITION = 0x40; const encoder = new TextEncoder(); @@ -91,6 +95,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; @@ -173,6 +178,21 @@ export function pack( ); o += 4; } + + if (op.transition) { + let t = op.transition; + let pmask = 0; + for (let name of t.properties) pmask |= propertyMask(name); + + view.setFloat32(o, t.duration, true); + o += 4; + view.setUint16(o, pmask, true); + o += 2; + view.setUint8(o, easingByte(t.easing ?? "linear")); + o += 1; + view.setUint8(o, t.interactive ? 1 : 0); + o += 1; + } break; } @@ -269,6 +289,7 @@ export interface OpenElement { attachPoints?: number; zIndex?: number; }; + transition?: Transition; } export interface Text { diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index fa4276a..fabda38 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -25,6 +25,9 @@ pointer event model and certain wrapper types — those are described in Section Input parsing is specified separately in the [Clayterm Input Specification](input-spec.md). +Transitions are specified separately in the +[Clayterm Transitions Specification](transitions-spec.md). + --- ## 2. Scope diff --git a/specs/transitions-spec.md b/specs/transitions-spec.md new file mode 100644 index 0000000..10ec2f5 --- /dev/null +++ b/specs/transitions-spec.md @@ -0,0 +1,575 @@ +# Clayterm Transitions Specification + +**Version:** 0.1 (draft) **Status:** Design specification for a work-in-progress +feature. Normative where it establishes invariants and contract. Descriptive +where surfaces may settle during implementation. + +--- + +## 1. Purpose + +A transition smoothly interpolates an element's visual properties over time when +they change between frames. This specification defines how transitions integrate +with Clayterm's frame-snapshot rendering model: how they are declared, how time +is supplied, and how callers observe in-flight animation so they can drive the +render loop. + +Transitions are a first-class extension of the rendering contract defined in the +[Clayterm Renderer Specification](renderer-spec.md). They do not change the +architectural model, do not introduce a component tree, and do not require +callers to hold cross-frame identity beyond the stable element identifiers they +already use. + +This specification covers what clayterm ships against the current upstream Clay +layout engine. Several capabilities that the rendering model naturally invites — +per-property easing, per-element enter/exit behaviors, custom bezier easings — +are intentionally excluded from v1 because the underlying Clay API cannot +express them without upstream changes that are still in flight. Section 13 +records these deferrals and the upstream dependencies that unblock them. + +--- + +## 2. Scope + +### In scope (normative) + +- The transition model and its relationship to the frame-snapshot rendering + contract +- Time handling and the `deltaTime` convention +- The animating signal returned from `render()` +- Element identity requirements for transitions +- Cancellation semantics (as a consequence of the frame-snapshot model) + +### In scope (non-normative, descriptive) + +- The shape of the `transition` field on the `open()` directive +- The set of easing functions exposed in v1 +- The set of transition properties exposed in v1 +- The wire encoding of transition data in the directive buffer +- Interaction with line mode +- Testing strategy + +### Out of scope (v1) + +See Section 13 for the deferred features and their upstream unblockers. + +### Out of scope (indefinitely) + +- Physics-based animation, spring interpolation, keyframe sequences +- Framework-level concepts of "animation groups" or cross-element choreography + (orchestration is a caller concern) +- Input parsing (see [Input Specification](input-spec.md)) + +--- + +## 3. Terminology + +**Transition.** A time-based interpolation of one or more of an element's visual +properties between an initial value and a target value. + +**Transition property.** A specific visual attribute of an element that can be +interpolated: position (x, y), size (width, height), background color, overlay +color, border color, or border width. + +**Easing.** A function mapping normalized progress in [0, 1] to an eased value +in [0, 1]. Clayterm exposes a fixed set of built-in easings. + +**Delta time (`deltaTime`).** The number of seconds elapsed since the previous +render transaction. Used by the renderer to advance interpolation. + +**Animating signal.** A boolean flag in the render result indicating whether any +transition is currently in progress. Callers use it to decide whether to +schedule another frame. + +--- + +## 4. Architectural Model + +_This section is normative._ + +### 4.1 Relationship to the frame-snapshot model + +Transitions do not alter the frame-snapshot contract defined in INV-3 of the +renderer specification. The directive array still fully describes the desired +state for its frame. Transitions interpolate between the previous frame's state +and the current frame's target state; they do not reintroduce a persistent +component tree on the caller side. + +What transitions add is the requirement that element identifiers remain stable +across frames for any element on which animation is desired. This is not a new +invariant — the existing pointer-event subsystem already relies on stable +identifiers — but it becomes load-bearing for transitions. + +### 4.2 Time ownership + +The `Term` instance is the sole source of frame-to-frame time. On each +`render()` call, the Term reads a monotonic clock and computes the elapsed +seconds since the previous render. That value is passed to the layout engine to +advance any in-flight transitions. + +If the previous render reported `animating=false`, the Term passes `deltaTime=0` +to the layout engine on the current render, regardless of wall-clock time +elapsed. The rationale: Clay is delta-based and has no concept of when a +transition began. Idle time between renders must not count toward any subsequent +transition's elapsed clock, otherwise a long idle gap followed by a mutation +would cause the transition to complete instantly. Passing `deltaTime=0` on the +first frame of any new transition gives it a clean elapsed=0 starting point; +real deltas resume once the previous render signals `animating=true`. + +The caller MAY override the computed delta via an explicit `deltaTime` option on +`render()`. Use cases include deterministic testing, snapshot rendering, and +compute-only renders where the caller is querying bounds without displaying +output. + +The Term MUST NOT use a non-monotonic clock (e.g., `Date.now()`). Wall-clock +time can move backward under NTP adjustments or DST, which would produce +negative deltas and corrupt interpolation. + +### 4.3 Delta clamping + +Clayterm does not clamp `deltaTime`. Long gaps between frames (process +suspension, backgrounded terminal, debugger pause) produce large deltas. The +underlying interpolation is duration-based and naturally clamps at 1.0 of +progress, so a large delta causes in-flight transitions to complete rather than +to overshoot or become unstable. + +### 4.4 Animation-loop signaling + +The render result MUST surface whether any transition is currently active. +Callers use this signal to schedule the next frame. When no transition is +active, callers may stop rendering until the next external event (input, resize, +application state change). + +This requirement exists because terminal applications typically render on-demand +rather than at a fixed refresh rate. Without an explicit animating signal, a +caller has no way to know that a transition it triggered is still in progress. + +### 4.5 Boundary preservation + +Transition configuration MUST be fully serializable. No function pointers, +closures, or callback registries cross the TS→WASM boundary during a render +transaction. + +This preserves INV-2 (single transaction per frame): one binary buffer in, one +result struct out. On the C side, a fixed set of easing handlers is +pre-registered; the directive selects one by enum value. + +--- + +## 5. Core Invariants + +_This section is normative._ + +**INV-T1. Time is driven by delta, not wall clock.** All transition +interpolation advances by `deltaTime`, a per-frame seconds value. The renderer +does not subscribe to an internal timer or schedule work of its own. + +**INV-T2. Render remains pure under time override.** When the caller supplies an +explicit `deltaTime`, the render result depends only on the directive array, the +previous frame's cell buffer, and the supplied `deltaTime`. This makes +deterministic rendering possible for tests and snapshots. + +**INV-T3. No callbacks across the boundary.** Transition configuration MUST be +fully serializable. No function pointers, closures, or callback registries cross +the TS→WASM boundary during a render transaction. + +**INV-T4. Identity is drawn from element IDs.** Transition state is associated +with elements by their declared `id`. Callers using transitions on an element +MUST assign it a stable, unique `id` across frames. Reusing an `id` for a +different logical element in a later frame is a caller error; behavior is +unspecified. + +**INV-T5. Animating signal is accurate per transaction.** The `animating` flag +returned by `render()` reflects the state of transitions as of the end of that +transaction. If it is `true`, at least one transition has non-zero remaining +progress and calling `render()` again with positive `deltaTime` will advance it. + +**INV-T6. Cancellation is structural.** There is no imperative `cancel()` API. +Transitions are cancelled by re-describing the previous target in a later frame; +the transition infrastructure re-anchors the interpolation from the current +visible value to the new target. + +--- + +## 6. Rendering Contract Additions + +_This section is normative._ + +### 6.1 `render()` signature + +The `render()` method accepts an optional `deltaTime` field in its options +argument: + +``` +render(ops: Op[], options?: RenderOptions): RenderResult + +interface RenderOptions { + mode?: "line"; + row?: number; + pointer?: { x, y, down }; + deltaTime?: number; +} +``` + +Each `render()` call advances transitions by its `deltaTime`: + +- If `deltaTime` is provided explicitly, it is used verbatim. +- Otherwise, if the previous render reported `animating=false`, `deltaTime=0` + (see §4.2 for rationale). +- Otherwise, `deltaTime` is the monotonic wall-clock time elapsed since the + previous `render()` call. + +On every `render()` call, Term captures the current monotonic timestamp as the +reference point for the next implicit delta. The two modes can be freely mixed, +but mixing within a single session is primarily useful for tests that step time +manually and should otherwise be avoided. + +### 6.2 `RenderResult` addition + +The render result gains one field: + +``` +interface RenderResult { + output: Uint8Array; + events: PointerEvent[]; + info: RenderInfo; + errors: ClayError[]; + animating: boolean; +} +``` + +`animating` is `true` if and only if at least one element has an in-flight +transition at the end of the transaction. + +### 6.3 The `transition` field on `open()` + +An element may declare a transition by adding a `transition` field to its +open-element directive. The field is optional. Its absence means the element has +no transitions, which is the default. + +See Section 7 for the shape. + +--- + +## 7. Declarative Transition Surface + +_This section is descriptive._ + +### 7.1 The `transition` field + +All listed properties share a single duration and a single easing. + +```ts +open("sidebar", { + layout: { width: fixed(20) }, + bg: rgba(30, 30, 30, 255), + transition: { + duration: 0.2, + easing: "easeOut", + properties: ["x", "width", "bg"], + interactive: false, + }, +}); +``` + +**`duration`** — seconds. Must be non-negative. + +**`easing`** — a string naming one of the built-in easing curves (Section 7.2). +Defaults to `"linear"` when omitted. + +**`properties`** — list of property names to interpolate. Group names +(`position`, `size`, `all`) expand to the union of the underlying properties. + +**`interactive`** (default `false`) — when `false`, pointer interactions with +the element are disabled while a position transition is in progress. When +`true`, pointer interactions remain enabled throughout. + +### 7.2 Easing values + +The `easing` field takes one of four string values: + +```ts +type Easing = "linear" | "easeIn" | "easeOut" | "easeInOut"; +``` + +Each value maps to a wire byte (see Section 8). The byte space is deliberately +larger than this set so additional easings can be added later without breaking +serialized frames. A future parametric easing (e.g., cubic bezier) would extend +the type to a discriminated union: +`"linear" | "easeIn" | ... | { cubicBezier: [number, number, number, number] }`. +Today all values are non-parametric, so the type is a plain string union. + +### 7.3 Property names + +```ts +type TransitionProperty = + | "x" + | "y" + | "position" + | "width" + | "height" + | "size" + | "bg" + | "overlay" + | "borderColor" + | "borderWidth" + | "all"; +``` + +Group names expand as follows: + +- `position` → `x`, `y` +- `size` → `width`, `height` +- `all` → every individual property above + +--- + +## 8. Wire Encoding + +_This section is descriptive._ + +The transition block is a new optional tagged section on `OP_OPEN_ELEMENT`. Its +presence is indicated by a bit in the open-element property mask. When present, +the block is a fixed 8-byte record: + +``` +transition_block { + duration: f32 // seconds, non-negative + properties: u16 // Clay-native bitmask (see below) + easing: u8 // easing kind (0 = linear, 1 = easeIn, 2 = easeOut, 3 = easeInOut) + flags: u8 // bit 0: interactive (0 = disable, 1 = allow) +} +``` + +The `properties` value is the Clay transition property bitmask: + +``` +CLAY_TRANSITION_PROPERTY_X = 1 +CLAY_TRANSITION_PROPERTY_Y = 2 +CLAY_TRANSITION_PROPERTY_WIDTH = 4 +CLAY_TRANSITION_PROPERTY_HEIGHT = 8 +CLAY_TRANSITION_PROPERTY_BACKGROUND_COLOR = 16 +CLAY_TRANSITION_PROPERTY_OVERLAY_COLOR = 32 +CLAY_TRANSITION_PROPERTY_BORDER_COLOR = 128 +CLAY_TRANSITION_PROPERTY_BORDER_WIDTH = 256 +``` + +(Value 64, `CLAY_TRANSITION_PROPERTY_CORNER_RADIUS`, is defined upstream but has +no field in `Clay_TransitionData` and is not emitted by clayterm.) + +The property-name helpers on the TS side expand to this bitmask during packing. + +### 8.1 Validation + +`validate()` checks: + +- `duration >= 0`. +- `easing` is one of the defined enum values (0-3). +- Property names are from the defined set (Section 7.3). + +--- + +## 9. Cancellation Semantics + +_This section is normative._ + +A caller cancels an in-flight transition by emitting a new frame whose directive +for that element describes a different target state. The transition +infrastructure re-anchors the interpolation: + +- The new `initial` value becomes the element's currently-visible value. +- `elapsedTime` resets to zero. +- The new `target` is the value declared in the current frame. + +The transition duration is unchanged. A cancelled-and-reversed transition takes +its full configured duration regardless of how far it had progressed at the time +of cancellation. + +There is no `term.cancelTransition(id)` call. The frame-snapshot model makes +cancellation a structural consequence of re-describing the desired state rather +than an imperative operation. + +--- + +## 10. Interaction with Line Mode + +_This section is descriptive._ + +Line mode emits cells as newline-separated rows without absolute cursor +positioning. Position transitions (`x`, `y`) have no meaningful effect in this +mode: rows are placed at the current cursor, not at absolute coordinates. + +Expected behavior in line mode: + +- Color and size transitions proceed normally. +- Position transitions are silently skipped (the property bits for x and y are + cleared before the configuration reaches Clay). +- The `animating` signal reports accurately regardless of mode. + +--- + +## 11. Testing Strategy + +_This section is descriptive._ + +The `deltaTime` override enables deterministic, snapshot-friendly tests. A test +sequence looks like: + +```ts +term.render(opsA, { deltaTime: 0 }); +term.render(opsB, { deltaTime: 0 }); // target change, no time elapsed +term.render(opsB, { deltaTime: 0.1 }); // 50% through a 0.2s transition +term.render(opsB, { deltaTime: 0.1 }); // 100%, completed +``` + +Test coverage should include, at minimum: + +- Property change mid-stream interpolates and completes. +- `animating` is false on static frames, true during interpolation, false again + when the transition completes. +- Mid-transition target change re-anchors initial to current value. +- Multiple concurrent transitions on multiple elements. +- Line mode: color and size transitions apply, position transitions are silently + skipped. +- Each easing enum produces distinct progression (linear, easeIn, easeOut, + easeInOut). + +--- + +## 12. Implementation Notes + +_This section is descriptive and may change without affecting contract._ + +### 12.1 Clay submodule pin + +clayterm pins Clay at a specific commit that includes the transition API +introduced upstream in commit `ee192f4`. The pin is recorded in the `clay` +submodule pointer. Advancing the pin is a prerequisite when upstream adds +capabilities clayterm depends on (Section 13). + +### 12.2 Handler architecture + +Each `Term` registers one C-side transition handler per easing kind (four total +for v1: linear, easeIn, easeOut, easeInOut). At element-configuration time the +decoder selects the handler matching the element's easing enum and stores it on +the `Clay_TransitionElementConfig`. + +Each handler: + +1. Computes progress as `clamp(elapsedTime / duration, 0, 1)`. +2. Applies its easing curve to progress. +3. Lerps each property named in the `properties` bitmask from `initial` to + `target`. +4. Increments the Term context's `animating_count` unless progress is 1.0. +5. Returns `true` if progress is 1.0 (transition complete), `false` otherwise. + +At the start of each `render()`, the Term resets `animating_count` to zero. At +the end, the value is copied into the result struct as the `animating` flag +(`true` if count > 0). + +### 12.3 Per-Term isolation + +The `animating_count` lives on the Term's C-side context, not as module-level +state. Multiple Terms created in the same process remain isolated. + +### 12.4 Resolving the active Term inside the handler + +Clay's transition-handler signature does not carry a `userData` pointer or +element ID. Each `reduce()` call records the currently-active Term pointer in a +module-level variable (`ct_active_context`) and clears it at the end. The +handler reads this variable to reach the Term's `animating_count`. A single +render pass cannot overlap with another (renders are synchronous), so there is +no concurrency concern. + +--- + +## 13. Deferred Until Upstream Clay + +These capabilities are intentionally not in v1 because the required Clay +primitives are either missing or in flight upstream. The absence is motivated; +re-adding them is straightforward once Clay lands the pieces. + +### 13.1 Per-property easing and duration + +The directive API could allow each property to have its own duration and easing +(e.g., "fade bg in 150ms, slide x in 300ms"). Clay's +`Clay_TransitionElementConfig` carries a single `duration`, a single `handler`, +and a single `properties` bitmask per element, so the handler has no way to +distinguish per-property timing. Working around this requires per-element +metadata addressable from inside the handler. + +**Unblocked by:** Clay adding `void* userData` to the transition arguments +(upstream PR [nicbarker/clay#603](https://github.com/nicbarker/clay/pull/603)). + +### 13.2 Enter and exit transitions + +Elements mounted or removed between frames cannot express per-element initial or +final state deltas. Clay exposes `setInitialState` and `setFinalState` callbacks +with signatures that take no element identifier or user pointer, so there is no +way to look up per-element deltas from inside the callbacks. Additionally, exit +transitions require their configuration to survive past the frame on which the +element was last declared, which requires a lifetime signal. + +**Unblocked by:** + +- Clay `userData` on transition arguments (PR #603, above). +- An exit-completion callback or an `exiting` flag on the render command, both + of which have been discussed upstream with Clay's maintainer as forthcoming. + +### 13.3 `cubicBezier` easing + +Custom cubic-bezier curves need per-element control-point parameters, and Clay's +fixed handler signature has no mechanism to thread parameters to a shared +handler. + +**Unblocked by:** the same Clay `userData` addition as 13.1. + +### 13.4 Corner-radius transitions + +`CLAY_TRANSITION_PROPERTY_CORNER_RADIUS` is defined in the Clay property enum, +but `Clay_TransitionData` has no field carrying corner radius. Upstream +`Clay_EaseOut` does not interpolate it. Clayterm cannot either. + +**Unblocked by:** Clay adding a `cornerRadius` field to `Clay_TransitionData` +and interpolating it in layout. + +--- + +## 14. Demos + +One demo accompanies v1: + +**`demo/transitions.ts`** — exercises v1 transitions meaningfully in a terminal +context (e.g., a collapsing sidebar or a colored highlight that fades between +states). Purpose: surface real-world API sharp edges. + +--- + +## Appendix A. Relationship to the Renderer Specification + +This specification extends, but does not modify, the renderer specification. +Specifically: + +- **INV-1 (Zero IO).** Transitions introduce reading of a monotonic clock for + `deltaTime` computation. A clock read is not terminal IO and does not violate + this invariant. The renderer still produces bytes only; it does not read or + write terminals. + +- **INV-2 (Single transaction per frame).** Transitions preserve this. All + transition configuration is serialized into the single directive buffer; no + additional boundary crossings occur during rendering. + +- **INV-3 (Frame-snapshot independence).** Transitions preserve this at the API + level. Each directive array still fully describes the desired state. Element + IDs carry more weight (Section 4.1) but callers do not acquire new cross-frame + bookkeeping responsibilities. + +- **INV-4 (ANSI byte output).** Unchanged. + +- **INV-5 (Layout/render/diff ownership).** The renderer additionally owns + transition interpolation. Interpolated values feed into the existing layout + and diff pipeline at the same pipeline stage that resolved values would. + +The "Deferred/Future Areas" section of the renderer specification should be +updated to reference this specification rather than list transitions as a single +bullet. diff --git a/src/clayterm.c b/src/clayterm.c index 2af9afd..38348df 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -12,6 +12,7 @@ */ #include "clayterm.h" +#include "transitions.h" #include "../clay/clay.h" #include "buffer.h" #include "cell.h" @@ -19,6 +20,14 @@ #include "utf8.h" #include "wcwidth.h" +/* Module-level pointer to the Term currently executing reduce(). + * Set/cleared around each render pass so transition handlers (which Clay + * invokes with no userData — see Clay_TransitionCallbackArguments) can + * report back to the right Term's animating_count. Revisit once + * nicbarker/clay#603 lands userData on transition callbacks; then the + * handler can resolve its Term from args directly and this can go away. */ +struct Clayterm *ct_active_context = NULL; + /* ── Command buffer protocol ──────────────────────────────────────── */ #define OP_BEGIN_LAYOUT 0x01 @@ -33,6 +42,7 @@ #define PROP_BORDER 0x08 #define PROP_CLIP 0x10 #define PROP_FLOATING 0x20 +#define PROP_TRANSITION 0x40 /* ── Instance state ───────────────────────────────────────────────── */ @@ -51,6 +61,7 @@ struct Clayterm { /* error collection */ Clay_ErrorData errors[MAX_ERRORS]; int error_count; + int animating_count; }; /* Memory layout inside the arena provided by the host: @@ -467,9 +478,12 @@ struct Clayterm *init(void *mem, int w, int h) { return ct; } -void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row) { +void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, + float deltaTime) { int i = 0; + ct_active_context = ct; ct->error_count = 0; + ct->animating_count = 0; Clay_BeginLayout(); @@ -555,6 +569,22 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row) { decl.floating.zIndex = (int16_t)((fc >> 24) & 0xff); } + if (mask & PROP_TRANSITION) { + float duration = rdf(buf, len, &i); + uint32_t props_and_flags = rd(buf, len, &i); + uint16_t props = props_and_flags & 0xFFFF; + uint8_t easing = (props_and_flags >> 16) & 0xFF; + uint8_t interactive = (props_and_flags >> 24) & 0xFF; + + decl.transition.handler = ct_handler_for(easing); + decl.transition.duration = duration; + decl.transition.properties = (Clay_TransitionProperty)props; + decl.transition.interactionHandling = + interactive + ? CLAY_TRANSITION_ALLOW_INTERACTIONS_WHILE_TRANSITIONING_POSITION + : CLAY_TRANSITION_DISABLE_INTERACTIONS_WHILE_TRANSITIONING_POSITION; + } + Clay__ConfigureOpenElement(decl); break; } @@ -577,7 +607,7 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row) { /* 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; } @@ -590,7 +620,7 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row) { } } - Clay_RenderCommandArray cmds = Clay_EndLayout(); + Clay_RenderCommandArray cmds = Clay_EndLayout(deltaTime); /* reset output state */ ct->out.length = 0; @@ -638,12 +668,16 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row) { } else { present_cups(ct, row); } + + ct_active_context = NULL; } char *output(struct Clayterm *ct) { return ct->out.data; } int length(struct Clayterm *ct) { return ct->out.length; } +int animating(struct Clayterm *ct) { return ct->animating_count; } + int get_element_bounds(const char *name, int name_len, float *out) { Clay_String str = {.length = name_len, .chars = name}; Clay_ElementId eid = Clay__HashString(str, 0); diff --git a/src/clayterm.h b/src/clayterm.h index 5065ed5..8a24db4 100644 --- a/src/clayterm.h +++ b/src/clayterm.h @@ -12,9 +12,11 @@ struct Clayterm; /* WASM exports */ int clayterm_size(int w, int h); struct Clayterm *init(void *mem, int w, int h); -void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row); +void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, + float deltaTime); char *output(struct Clayterm *ct); int length(struct Clayterm *ct); +int animating(struct Clayterm *ct); void measure(int ret, int txt); int get_element_bounds(const char *name, int name_len, float *out); diff --git a/src/module.c b/src/module.c index 709884d..bca0757 100644 --- a/src/module.c +++ b/src/module.c @@ -8,5 +8,6 @@ #include "utf8.c" #include "wcwidth.c" #include "clayterm.c" +#include "transitions.c" #include "trie.c" #include "input.c" diff --git a/src/transitions.c b/src/transitions.c new file mode 100644 index 0000000..7c6837b --- /dev/null +++ b/src/transitions.c @@ -0,0 +1,132 @@ +#include "transitions.h" +#include "clayterm.h" + +extern struct Clayterm *ct_active_context; + +static float clampf(float v, float lo, float hi) { + if (v < lo) { + return lo; + } else if (v > hi) { + return hi; + } else { + return v; + } +} + +static float ease_in(float t) { return t * t; } + +static float ease_out(float t) { + float inv = 1.0f - t; + return 1.0f - inv * inv; +} + +static float ease_in_out(float t) { + if (t < 0.5f) { + return 2.0f * t * t; + } else { + float inv = 1.0f - t; + return 1.0f - 2.0f * inv * inv; + } +} + +static float lerpf(float a, float b, float t) { return a + (b - a) * t; } + +static Clay_Color lerp_color(Clay_Color a, Clay_Color b, float t) { + Clay_Color out; + out.r = lerpf(a.r, b.r, t); + out.g = lerpf(a.g, b.g, t); + out.b = lerpf(a.b, b.b, t); + out.a = lerpf(a.a, b.a, t); + return out; +} + +static bool apply(Clay_TransitionCallbackArguments args, float eased, + bool done) { + if (args.properties & CLAY_TRANSITION_PROPERTY_X) { + args.current->boundingBox.x = + lerpf(args.initial.boundingBox.x, args.target.boundingBox.x, eased); + } + if (args.properties & CLAY_TRANSITION_PROPERTY_Y) { + args.current->boundingBox.y = + lerpf(args.initial.boundingBox.y, args.target.boundingBox.y, eased); + } + if (args.properties & CLAY_TRANSITION_PROPERTY_WIDTH) { + args.current->boundingBox.width = lerpf( + args.initial.boundingBox.width, args.target.boundingBox.width, eased); + } + if (args.properties & CLAY_TRANSITION_PROPERTY_HEIGHT) { + args.current->boundingBox.height = lerpf( + args.initial.boundingBox.height, args.target.boundingBox.height, eased); + } + if (args.properties & CLAY_TRANSITION_PROPERTY_BACKGROUND_COLOR) { + args.current->backgroundColor = lerp_color( + args.initial.backgroundColor, args.target.backgroundColor, eased); + } + if (args.properties & CLAY_TRANSITION_PROPERTY_OVERLAY_COLOR) { + args.current->overlayColor = + lerp_color(args.initial.overlayColor, args.target.overlayColor, eased); + } + if (args.properties & CLAY_TRANSITION_PROPERTY_BORDER_COLOR) { + args.current->borderColor = + lerp_color(args.initial.borderColor, args.target.borderColor, eased); + } + if (args.properties & CLAY_TRANSITION_PROPERTY_BORDER_WIDTH) { + args.current->borderWidth.left = (uint16_t)lerpf( + args.initial.borderWidth.left, args.target.borderWidth.left, eased); + args.current->borderWidth.right = (uint16_t)lerpf( + args.initial.borderWidth.right, args.target.borderWidth.right, eased); + args.current->borderWidth.top = (uint16_t)lerpf( + args.initial.borderWidth.top, args.target.borderWidth.top, eased); + args.current->borderWidth.bottom = (uint16_t)lerpf( + args.initial.borderWidth.bottom, args.target.borderWidth.bottom, eased); + args.current->borderWidth.betweenChildren = + (uint16_t)lerpf(args.initial.borderWidth.betweenChildren, + args.target.borderWidth.betweenChildren, eased); + } + if (ct_active_context && !done) { + ct_active_context->animating_count++; + } + return done; +} + +static float progress(Clay_TransitionCallbackArguments args) { + if (args.duration <= 0.0f) { + return 1.0f; + } else { + return clampf(args.elapsedTime / args.duration, 0.0f, 1.0f); + } +} + +bool ct_handler_linear(Clay_TransitionCallbackArguments args) { + float p = progress(args); + return apply(args, p, p >= 1.0f); +} + +bool ct_handler_ease_in(Clay_TransitionCallbackArguments args) { + float p = progress(args); + return apply(args, ease_in(p), p >= 1.0f); +} + +bool ct_handler_ease_out(Clay_TransitionCallbackArguments args) { + float p = progress(args); + return apply(args, ease_out(p), p >= 1.0f); +} + +bool ct_handler_ease_in_out(Clay_TransitionCallbackArguments args) { + float p = progress(args); + return apply(args, ease_in_out(p), p >= 1.0f); +} + +bool (*ct_handler_for(int kind))(Clay_TransitionCallbackArguments) { + switch (kind) { + case CT_EASING_EASE_IN: + return ct_handler_ease_in; + case CT_EASING_EASE_OUT: + return ct_handler_ease_out; + case CT_EASING_EASE_IN_OUT: + return ct_handler_ease_in_out; + case CT_EASING_LINEAR: + default: + return ct_handler_linear; + } +} diff --git a/src/transitions.h b/src/transitions.h new file mode 100644 index 0000000..4a68e43 --- /dev/null +++ b/src/transitions.h @@ -0,0 +1,19 @@ +#ifndef CLAYTERM_TRANSITIONS_H +#define CLAYTERM_TRANSITIONS_H + +#include +#include "../clay/clay.h" + +#define CT_EASING_LINEAR 0 +#define CT_EASING_EASE_IN 1 +#define CT_EASING_EASE_OUT 2 +#define CT_EASING_EASE_IN_OUT 3 + +bool ct_handler_linear(Clay_TransitionCallbackArguments args); +bool ct_handler_ease_in(Clay_TransitionCallbackArguments args); +bool ct_handler_ease_out(Clay_TransitionCallbackArguments args); +bool ct_handler_ease_in_out(Clay_TransitionCallbackArguments args); + +bool (*ct_handler_for(int kind))(Clay_TransitionCallbackArguments); + +#endif diff --git a/term-native.ts b/term-native.ts index 40e646d..cdd0637 100644 --- a/term-native.ts +++ b/term-native.ts @@ -20,12 +20,20 @@ export interface Native { memory: WebAssembly.Memory; statePtr: number; opsBuf: number; - reduce(ct: number, buf: number, len: number, mode: number, row: number): void; + reduce( + ct: number, + buf: number, + len: number, + mode: number, + row: number, + deltaTime: number, + ): void; output(ct: number): number; length(ct: number): number; setPointer(x: number, y: number, down: boolean): void; getPointerOverIds(): string[]; getElementBounds(id: string): BoundingBox | undefined; + animating(ct: number): number; errorCount(ct: number): number; errorType(ct: number, index: number): number; errorMessage(ct: number, index: number): string; @@ -75,6 +83,7 @@ export async function createTermNative( len: number, mode: number, row: number, + deltaTime: number, ): void; output(ct: number): number; length(ct: number): number; @@ -83,6 +92,7 @@ export async function createTermNative( pointer_over_id_string_length(index: number): number; pointer_over_id_string_ptr(index: number): number; get_element_bounds(name: number, len: number, out: number): number; + animating(ct: number): number; error_count(ct: number): number; error_type(ct: number, index: number): number; error_message_length(ct: number, index: number): number; @@ -110,6 +120,7 @@ export async function createTermNative( reduce: ct.reduce, output: ct.output, length: ct.length, + animating: ct.animating as Native["animating"], 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 12517d0..db61018 100644 --- a/term.ts +++ b/term.ts @@ -25,6 +25,7 @@ export interface RenderOptions { y: number; down: boolean; }; + deltaTime?: number; } export type PointerEvent = @@ -64,6 +65,7 @@ export interface RenderResult { events: PointerEvent[]; info: RenderInfo; errors: ClayError[]; + animating: boolean; } export interface Term { @@ -78,13 +80,25 @@ export async function createTerm(options: TermOptions): Promise { let prev = new Set(); let pressed = new Set(); let wasDown = false; + let lastRenderAt: number | undefined; + let wasAnimating = false; return { render(ops: Op[], options?: RenderOptions): RenderResult { let len = pack(ops, memory.buffer, opsBuf, memory.buffer.byteLength); let mode = options?.mode === "line" ? 1 : 0; let row = options?.row ?? 1; - native.reduce(statePtr, opsBuf, len, mode, row); + let now = performance.now() / 1000; + let dt: number; + if (options?.deltaTime !== undefined) { + dt = options.deltaTime; + } else if (!wasAnimating || lastRenderAt === undefined) { + dt = 0; + } else { + dt = now - lastRenderAt; + } + lastRenderAt = now; + native.reduce(statePtr, opsBuf, len, mode, row, dt); if (options?.pointer) { let { x, y, down } = options.pointer; @@ -152,7 +166,9 @@ export async function createTerm(options: TermOptions): Promise { }); } - return { output, events, info, errors }; + let animating = native.animating(statePtr) > 0; + wasAnimating = animating; + return { output, events, info, errors, animating }; }, }; } diff --git a/test/transitions-pack.test.ts b/test/transitions-pack.test.ts new file mode 100644 index 0000000..885a89a --- /dev/null +++ b/test/transitions-pack.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "./suite.ts"; +import { close, open, pack } from "../mod.ts"; + +describe("pack transition", () => { + it("encodes a transition without throwing", () => { + let mem = new ArrayBuffer(4096); + let len = pack( + [ + open("a", { + transition: { + duration: 0.2, + easing: "easeOut", + properties: ["x", "bg"], + }, + }), + close(), + ], + mem, + 0, + 4096, + ); + expect(len).toBeGreaterThan(0); + }); + + it("writes a longer buffer when a transition is present", () => { + let mem1 = new ArrayBuffer(4096); + let withoutLen = pack([open("a", {}), close()], mem1, 0, 4096); + let mem2 = new ArrayBuffer(4096); + let withLen = pack( + [ + open("a", { transition: { duration: 0.2, properties: ["x"] } }), + close(), + ], + mem2, + 0, + 4096, + ); + expect(withLen).toBeGreaterThan(withoutLen); + // The transition block is exactly 8 bytes = 2 words. + expect(withLen - withoutLen).toBe(2); + }); +}); diff --git a/test/transitions-run.test.ts b/test/transitions-run.test.ts new file mode 100644 index 0000000..16d77eb --- /dev/null +++ b/test/transitions-run.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "./suite.ts"; +import { close, createTerm, fixed, type Op, open, rgba } from "../mod.ts"; + +describe("transition lifecycle", () => { + it("animates bg change between frames", async () => { + let term = await createTerm({ width: 20, height: 5 }); + let frame = (bg: number): Op[] => [ + open("box", { + layout: { width: fixed(10), height: fixed(3) }, + bg, + transition: { duration: 0.2, easing: "easeInOut", properties: ["bg"] }, + }), + close(), + ]; + + let r0 = term.render(frame(rgba(255, 0, 0)), { deltaTime: 0 }); + expect(r0.animating).toBe(false); + + term.render(frame(rgba(0, 0, 255)), { deltaTime: 0 }); + let mid = term.render(frame(rgba(0, 0, 255)), { deltaTime: 0.1 }); + expect(mid.animating).toBe(true); + + term.render(frame(rgba(0, 0, 255)), { deltaTime: 0.15 }); + let done = term.render(frame(rgba(0, 0, 255)), { deltaTime: 0.05 }); + expect(done.animating).toBe(false); + }); + + it("reports animating=false when duration is 0", async () => { + let term = await createTerm({ width: 10, height: 3 }); + let frame = (bg: number): Op[] => [ + open("box", { + layout: { width: fixed(5), height: fixed(2) }, + bg, + transition: { duration: 0, properties: ["bg"] }, + }), + close(), + ]; + + term.render(frame(rgba(255, 0, 0)), { deltaTime: 0 }); + let r = term.render(frame(rgba(0, 0, 255)), { deltaTime: 0.1 }); + expect(r.animating).toBe(false); + }); +}); + +describe("transitions in line mode", () => { + it("runs color transitions in line mode", async () => { + let term = await createTerm({ width: 20, height: 5 }); + let frame = (bg: number): Op[] => [ + open("box", { + layout: { width: fixed(10), height: fixed(2) }, + bg, + transition: { duration: 0.2, properties: ["bg"] }, + }), + close(), + ]; + + term.render(frame(rgba(255, 0, 0)), { deltaTime: 0, mode: "line" }); + term.render(frame(rgba(0, 255, 0)), { deltaTime: 0, mode: "line" }); + let r = term.render(frame(rgba(0, 255, 0)), { + deltaTime: 0.1, + mode: "line", + }); + expect(r.animating).toBe(true); + expect(r.output).toBeInstanceOf(Uint8Array); + }); +}); diff --git a/test/transitions.test.ts b/test/transitions.test.ts new file mode 100644 index 0000000..184db3f --- /dev/null +++ b/test/transitions.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "./suite.ts"; +import { close, createTerm, grow, open, text } from "../mod.ts"; + +describe("deltaTime", () => { + it("accepts explicit deltaTime without throwing", async () => { + let term = await createTerm({ width: 40, height: 10 }); + let result = term.render([ + open("root", { layout: { width: grow(), height: grow() } }), + text("hi"), + close(), + ], { deltaTime: 0.016 }); + expect(result.output).toBeInstanceOf(Uint8Array); + }); +}); + +describe("animating", () => { + it("reports animating=false for a static frame", async () => { + let term = await createTerm({ width: 40, height: 10 }); + let result = term.render([ + open("root", { layout: { width: grow(), height: grow() } }), + text("hi"), + close(), + ]); + expect(result.animating).toBe(false); + }); +});