From 5e253a722d6a2a4072797dbc76729a8a0c990ece Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Fri, 26 Jun 2026 01:10:28 -0500 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8Transitions=20(#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * πŸ“ add transitions design specification Design spec for clayterm transitions: frame-snapshot-compatible interpolation of element position, size, and color properties. Defines the deltaTime convention, the animating signal on RenderResult, declarative enter/exit semantics that replace Clay's function-pointer callbacks, and cancellation as a structural consequence of re-describing state. Implementation is gated on bumping the Clay submodule past the upstream transition commit. * ⬆️ bump Clay submodule to latest main (transitions API) * πŸ”§ adapt clayterm to new Clay signatures (OpenTextElement, EndLayout) * ⬆️ pin Clay to 938967a (work around upstream CLAY_WASM_EXPORT typo) * ✨ add deltaTime parameter to reduce() * πŸ”§ add deltaTime to Native.reduce signature * ✨ track deltaTime on Term, accept deltaTime override * ✨ add animating_count to Clayterm context * πŸ”§ expose animating() via Native binding * ✨ surface animating: boolean on RenderResult * πŸ“ rewrite transitions spec for v1 (Clay-supported subset) Scope v1 to what Clay currently supports without userData on transition callbacks: one duration and one easing per element, applied to all listed properties. Drop per-property longhand, enter/exit deltas, cubicBezier, and corner radius β€” each with an explicit "Deferred Until Upstream Clay" entry in Β§13 referencing nicbarker/clay#603 and the forthcoming exit-flag work. Easings are plain string literals ("linear" | "easeIn" | "easeOut" | "easeInOut") since v1 has no parametric easings. * ✨ add transition property names, bitmask helpers, and Easing * ✨ add transition field type to OpenElement * ✨ encode transition block in pack() * ✨ register Clay handlers, interpolate on property change Co-Authored-By: * ✨ reset deltaTime to 0 after idle (preserve transitions across long gaps) * βœ… verify color transitions work in line mode * 🎨 apply deno fmt and clang-format * ✨ add transitions demo (collapsing sidebar) * πŸ“ reference transitions-spec from renderer-spec * ✨ rewrite transitions demo as interactive full-screen menu * ✨ add clay-transitions demo port (v1-compatible subset) Ports the spirit of the raylib-transitions demo to clayterm: a 4Γ—4 grid of colored boxes that animate position, size, and bg color. Shuffle (s) animates positions via Clay's transition system; recolor (c) toggles between two palettes with animated bg interpolation; hover tints each box by blending its bg toward white (overlay-color field is not yet in the v1 command buffer, so lighten-on-hover substitutes). Full mouse tracking is wired via mouseTracking() + pointer state from input events. * 🎨 let clay-transitions demo rows fill available height * 🎨 remove modeline from clay-transitions demo * πŸ“ note ct_active_context is a workaround for Clay userData PR * 🎨 use border-only boxes in clay-transitions demo * 🎨 prevent menu text from wrapping during sidebar transition * πŸ”₯ drop unused grow import in transitions-run test * Update transitions branch with upstream changes (#53) * ✨ add snapshot() for pre-packing directive subtrees Introduces a new `snapshot(ops)` constructor that pre-packs a directive array into its transfer encoding. The returned opaque `Op` can be spliced into any directive array, and during packing its bytes are copied directly into the command buffer without re-encoding. This enables higher-level frameworks to implement dirty tracking: unchanged component subtrees can reuse a cached snapshot, skipping the per-frame packing cost entirely. * docs: add maintainer build guide * πŸ’„ format build docs * πŸ› improve pack string overflow errors * os matrix test in ci (#36) * 🧼 optimize build * 🧼 compress bundled wasm * 🧼 optimize wcwidth.c size * βš™οΈ update npm settings * πŸ› install wasm-opt in ci * ⚑ use brotli-11 + z85 wasm encoding * πŸ“Œ pin @types/node to v22 * 🧼 apply @ghostdevv review suggestions from PR #35 Co-Authored-By: ghostdevv * πŸ”¨ add type to bundle-wasm * πŸ’Œ signed, sealed, delivered * chore: use hashes for versions * chore: don't save git credentials * chore: use array syntax for some reason the schema for the actions wants it to be an array * perf: set concurrency limits to reduce cost and improve dx Without this it means that, for example, if I push a change to a PR then shortly push again this workflow will be running twice. This change will cancel the old run before starting the new one, which reduces the overall actions cost and DX as you don't have extra runs * chore: use hashes for versions * chore: update node version * perf: set concurrency limits to reduce cost and improve dx Without this it means that, for example, if I push a change to a PR then shortly push again this workflow will be running twice. This change will cancel the old run before starting the new one, which reduces the overall actions cost and DX as you don't have extra runs * chore: don't save git credentials * chore: mitigate potential template injection See https://docs.zizmor.sh/audits/#template-injection * chore: update ::set-output command to new syntax https://github.blog/changelog/2022-10-11-github-actions-deprecating-save-state-and-set-output-commands/ * chore: use hashes for versions * chore: don't save git credentials * chore: limit id-token permission to the publishing steps * chore: explicitly disable npm cache to mitigate cache poisoning attacks * chore: mitigate potential template injection See https://docs.zizmor.sh/audits/#template-injection * chore: use oidc This should be using OIDC for publishing * chore: update ::set-output command to new syntax https://github.blog/changelog/2022-10-11-github-actions-deprecating-save-state-and-set-output-commands/ * πŸ™… revert aggressive optimization experiments pending benchmark Reverts three changes that need benchmarks before landing: - -Oz / wasm-opt - brotli+Z85 wasm compression - wcwidth.c rewrite * Add CodSpeed performance benchmarks * πŸ”¨ use deno * 🧼 deno fmt * βš™οΈ vitest -> tinybench * 🧼 remove codspeed assets * πŸ”¨ fix ci * add type module * fmt * chore: update github url (#38) * downgrade to tinybench@5 * move to examples folder with readme * πŸ”§ export animating from wasm build * πŸ§ͺ cover transitions in snapshots and validation * βœ… enforce nonnegative transition duration --------- Co-authored-by: Charles Lowell Co-authored-by: Jacob Bolda Co-authored-by: Nate Moore Co-authored-by: ghostdevv Co-authored-by: Nate Moore Co-authored-by: codspeed-hq[bot] <117304815+codspeed-hq[bot]@users.noreply.github.com> Co-authored-by: Nate Moore * remove uncecessary note * clean up and consolidate * re-organize transitions examples * πŸ”₯remove redundant cast --------- Co-authored-by: Ryan Rauh Co-authored-by: Jacob Bolda Co-authored-by: Nate Moore Co-authored-by: ghostdevv Co-authored-by: Nate Moore Co-authored-by: codspeed-hq[bot] <117304815+codspeed-hq[bot]@users.noreply.github.com> Co-authored-by: Nate Moore Co-authored-by: Ryan Rauh --- Makefile | 1 + clay | 2 +- examples/README.md | 18 + examples/keyboard/index.ts | 4 +- examples/transitions/notecards.ts | 430 ++++++++++++++++++++ examples/transitions/sidebar.ts | 288 ++++++++++++++ examples/{keyboard => }/use-input.ts | 2 +- examples/{keyboard => }/use-stdin.ts | 0 ops.ts | 100 +++++ specs/renderer-spec.md | 3 + specs/transitions-spec.md | 565 +++++++++++++++++++++++++++ src/clayterm.c | 40 +- src/clayterm.h | 4 +- src/module.c | 1 + src/transitions.c | 132 +++++++ src/transitions.h | 19 + term-native.ts | 13 +- term.ts | 20 +- test/transitions.test.ts | 105 +++++ test/validate.test.ts | 31 ++ validate.ts | 29 ++ 21 files changed, 1796 insertions(+), 11 deletions(-) create mode 100644 examples/transitions/notecards.ts create mode 100644 examples/transitions/sidebar.ts rename examples/{keyboard => }/use-input.ts (98%) rename examples/{keyboard => }/use-stdin.ts (100%) create mode 100644 specs/transitions-spec.md create mode 100644 src/transitions.c create mode 100644 src/transitions.h create mode 100644 test/transitions.test.ts diff --git a/Makefile b/Makefile index 1103b8e..05a2dd1 100644 --- a/Makefile +++ b/Makefile @@ -21,6 +21,7 @@ EXPORTS = \ -Wl,--export=pointer_over_id_string_length \ -Wl,--export=pointer_over_id_string_ptr \ -Wl,--export=get_element_bounds \ + -Wl,--export=animating \ -Wl,--export=error_count \ -Wl,--export=error_type \ -Wl,--export=error_message_length \ 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/examples/README.md b/examples/README.md index 9009edc..6f4c846 100644 --- a/examples/README.md +++ b/examples/README.md @@ -57,6 +57,24 @@ state. Clay drives the hit testing; no manual coordinate math required. ![Pointer events demo](keyboard/keyboard-pointer-events.gif) +## Transitions + +Paths: `examples/transitions/sidebar.ts` and `examples/transitions/notecards.ts` + +Run them with: + +```sh +deno run examples/transitions/sidebar.ts +# or +deno run examples/transitions/notecards.ts +``` + +What they show: + +- element transitions driven by changing layout and style properties +- renderer `animating` state for scheduling follow-up frames +- color and layout interpolation in an interactive keyboard/sidebar demo + ## Inline Regions Path: `examples/inline-regions/index.ts` diff --git a/examples/keyboard/index.ts b/examples/keyboard/index.ts index fffa499..e4b7ef3 100644 --- a/examples/keyboard/index.ts +++ b/examples/keyboard/index.ts @@ -32,8 +32,8 @@ import { type Setting, settings, } from "../../settings.ts"; -import { useInput } from "./use-input.ts"; -import { useStdin } from "./use-stdin.ts"; +import { useInput } from "../use-input.ts"; +import { useStdin } from "../use-stdin.ts"; const active = rgba(60, 120, 220); const inactive = rgba(50, 50, 60); diff --git a/examples/transitions/notecards.ts b/examples/transitions/notecards.ts new file mode 100644 index 0000000..66bf2c8 --- /dev/null +++ b/examples/transitions/notecards.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: "center", + alignY: "center", + 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: "center", + }, + 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: "center", + alignY: "center", + }, + 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/examples/transitions/sidebar.ts b/examples/transitions/sidebar.ts new file mode 100644 index 0000000..79757f8 --- /dev/null +++ b/examples/transitions/sidebar.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/examples/keyboard/use-input.ts b/examples/use-input.ts similarity index 98% rename from examples/keyboard/use-input.ts rename to examples/use-input.ts index f85566c..73a4467 100644 --- a/examples/keyboard/use-input.ts +++ b/examples/use-input.ts @@ -11,7 +11,7 @@ import { suspend, until, } from "effection"; -import { createInput, type InputEvent, type InputOptions } from "../../mod.ts"; +import { createInput, type InputEvent, type InputOptions } from "../mod.ts"; function nothing() { return suspend() as unknown as Operation< diff --git a/examples/keyboard/use-stdin.ts b/examples/use-stdin.ts similarity index 100% rename from examples/keyboard/use-stdin.ts rename to examples/use-stdin.ts diff --git a/ops.ts b/ops.ts index 28a52ee..3fd1d04 100644 --- a/ops.ts +++ b/ops.ts @@ -1,3 +1,25 @@ +export type TransitionProperty = + | "x" + | "y" + | "position" + | "width" + | "height" + | "size" + | "bg" + | "overlay" + | "borderColor" + | "borderWidth" + | "all"; + +export type Easing = "linear" | "easeIn" | "easeOut" | "easeInOut"; + +export interface Transition { + duration: number; + easing?: Easing; + properties: TransitionProperty[]; + interactive?: boolean; +} + /* Command buffer opcodes β€” mirrors ops.h */ const OP_OPEN_ELEMENT = 0x02; const OP_TEXT = 0x03; @@ -11,6 +33,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(); @@ -128,6 +151,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; @@ -240,6 +264,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; } @@ -366,6 +405,7 @@ export interface OpenElement { clipTo?: ClipTo; zIndex?: number; }; + transition?: Transition; } export type AttachPoint = @@ -489,6 +529,7 @@ function packSize(ops: Op[]): number { if (op.clip) n += 4; // x, y, expand width/height, parent, attach/pointer, clip/z if (op.floating) n += 7 * 4; + if (op.transition) n += 8; break; } case OP_TEXT: { @@ -507,3 +548,62 @@ export function snapshot(ops: Op[]): Op { let words = pack(ops, buf, 0, size); return { directive: OP_SNAPSHOT, data: new Uint8Array(buf, 0, words * 4) }; } + +const TP_X = 1; +const TP_Y = 2; +const TP_WIDTH = 4; +const TP_HEIGHT = 8; +const TP_BG = 16; +const TP_OVERLAY = 32; +const TP_BORDER_COLOR = 128; +const TP_BORDER_WIDTH = 256; + +const TP_POSITION = TP_X | TP_Y; +const TP_SIZE = TP_WIDTH | TP_HEIGHT; +const TP_ALL = TP_X | TP_Y | TP_WIDTH | TP_HEIGHT | + TP_BG | TP_OVERLAY | TP_BORDER_COLOR | TP_BORDER_WIDTH; + +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; + } +} + +const EASING_LINEAR = 0; +const EASING_EASE_IN = 1; +const EASING_EASE_OUT = 2; +const EASING_EASE_IN_OUT = 3; + +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; + } +} diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index b48ab05..398e78a 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..13e04b1 --- /dev/null +++ b/specs/transitions-spec.md @@ -0,0 +1,565 @@ +# 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. + +--- + +## 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 b1029db..e48091e 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 ───────────────────────────────────────────────── */ @@ -73,6 +83,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: @@ -536,9 +547,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(); @@ -635,6 +649,22 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row) { decl.floating.zIndex = (int16_t)(fd >> 8); } + 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; } @@ -659,7 +689,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; } @@ -672,7 +702,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; @@ -771,12 +801,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 4f32dbf..8bb58f4 100644 --- a/term-native.ts +++ b/term-native.ts @@ -29,12 +29,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; @@ -84,6 +92,7 @@ export async function createTermNative( len: number, mode: number, row: number, + deltaTime: number, ): void; output(ct: number): number; length(ct: number): number; @@ -92,6 +101,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; @@ -124,6 +134,7 @@ export async function createTermNative( reduce: ct.reduce, output: ct.output, length: ct.length, + animating: ct.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 9836d18..3b8ddef 100644 --- a/term.ts +++ b/term.ts @@ -25,6 +25,7 @@ export interface RenderOptions { y: number; down: boolean; }; + deltaTime?: number; } export type PointerEvent = @@ -65,6 +66,7 @@ export interface RenderResult { events: PointerEvent[]; info: RenderInfo; errors: ClayError[]; + animating: boolean; } export interface Term { @@ -79,13 +81,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; @@ -153,7 +167,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.test.ts b/test/transitions.test.ts new file mode 100644 index 0000000..3a99c33 --- /dev/null +++ b/test/transitions.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "./suite.ts"; +import { + close, + createTerm, + fixed, + grow, + type Op, + open, + rgba, + text, +} from "../mod.ts"; + +describe("transitions", () => { + 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); + }); + }); + + describe("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("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/validate.test.ts b/test/validate.test.ts index 5f2afec..3cd8896 100644 --- a/test/validate.test.ts +++ b/test/validate.test.ts @@ -19,6 +19,20 @@ describe("validate", () => { expect(validate([])).toBe(true); }); + it("accepts transition ops", () => { + expect(validate([ + open("x", { + transition: { + duration: 0.2, + easing: "easeOut", + properties: ["x", "bg"], + interactive: true, + }, + }), + close(), + ])).toBe(true); + }); + it("rejects ops with wrong directive", () => { expect(validate([{ directive: 0xff }])).toBe(false); }); @@ -31,6 +45,23 @@ describe("validate", () => { expect(validate([{ directive: 0x03 }])).toBe(false); }); + it("rejects invalid transition properties", () => { + expect(validate([ + open("x", { + // deno-lint-ignore no-explicit-any + transition: { duration: 0.2, properties: ["opacity" as any] }, + }), + close(), + ])).toBe(false); + }); + + it("rejects negative transition duration", () => { + expect(validate([ + open("x", { transition: { duration: -1, properties: ["x"] } }), + close(), + ])).toBe(false); + }); + it("rejects non-array", () => { expect(validate("garbage")).toBe(false); }); diff --git a/validate.ts b/validate.ts index ac0d8f3..07c3f61 100644 --- a/validate.ts +++ b/validate.ts @@ -150,6 +150,34 @@ const Floating = Type.Object({ zIndex: Type.Optional(i16), }); +const TransitionProperty = Type.Union([ + Type.Literal("x"), + Type.Literal("y"), + Type.Literal("position"), + Type.Literal("width"), + Type.Literal("height"), + Type.Literal("size"), + Type.Literal("bg"), + Type.Literal("overlay"), + Type.Literal("borderColor"), + Type.Literal("borderWidth"), + Type.Literal("all"), +]); + +const Easing = Type.Union([ + Type.Literal("linear"), + Type.Literal("easeIn"), + Type.Literal("easeOut"), + Type.Literal("easeInOut"), +]); + +const Transition = Type.Object({ + duration: Type.Number({ minimum: 0 }), + easing: Type.Optional(Easing), + properties: Type.Array(TransitionProperty), + interactive: Type.Optional(Type.Boolean()), +}); + /* ── Op types (discriminated on `directive`) ──────────────────────── */ const CloseElement = Type.Object({ directive: Type.Literal(0x04) }); @@ -163,6 +191,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({ -- 2.51.2