[READ-ONLY] Mirror of https://github.com/bombshell-dev/tty. Platform independent 2D layout engine for terminal applications based on Clay
5

Configure Feed

Select the types of activity you want to include in your feed.

✨Transitions (#27)

* 📝 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 <git@willow.sh>

* 🔨 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 <cowboyd@frontside.com>
Co-authored-by: Jacob Bolda <me@jacobbolda.com>
Co-authored-by: Nate Moore <nate@natemoo.re>
Co-authored-by: ghostdevv <git@willow.sh>
Co-authored-by: Nate Moore <git@natemoo.re>
Co-authored-by: codspeed-hq[bot] <117304815+codspeed-hq[bot]@users.noreply.github.com>
Co-authored-by: Nate Moore <natemoo-re@users.noreply.github.com>

* remove uncecessary note

* clean up and consolidate

* re-organize transitions examples

* 🔥remove redundant cast

---------

Co-authored-by: Ryan Rauh <rauhryan@users.noreply.github.com>
Co-authored-by: Jacob Bolda <me@jacobbolda.com>
Co-authored-by: Nate Moore <nate@natemoo.re>
Co-authored-by: ghostdevv <git@willow.sh>
Co-authored-by: Nate Moore <git@natemoo.re>
Co-authored-by: codspeed-hq[bot] <117304815+codspeed-hq[bot]@users.noreply.github.com>
Co-authored-by: Nate Moore <natemoo-re@users.noreply.github.com>
Co-authored-by: Ryan Rauh <rauh.ryan@gmail.com>

+1795 -10
+1
Makefile
··· 21 21 -Wl,--export=pointer_over_id_string_length \ 22 22 -Wl,--export=pointer_over_id_string_ptr \ 23 23 -Wl,--export=get_element_bounds \ 24 + -Wl,--export=animating \ 24 25 -Wl,--export=error_count \ 25 26 -Wl,--export=error_type \ 26 27 -Wl,--export=error_message_length \
+18
examples/README.md
··· 57 57 58 58 ![Pointer events demo](keyboard/keyboard-pointer-events.gif) 59 59 60 + ## Transitions 61 + 62 + Paths: `examples/transitions/sidebar.ts` and `examples/transitions/notecards.ts` 63 + 64 + Run them with: 65 + 66 + ```sh 67 + deno run examples/transitions/sidebar.ts 68 + # or 69 + deno run examples/transitions/notecards.ts 70 + ``` 71 + 72 + What they show: 73 + 74 + - element transitions driven by changing layout and style properties 75 + - renderer `animating` state for scheduling follow-up frames 76 + - color and layout interpolation in an interactive keyboard/sidebar demo 77 + 60 78 ## Inline Regions 61 79 62 80 Path: `examples/inline-regions/index.ts`
+2 -2
examples/keyboard/index.ts
··· 32 32 type Setting, 33 33 settings, 34 34 } from "../../settings.ts"; 35 - import { useInput } from "./use-input.ts"; 36 - import { useStdin } from "./use-stdin.ts"; 35 + import { useInput } from "../use-input.ts"; 36 + import { useStdin } from "../use-stdin.ts"; 37 37 38 38 const active = rgba(60, 120, 220); 39 39 const inactive = rgba(50, 50, 60);
+1 -1
examples/keyboard/use-input.ts examples/use-input.ts
··· 11 11 suspend, 12 12 until, 13 13 } from "effection"; 14 - import { createInput, type InputEvent, type InputOptions } from "../../mod.ts"; 14 + import { createInput, type InputEvent, type InputOptions } from "../mod.ts"; 15 15 16 16 function nothing() { 17 17 return suspend() as unknown as Operation<
examples/keyboard/use-stdin.ts examples/use-stdin.ts
+430
examples/transitions/notecards.ts
··· 1 + /** 2 + * Clay-transitions demo — a port of the raylib-transitions example to clayterm. 3 + * 4 + * A grid of colored boxes that animate position, size, and background color. 5 + * Press 's' to shuffle (animates position), 'c' to recolor (animates bg). 6 + * Hover any box to see a bg-tint transition on mouse over. 7 + * Press 'q' or Ctrl+C to quit. 8 + * 9 + * Omits enter/exit transitions and "Add Box" (v1 constraints). 10 + * Overlay-color field is not yet in the v1 command buffer; hover tint is 11 + * achieved by blending the bg color toward a highlight shade instead. 12 + */ 13 + 14 + import { 15 + createChannel, 16 + each, 17 + ensure, 18 + main, 19 + race, 20 + resource, 21 + sleep, 22 + spawn, 23 + type Stream, 24 + until, 25 + } from "effection"; 26 + import { 27 + close, 28 + createTerm, 29 + fixed, 30 + grow, 31 + type InputEvent, 32 + type Op, 33 + open, 34 + type PointerEvent, 35 + rgba, 36 + text, 37 + } from "../../mod.ts"; 38 + import { 39 + alternateBuffer, 40 + cursor, 41 + mouseTracking, 42 + settings, 43 + } from "../../settings.ts"; 44 + import { useInput } from "../use-input.ts"; 45 + import { useStdin } from "../use-stdin.ts"; 46 + 47 + const DEFAULT_PALETTE = [ 48 + rgba(225, 138, 50), 49 + rgba(111, 173, 162), 50 + rgba(184, 87, 134), 51 + rgba(87, 134, 184), 52 + rgba(134, 184, 87), 53 + rgba(184, 134, 87), 54 + rgba(87, 184, 134), 55 + rgba(134, 87, 184), 56 + rgba(200, 100, 100), 57 + rgba(100, 200, 100), 58 + rgba(100, 100, 200), 59 + rgba(200, 200, 100), 60 + rgba(200, 100, 200), 61 + rgba(100, 200, 200), 62 + rgba(180, 160, 80), 63 + rgba(80, 160, 180), 64 + ]; 65 + 66 + const PINK_PALETTE = DEFAULT_PALETTE.map((c) => { 67 + let r = (c >> 24) & 0xff; 68 + let g = (c >> 16) & 0xff; 69 + let b = (c >> 8) & 0xff; 70 + let a = c & 0xff; 71 + let pr = Math.min(255, r + 80); 72 + let pg = Math.max(0, g - 60); 73 + let pb = Math.max(0, Math.min(255, b + 40)); 74 + return rgba(pr, pg, pb, a); 75 + }); 76 + 77 + // Blend a packed rgba color toward white by ratio [0,1]. 78 + function lighten(color: number, ratio: number): number { 79 + let r = (color >> 24) & 0xff; 80 + let g = (color >> 16) & 0xff; 81 + let b = (color >> 8) & 0xff; 82 + let a = color & 0xff; 83 + return rgba( 84 + Math.round(r + (255 - r) * ratio), 85 + Math.round(g + (255 - g) * ratio), 86 + Math.round(b + (255 - b) * ratio), 87 + a, 88 + ); 89 + } 90 + 91 + // Lighten ratio applied to bg when box is hovered (blends toward white). 92 + const HOVER_LIGHTEN = 0.35; 93 + 94 + const ROOT_BG = rgba(18, 18, 22); 95 + const TOPBAR_BG = rgba(40, 40, 55); 96 + const BTN_DEFAULT = rgba(60, 60, 80); 97 + const BTN_HOVER = rgba(90, 90, 120); 98 + const KEY_COLOR = rgba(255, 220, 120); 99 + const LABEL_COLOR = rgba(200, 200, 220); 100 + 101 + const COLS = 4; 102 + 103 + interface Box { 104 + id: number; 105 + color: number; 106 + } 107 + 108 + interface State { 109 + boxes: Box[]; 110 + palette: "default" | "pink"; 111 + entered: Set<string>; 112 + pointer: { x: number; y: number; down: boolean } | undefined; 113 + } 114 + 115 + function fisherYates<T>(arr: T[]): T[] { 116 + let out = arr.slice(); 117 + for (let i = out.length - 1; i > 0; i--) { 118 + let j = Math.floor(Math.random() * (i + 1)); 119 + let tmp = out[i]; 120 + out[i] = out[j]; 121 + out[j] = tmp; 122 + } 123 + return out; 124 + } 125 + 126 + function recolor(boxes: Box[], palette: "default" | "pink"): Box[] { 127 + let pal = palette === "pink" ? PINK_PALETTE : DEFAULT_PALETTE; 128 + return boxes.map((b, i) => ({ ...b, color: pal[i % pal.length] })); 129 + } 130 + 131 + function button( 132 + id: string, 133 + label: string, 134 + hovered: boolean, 135 + key: string, 136 + ): Op[] { 137 + return [ 138 + open(id, { 139 + layout: { 140 + direction: "ltr", 141 + padding: { left: 2, right: 2, top: 0, bottom: 0 }, 142 + alignX: "center", 143 + alignY: "center", 144 + height: grow(), 145 + }, 146 + bg: hovered ? BTN_HOVER : BTN_DEFAULT, 147 + border: hovered 148 + ? { color: KEY_COLOR, left: 1, right: 1, top: 1, bottom: 1 } 149 + : undefined, 150 + }), 151 + text(key, { color: KEY_COLOR }), 152 + text(` ${label}`, { color: LABEL_COLOR }), 153 + close(), 154 + ]; 155 + } 156 + 157 + function view(state: State): Op[] { 158 + let ops: Op[] = []; 159 + 160 + ops.push( 161 + open("root", { 162 + layout: { width: grow(), height: grow(), direction: "ttb" }, 163 + bg: ROOT_BG, 164 + }), 165 + ); 166 + 167 + ops.push( 168 + open("topbar", { 169 + layout: { 170 + width: grow(), 171 + height: fixed(3), 172 + direction: "ltr", 173 + padding: { left: 2, right: 2, top: 0, bottom: 0 }, 174 + gap: 2, 175 + alignY: "center", 176 + }, 177 + bg: TOPBAR_BG, 178 + }), 179 + ); 180 + 181 + ops.push( 182 + ...button( 183 + "btn:shuffle", 184 + "shuffle", 185 + state.entered.has("btn:shuffle"), 186 + "s", 187 + ), 188 + ...button( 189 + "btn:recolor", 190 + "recolor", 191 + state.entered.has("btn:recolor"), 192 + "c", 193 + ), 194 + ...button("btn:quit", "quit", state.entered.has("btn:quit"), "q"), 195 + ); 196 + 197 + ops.push(close()); 198 + 199 + ops.push( 200 + open("grid", { 201 + layout: { 202 + width: grow(), 203 + height: grow(), 204 + direction: "ttb", 205 + padding: { left: 1, right: 1, top: 1, bottom: 1 }, 206 + gap: 1, 207 + }, 208 + }), 209 + ); 210 + 211 + let boxes = state.boxes; 212 + let rows = Math.ceil(boxes.length / COLS); 213 + 214 + for (let r = 0; r < rows; r++) { 215 + ops.push( 216 + open(`row:${r}`, { 217 + layout: { 218 + width: grow(), 219 + height: grow(), 220 + direction: "ltr", 221 + gap: 1, 222 + }, 223 + }), 224 + ); 225 + 226 + for (let c = 0; c < COLS; c++) { 227 + let i = r * COLS + c; 228 + if (i >= boxes.length) { 229 + break; 230 + } 231 + let b = boxes[i]; 232 + let bid = `box:${b.id}`; 233 + let hov = state.entered.has(bid); 234 + let borderColor = hov ? lighten(b.color, HOVER_LIGHTEN) : b.color; 235 + ops.push( 236 + open(bid, { 237 + layout: { 238 + width: grow(), 239 + height: grow(), 240 + alignX: "center", 241 + alignY: "center", 242 + }, 243 + border: { 244 + color: borderColor, 245 + left: 1, 246 + right: 1, 247 + top: 1, 248 + bottom: 1, 249 + }, 250 + transition: { 251 + duration: 0.4, 252 + easing: "easeInOut", 253 + properties: ["width", "position", "borderColor"], 254 + interactive: true, 255 + }, 256 + }), 257 + text(`${b.id < 10 ? "0" : ""}${b.id}`, { color: b.color }), 258 + close(), 259 + ); 260 + } 261 + 262 + ops.push(close()); 263 + } 264 + 265 + ops.push(close()); 266 + 267 + ops.push(close()); 268 + 269 + return ops; 270 + } 271 + 272 + function ticker(flag: { animating: boolean }): Stream<void, void> { 273 + return resource(function* (provide) { 274 + let ch = createChannel<void, void>(); 275 + yield* spawn(function* () { 276 + while (true) { 277 + if (flag.animating) { 278 + yield* sleep(2); 279 + yield* ch.send(); 280 + } else { 281 + yield* sleep(50); 282 + } 283 + } 284 + }); 285 + let sub = yield* ch; 286 + yield* race([provide(sub), drain(ch)]); 287 + }); 288 + } 289 + 290 + function merge<A, B, TClose>( 291 + a: Stream<A, TClose>, 292 + b: Stream<B, TClose>, 293 + ): Stream<A | B, TClose> { 294 + return resource(function* (provide) { 295 + let sub = { 296 + a: yield* a, 297 + b: yield* b, 298 + }; 299 + return yield* provide({ 300 + *next() { 301 + return yield* race([sub.a.next(), sub.b.next()]); 302 + }, 303 + }); 304 + }); 305 + } 306 + 307 + function* drain<T, TClose>(stream: Stream<T, TClose>) { 308 + for (let _ of yield* each(stream)) { 309 + yield* each.next(); 310 + } 311 + } 312 + 313 + await main(function* () { 314 + let { columns, rows } = Deno.stdout.isTerminal() 315 + ? Deno.consoleSize() 316 + : { columns: 80, rows: 24 }; 317 + 318 + Deno.stdin.setRaw(true); 319 + yield* ensure(() => Deno.stdin.setRaw(false)); 320 + 321 + let stdin = yield* useStdin(); 322 + let input = useInput(stdin); 323 + 324 + let term = yield* until(createTerm({ width: columns, height: rows })); 325 + 326 + let tty = settings(alternateBuffer(), cursor(false), mouseTracking()); 327 + Deno.stdout.writeSync(tty.apply); 328 + yield* ensure(() => { 329 + Deno.stdout.writeSync(tty.revert); 330 + }); 331 + 332 + let count = 16; 333 + let pal = DEFAULT_PALETTE; 334 + let initialBoxes: Box[] = Array.from({ length: count }, (_, i) => ({ 335 + id: i, 336 + color: pal[i % pal.length], 337 + })); 338 + 339 + let state: State = { 340 + boxes: initialBoxes, 341 + palette: "default", 342 + entered: new Set(), 343 + pointer: undefined, 344 + }; 345 + 346 + let flag = { animating: false }; 347 + 348 + function draw(): void { 349 + let { output, animating, events } = term.render(view(state), { 350 + pointer: state.pointer, 351 + }); 352 + flag.animating = animating; 353 + for (let ev of events) { 354 + if (ev.type === "pointerenter") { 355 + state = { ...state, entered: new Set([...state.entered, ev.id]) }; 356 + } else if (ev.type === "pointerleave") { 357 + let next = new Set(state.entered); 358 + next.delete(ev.id); 359 + state = { ...state, entered: next }; 360 + } 361 + } 362 + Deno.stdout.writeSync(output); 363 + } 364 + 365 + draw(); 366 + 367 + let pointer = createChannel<PointerEvent, void>(); 368 + let ticks = ticker(flag); 369 + let events = merge(merge(input, pointer), ticks); 370 + 371 + for (let ev of yield* each(events)) { 372 + if (ev !== undefined && typeof ev === "object" && "type" in ev) { 373 + let e = ev as InputEvent | PointerEvent; 374 + 375 + if (e.type === "keydown") { 376 + if (e.ctrl && e.key === "c") { 377 + break; 378 + } 379 + if (e.key === "q") { 380 + break; 381 + } 382 + if (e.key === "s") { 383 + state = { ...state, boxes: fisherYates(state.boxes) }; 384 + } 385 + if (e.key === "c") { 386 + let next: "default" | "pink" = state.palette === "default" 387 + ? "pink" 388 + : "default"; 389 + state = { 390 + ...state, 391 + palette: next, 392 + boxes: recolor(state.boxes, next), 393 + }; 394 + } 395 + } 396 + 397 + if ("x" in e && "y" in e) { 398 + let me = e as { x: number; y: number; type: string }; 399 + state = { 400 + ...state, 401 + pointer: { 402 + x: me.x, 403 + y: me.y, 404 + down: me.type === "mousedown", 405 + }, 406 + }; 407 + } 408 + } 409 + 410 + let { output, animating, events: pevents } = term.render(view(state), { 411 + pointer: state.pointer, 412 + }); 413 + flag.animating = animating; 414 + 415 + for (let pev of pevents) { 416 + if (pev.type === "pointerenter") { 417 + state = { ...state, entered: new Set([...state.entered, pev.id]) }; 418 + } else if (pev.type === "pointerleave") { 419 + let next = new Set(state.entered); 420 + next.delete(pev.id); 421 + state = { ...state, entered: next }; 422 + } 423 + yield* pointer.send(pev); 424 + } 425 + 426 + Deno.stdout.writeSync(output); 427 + 428 + yield* each.next(); 429 + } 430 + });
+288
examples/transitions/sidebar.ts
··· 1 + /** 2 + * Interactive transitions demo — a sidebar that smoothly expands and collapses. 3 + * 4 + * Press Enter to open the menu sidebar, Esc to close it, q or Ctrl+C to quit. 5 + * Exercises v1 transitions: width + bg animated simultaneously. 6 + */ 7 + 8 + import { 9 + createChannel, 10 + each, 11 + ensure, 12 + main, 13 + race, 14 + resource, 15 + sleep, 16 + spawn, 17 + type Stream, 18 + until, 19 + } from "effection"; 20 + import { 21 + close, 22 + createTerm, 23 + fixed, 24 + grow, 25 + type InputEvent, 26 + type Op, 27 + open, 28 + percent, 29 + rgba, 30 + text, 31 + } from "../../mod.ts"; 32 + import { alternateBuffer, cursor, settings } from "../../settings.ts"; 33 + import { useInput } from "../use-input.ts"; 34 + import { useStdin } from "../use-stdin.ts"; 35 + 36 + const SIDEBAR_BG_OPEN = rgba(80, 80, 140); 37 + const SIDEBAR_BG_CLOSED = rgba(30, 30, 50); 38 + const CONTENT_BG = rgba(18, 18, 22); 39 + const MODELINE_BG = rgba(40, 40, 55); 40 + const TEXT = rgba(220, 220, 220); 41 + const DIM = rgba(130, 130, 150); 42 + const HEADING = rgba(255, 220, 120); 43 + const MENU_ITEM = rgba(180, 200, 240); 44 + const KEY_LABEL = rgba(255, 220, 120); 45 + 46 + const MENU_ITEMS = [ 47 + "New file", 48 + "Open file…", 49 + "Save", 50 + "Save as…", 51 + "—", 52 + "Preferences", 53 + "Quit (q)", 54 + ]; 55 + 56 + const BODY = [ 57 + { kind: "h1", text: "Lorem Ipsum" }, 58 + { 59 + kind: "p", 60 + text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", 61 + }, 62 + { 63 + kind: "p", 64 + text: "Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", 65 + }, 66 + { kind: "h2", text: "Section" }, 67 + { 68 + kind: "p", 69 + text: "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.", 70 + }, 71 + { 72 + kind: "p", 73 + text: "Duis aute irure dolor in reprehenderit in voluptate velit esse.", 74 + }, 75 + { 76 + kind: "p", 77 + text: "Excepteur sint occaecat cupidatat non proident, sunt in culpa qui.", 78 + }, 79 + ]; 80 + 81 + interface State { 82 + menuOpen: boolean; 83 + } 84 + 85 + function view(state: State): Op[] { 86 + let ops: Op[] = []; 87 + 88 + ops.push( 89 + open("root", { 90 + layout: { width: grow(), height: grow(), direction: "ttb" }, 91 + }), 92 + ); 93 + 94 + ops.push( 95 + open("main-row", { 96 + layout: { width: grow(), height: grow(), direction: "ltr" }, 97 + }), 98 + ); 99 + 100 + ops.push( 101 + open("sidebar", { 102 + layout: { 103 + width: state.menuOpen ? percent(0.2) : fixed(2), 104 + height: grow(), 105 + direction: "ttb", 106 + padding: { left: 1, right: 1, top: 1, bottom: 1 }, 107 + gap: 1, 108 + }, 109 + bg: state.menuOpen ? SIDEBAR_BG_OPEN : SIDEBAR_BG_CLOSED, 110 + clip: { horizontal: true }, 111 + transition: { 112 + duration: 0.2, 113 + easing: "easeInOut", 114 + properties: ["width", "bg"], 115 + }, 116 + }), 117 + ); 118 + 119 + if (state.menuOpen) { 120 + ops.push( 121 + open("menu-title", { layout: { height: fixed(1) } }), 122 + text("Menu", { color: HEADING, wrap: 2 }), 123 + close(), 124 + ); 125 + for (let item of MENU_ITEMS) { 126 + ops.push( 127 + open(`menu:${item}`, { layout: { height: fixed(1) } }), 128 + text(item, { color: item === "—" ? DIM : MENU_ITEM, wrap: 2 }), 129 + close(), 130 + ); 131 + } 132 + } 133 + 134 + ops.push(close()); // sidebar 135 + 136 + ops.push( 137 + open("content", { 138 + layout: { 139 + width: grow(), 140 + height: grow(), 141 + direction: "ttb", 142 + padding: { left: 3, right: 3, top: 1, bottom: 1 }, 143 + gap: 1, 144 + }, 145 + bg: CONTENT_BG, 146 + }), 147 + ); 148 + 149 + for (let { kind, text: t } of BODY) { 150 + ops.push(open(`body:${t.slice(0, 8)}`, { layout: { height: fixed(1) } })); 151 + let color = kind === "h1" ? HEADING : kind === "h2" ? KEY_LABEL : TEXT; 152 + ops.push(text(t, { color })); 153 + ops.push(close()); 154 + } 155 + 156 + ops.push(close()); // content 157 + 158 + ops.push(close()); // main-row 159 + 160 + ops.push( 161 + open("modeline", { 162 + layout: { 163 + width: grow(), 164 + height: fixed(1), 165 + direction: "ltr", 166 + padding: { left: 1, right: 1 }, 167 + gap: 2, 168 + }, 169 + bg: MODELINE_BG, 170 + }), 171 + open("mod:quit", { layout: { direction: "ltr", gap: 0 } }), 172 + text("q", { color: KEY_LABEL }), 173 + text(" quit", { color: TEXT }), 174 + close(), 175 + open("mod:menu", { layout: { direction: "ltr", gap: 0 } }), 176 + text("enter", { color: KEY_LABEL }), 177 + text(" show menu", { color: TEXT }), 178 + close(), 179 + open("mod:hide", { layout: { direction: "ltr", gap: 0 } }), 180 + text("esc", { color: KEY_LABEL }), 181 + text(" hide menu", { color: TEXT }), 182 + close(), 183 + close(), // modeline 184 + ); 185 + 186 + ops.push(close()); // root 187 + 188 + return ops; 189 + } 190 + 191 + // A stream that emits at ~60fps intervals, but only while the shared flag is true. 192 + function ticker(flag: { animating: boolean }): Stream<void, void> { 193 + return resource(function* (provide) { 194 + let ch = createChannel<void, void>(); 195 + yield* spawn(function* () { 196 + while (true) { 197 + if (flag.animating) { 198 + yield* sleep(16); 199 + yield* ch.send(); 200 + } else { 201 + // Park until animating becomes true; check every 50ms. 202 + yield* sleep(50); 203 + } 204 + } 205 + }); 206 + let sub = yield* ch; 207 + yield* race([provide(sub), drain(ch)]); 208 + }); 209 + } 210 + 211 + function merge<A, B, TClose>( 212 + a: Stream<A, TClose>, 213 + b: Stream<B, TClose>, 214 + ): Stream<A | B, TClose> { 215 + return resource(function* (provide) { 216 + let sub = { 217 + a: yield* a, 218 + b: yield* b, 219 + }; 220 + return yield* provide({ 221 + *next() { 222 + return yield* race([sub.a.next(), sub.b.next()]); 223 + }, 224 + }); 225 + }); 226 + } 227 + 228 + function* drain<T, TClose>(stream: Stream<T, TClose>) { 229 + for (let _ of yield* each(stream)) { 230 + yield* each.next(); 231 + } 232 + } 233 + 234 + await main(function* () { 235 + let { columns, rows } = Deno.stdout.isTerminal() 236 + ? Deno.consoleSize() 237 + : { columns: 80, rows: 24 }; 238 + 239 + Deno.stdin.setRaw(true); 240 + yield* ensure(() => Deno.stdin.setRaw(false)); 241 + 242 + let stdin = yield* useStdin(); 243 + let input = useInput(stdin); 244 + 245 + let term = yield* until(createTerm({ width: columns, height: rows })); 246 + 247 + let tty = settings(alternateBuffer(), cursor(false)); 248 + Deno.stdout.writeSync(tty.apply); 249 + yield* ensure(() => { 250 + Deno.stdout.writeSync(tty.revert); 251 + }); 252 + 253 + let state: State = { menuOpen: false }; 254 + let flag = { animating: false }; 255 + 256 + function draw(): void { 257 + let { output, animating } = term.render(view(state)); 258 + flag.animating = animating; 259 + Deno.stdout.writeSync(output); 260 + } 261 + 262 + draw(); 263 + 264 + let ticks = ticker(flag); 265 + let events = merge(input, ticks); 266 + 267 + for (let _ of yield* each(events)) { 268 + if (_ !== undefined && typeof _ === "object" && "type" in _) { 269 + let event = _ as InputEvent; 270 + if (event.type === "keydown") { 271 + if (event.ctrl && event.key === "c") { 272 + break; 273 + } 274 + if (event.key === "q") { 275 + break; 276 + } 277 + if (event.key === "Enter") { 278 + state = { ...state, menuOpen: true }; 279 + } 280 + if (event.key === "Escape") { 281 + state = { ...state, menuOpen: false }; 282 + } 283 + } 284 + } 285 + draw(); 286 + yield* each.next(); 287 + } 288 + });
+100
ops.ts
··· 1 + export type TransitionProperty = 2 + | "x" 3 + | "y" 4 + | "position" 5 + | "width" 6 + | "height" 7 + | "size" 8 + | "bg" 9 + | "overlay" 10 + | "borderColor" 11 + | "borderWidth" 12 + | "all"; 13 + 14 + export type Easing = "linear" | "easeIn" | "easeOut" | "easeInOut"; 15 + 16 + export interface Transition { 17 + duration: number; 18 + easing?: Easing; 19 + properties: TransitionProperty[]; 20 + interactive?: boolean; 21 + } 22 + 1 23 /* Command buffer opcodes — mirrors ops.h */ 2 24 const OP_OPEN_ELEMENT = 0x02; 3 25 const OP_TEXT = 0x03; ··· 11 33 const PROP_BORDER = 0x08; 12 34 const PROP_CLIP = 0x10; 13 35 const PROP_FLOATING = 0x20; 36 + const PROP_TRANSITION = 0x40; 14 37 15 38 const encoder = new TextEncoder(); 16 39 ··· 128 151 if (op.border) mask |= PROP_BORDER; 129 152 if (op.clip) mask |= PROP_CLIP; 130 153 if (op.floating) mask |= PROP_FLOATING; 154 + if (op.transition) mask |= PROP_TRANSITION; 131 155 view.setUint32(o, mask, true); 132 156 o += 4; 133 157 ··· 240 264 ); 241 265 o += 4; 242 266 } 267 + 268 + if (op.transition) { 269 + let t = op.transition; 270 + let pmask = 0; 271 + for (let name of t.properties) pmask |= propertyMask(name); 272 + 273 + view.setFloat32(o, t.duration, true); 274 + o += 4; 275 + view.setUint16(o, pmask, true); 276 + o += 2; 277 + view.setUint8(o, easingByte(t.easing ?? "linear")); 278 + o += 1; 279 + view.setUint8(o, t.interactive ? 1 : 0); 280 + o += 1; 281 + } 243 282 break; 244 283 } 245 284 ··· 366 405 clipTo?: ClipTo; 367 406 zIndex?: number; 368 407 }; 408 + transition?: Transition; 369 409 } 370 410 371 411 export type AttachPoint = ··· 489 529 if (op.clip) n += 4; 490 530 // x, y, expand width/height, parent, attach/pointer, clip/z 491 531 if (op.floating) n += 7 * 4; 532 + if (op.transition) n += 8; 492 533 break; 493 534 } 494 535 case OP_TEXT: { ··· 507 548 let words = pack(ops, buf, 0, size); 508 549 return { directive: OP_SNAPSHOT, data: new Uint8Array(buf, 0, words * 4) }; 509 550 } 551 + 552 + const TP_X = 1; 553 + const TP_Y = 2; 554 + const TP_WIDTH = 4; 555 + const TP_HEIGHT = 8; 556 + const TP_BG = 16; 557 + const TP_OVERLAY = 32; 558 + const TP_BORDER_COLOR = 128; 559 + const TP_BORDER_WIDTH = 256; 560 + 561 + const TP_POSITION = TP_X | TP_Y; 562 + const TP_SIZE = TP_WIDTH | TP_HEIGHT; 563 + const TP_ALL = TP_X | TP_Y | TP_WIDTH | TP_HEIGHT | 564 + TP_BG | TP_OVERLAY | TP_BORDER_COLOR | TP_BORDER_WIDTH; 565 + 566 + function propertyMask(name: TransitionProperty): number { 567 + switch (name) { 568 + case "x": 569 + return TP_X; 570 + case "y": 571 + return TP_Y; 572 + case "position": 573 + return TP_POSITION; 574 + case "width": 575 + return TP_WIDTH; 576 + case "height": 577 + return TP_HEIGHT; 578 + case "size": 579 + return TP_SIZE; 580 + case "bg": 581 + return TP_BG; 582 + case "overlay": 583 + return TP_OVERLAY; 584 + case "borderColor": 585 + return TP_BORDER_COLOR; 586 + case "borderWidth": 587 + return TP_BORDER_WIDTH; 588 + case "all": 589 + return TP_ALL; 590 + } 591 + } 592 + 593 + const EASING_LINEAR = 0; 594 + const EASING_EASE_IN = 1; 595 + const EASING_EASE_OUT = 2; 596 + const EASING_EASE_IN_OUT = 3; 597 + 598 + function easingByte(easing: Easing): number { 599 + switch (easing) { 600 + case "linear": 601 + return EASING_LINEAR; 602 + case "easeIn": 603 + return EASING_EASE_IN; 604 + case "easeOut": 605 + return EASING_EASE_OUT; 606 + case "easeInOut": 607 + return EASING_EASE_IN_OUT; 608 + } 609 + }
+3
specs/renderer-spec.md
··· 25 25 Input parsing is specified separately in the 26 26 [Clayterm Input Specification](input-spec.md). 27 27 28 + Transitions are specified separately in the 29 + [Clayterm Transitions Specification](transitions-spec.md). 30 + 28 31 --- 29 32 30 33 ## 2. Scope
+565
specs/transitions-spec.md
··· 1 + # Clayterm Transitions Specification 2 + 3 + **Version:** 0.1 (draft) **Status:** Design specification for a work-in-progress 4 + feature. Normative where it establishes invariants and contract. Descriptive 5 + where surfaces may settle during implementation. 6 + 7 + --- 8 + 9 + ## 1. Purpose 10 + 11 + A transition smoothly interpolates an element's visual properties over time when 12 + they change between frames. This specification defines how transitions integrate 13 + with Clayterm's frame-snapshot rendering model: how they are declared, how time 14 + is supplied, and how callers observe in-flight animation so they can drive the 15 + render loop. 16 + 17 + Transitions are a first-class extension of the rendering contract defined in the 18 + [Clayterm Renderer Specification](renderer-spec.md). They do not change the 19 + architectural model, do not introduce a component tree, and do not require 20 + callers to hold cross-frame identity beyond the stable element identifiers they 21 + already use. 22 + 23 + This specification covers what clayterm ships against the current upstream Clay 24 + layout engine. Several capabilities that the rendering model naturally invites — 25 + per-property easing, per-element enter/exit behaviors, custom bezier easings — 26 + are intentionally excluded from v1 because the underlying Clay API cannot 27 + express them without upstream changes that are still in flight. Section 13 28 + records these deferrals and the upstream dependencies that unblock them. 29 + 30 + --- 31 + 32 + ## 2. Scope 33 + 34 + ### In scope (normative) 35 + 36 + - The transition model and its relationship to the frame-snapshot rendering 37 + contract 38 + - Time handling and the `deltaTime` convention 39 + - The animating signal returned from `render()` 40 + - Element identity requirements for transitions 41 + - Cancellation semantics (as a consequence of the frame-snapshot model) 42 + 43 + ### In scope (non-normative, descriptive) 44 + 45 + - The shape of the `transition` field on the `open()` directive 46 + - The set of easing functions exposed in v1 47 + - The set of transition properties exposed in v1 48 + - The wire encoding of transition data in the directive buffer 49 + - Interaction with line mode 50 + - Testing strategy 51 + 52 + ### Out of scope (v1) 53 + 54 + See Section 13 for the deferred features and their upstream unblockers. 55 + 56 + ### Out of scope (indefinitely) 57 + 58 + - Physics-based animation, spring interpolation, keyframe sequences 59 + - Framework-level concepts of "animation groups" or cross-element choreography 60 + (orchestration is a caller concern) 61 + - Input parsing (see [Input Specification](input-spec.md)) 62 + 63 + --- 64 + 65 + ## 3. Terminology 66 + 67 + **Transition.** A time-based interpolation of one or more of an element's visual 68 + properties between an initial value and a target value. 69 + 70 + **Transition property.** A specific visual attribute of an element that can be 71 + interpolated: position (x, y), size (width, height), background color, overlay 72 + color, border color, or border width. 73 + 74 + **Easing.** A function mapping normalized progress in [0, 1] to an eased value 75 + in [0, 1]. Clayterm exposes a fixed set of built-in easings. 76 + 77 + **Delta time (`deltaTime`).** The number of seconds elapsed since the previous 78 + render transaction. Used by the renderer to advance interpolation. 79 + 80 + **Animating signal.** A boolean flag in the render result indicating whether any 81 + transition is currently in progress. Callers use it to decide whether to 82 + schedule another frame. 83 + 84 + --- 85 + 86 + ## 4. Architectural Model 87 + 88 + _This section is normative._ 89 + 90 + ### 4.1 Relationship to the frame-snapshot model 91 + 92 + Transitions do not alter the frame-snapshot contract defined in INV-3 of the 93 + renderer specification. The directive array still fully describes the desired 94 + state for its frame. Transitions interpolate between the previous frame's state 95 + and the current frame's target state; they do not reintroduce a persistent 96 + component tree on the caller side. 97 + 98 + What transitions add is the requirement that element identifiers remain stable 99 + across frames for any element on which animation is desired. This is not a new 100 + invariant — the existing pointer-event subsystem already relies on stable 101 + identifiers — but it becomes load-bearing for transitions. 102 + 103 + ### 4.2 Time ownership 104 + 105 + The `Term` instance is the sole source of frame-to-frame time. On each 106 + `render()` call, the Term reads a monotonic clock and computes the elapsed 107 + seconds since the previous render. That value is passed to the layout engine to 108 + advance any in-flight transitions. 109 + 110 + If the previous render reported `animating=false`, the Term passes `deltaTime=0` 111 + to the layout engine on the current render, regardless of wall-clock time 112 + elapsed. The rationale: Clay is delta-based and has no concept of when a 113 + transition began. Idle time between renders must not count toward any subsequent 114 + transition's elapsed clock, otherwise a long idle gap followed by a mutation 115 + would cause the transition to complete instantly. Passing `deltaTime=0` on the 116 + first frame of any new transition gives it a clean elapsed=0 starting point; 117 + real deltas resume once the previous render signals `animating=true`. 118 + 119 + The caller MAY override the computed delta via an explicit `deltaTime` option on 120 + `render()`. Use cases include deterministic testing, snapshot rendering, and 121 + compute-only renders where the caller is querying bounds without displaying 122 + output. 123 + 124 + The Term MUST NOT use a non-monotonic clock (e.g., `Date.now()`). Wall-clock 125 + time can move backward under NTP adjustments or DST, which would produce 126 + negative deltas and corrupt interpolation. 127 + 128 + ### 4.3 Delta clamping 129 + 130 + Clayterm does not clamp `deltaTime`. Long gaps between frames (process 131 + suspension, backgrounded terminal, debugger pause) produce large deltas. The 132 + underlying interpolation is duration-based and naturally clamps at 1.0 of 133 + progress, so a large delta causes in-flight transitions to complete rather than 134 + to overshoot or become unstable. 135 + 136 + ### 4.4 Animation-loop signaling 137 + 138 + The render result MUST surface whether any transition is currently active. 139 + Callers use this signal to schedule the next frame. When no transition is 140 + active, callers may stop rendering until the next external event (input, resize, 141 + application state change). 142 + 143 + This requirement exists because terminal applications typically render on-demand 144 + rather than at a fixed refresh rate. Without an explicit animating signal, a 145 + caller has no way to know that a transition it triggered is still in progress. 146 + 147 + ### 4.5 Boundary preservation 148 + 149 + Transition configuration MUST be fully serializable. No function pointers, 150 + closures, or callback registries cross the TS→WASM boundary during a render 151 + transaction. 152 + 153 + This preserves INV-2 (single transaction per frame): one binary buffer in, one 154 + result struct out. On the C side, a fixed set of easing handlers is 155 + pre-registered; the directive selects one by enum value. 156 + 157 + --- 158 + 159 + ## 5. Core Invariants 160 + 161 + _This section is normative._ 162 + 163 + **INV-T1. Time is driven by delta, not wall clock.** All transition 164 + interpolation advances by `deltaTime`, a per-frame seconds value. The renderer 165 + does not subscribe to an internal timer or schedule work of its own. 166 + 167 + **INV-T2. Render remains pure under time override.** When the caller supplies an 168 + explicit `deltaTime`, the render result depends only on the directive array, the 169 + previous frame's cell buffer, and the supplied `deltaTime`. This makes 170 + deterministic rendering possible for tests and snapshots. 171 + 172 + **INV-T3. No callbacks across the boundary.** Transition configuration MUST be 173 + fully serializable. No function pointers, closures, or callback registries cross 174 + the TS→WASM boundary during a render transaction. 175 + 176 + **INV-T4. Identity is drawn from element IDs.** Transition state is associated 177 + with elements by their declared `id`. Callers using transitions on an element 178 + MUST assign it a stable, unique `id` across frames. Reusing an `id` for a 179 + different logical element in a later frame is a caller error; behavior is 180 + unspecified. 181 + 182 + **INV-T5. Animating signal is accurate per transaction.** The `animating` flag 183 + returned by `render()` reflects the state of transitions as of the end of that 184 + transaction. If it is `true`, at least one transition has non-zero remaining 185 + progress and calling `render()` again with positive `deltaTime` will advance it. 186 + 187 + **INV-T6. Cancellation is structural.** There is no imperative `cancel()` API. 188 + Transitions are cancelled by re-describing the previous target in a later frame; 189 + the transition infrastructure re-anchors the interpolation from the current 190 + visible value to the new target. 191 + 192 + --- 193 + 194 + ## 6. Rendering Contract Additions 195 + 196 + _This section is normative._ 197 + 198 + ### 6.1 `render()` signature 199 + 200 + The `render()` method accepts an optional `deltaTime` field in its options 201 + argument: 202 + 203 + ``` 204 + render(ops: Op[], options?: RenderOptions): RenderResult 205 + 206 + interface RenderOptions { 207 + mode?: "line"; 208 + row?: number; 209 + pointer?: { x, y, down }; 210 + deltaTime?: number; 211 + } 212 + ``` 213 + 214 + Each `render()` call advances transitions by its `deltaTime`: 215 + 216 + - If `deltaTime` is provided explicitly, it is used verbatim. 217 + - Otherwise, if the previous render reported `animating=false`, `deltaTime=0` 218 + (see §4.2 for rationale). 219 + - Otherwise, `deltaTime` is the monotonic wall-clock time elapsed since the 220 + previous `render()` call. 221 + 222 + On every `render()` call, Term captures the current monotonic timestamp as the 223 + reference point for the next implicit delta. The two modes can be freely mixed, 224 + but mixing within a single session is primarily useful for tests that step time 225 + manually and should otherwise be avoided. 226 + 227 + ### 6.2 `RenderResult` addition 228 + 229 + The render result gains one field: 230 + 231 + ``` 232 + interface RenderResult { 233 + output: Uint8Array; 234 + events: PointerEvent[]; 235 + info: RenderInfo; 236 + errors: ClayError[]; 237 + animating: boolean; 238 + } 239 + ``` 240 + 241 + `animating` is `true` if and only if at least one element has an in-flight 242 + transition at the end of the transaction. 243 + 244 + ### 6.3 The `transition` field on `open()` 245 + 246 + An element may declare a transition by adding a `transition` field to its 247 + open-element directive. The field is optional. Its absence means the element has 248 + no transitions, which is the default. 249 + 250 + See Section 7 for the shape. 251 + 252 + --- 253 + 254 + ## 7. Declarative Transition Surface 255 + 256 + _This section is descriptive._ 257 + 258 + ### 7.1 The `transition` field 259 + 260 + All listed properties share a single duration and a single easing. 261 + 262 + ```ts 263 + open("sidebar", { 264 + layout: { width: fixed(20) }, 265 + bg: rgba(30, 30, 30, 255), 266 + transition: { 267 + duration: 0.2, 268 + easing: "easeOut", 269 + properties: ["x", "width", "bg"], 270 + interactive: false, 271 + }, 272 + }); 273 + ``` 274 + 275 + **`duration`** — seconds. Must be non-negative. 276 + 277 + **`easing`** — a string naming one of the built-in easing curves (Section 7.2). 278 + Defaults to `"linear"` when omitted. 279 + 280 + **`properties`** — list of property names to interpolate. Group names 281 + (`position`, `size`, `all`) expand to the union of the underlying properties. 282 + 283 + **`interactive`** (default `false`) — when `false`, pointer interactions with 284 + the element are disabled while a position transition is in progress. When 285 + `true`, pointer interactions remain enabled throughout. 286 + 287 + ### 7.2 Easing values 288 + 289 + The `easing` field takes one of four string values: 290 + 291 + ```ts 292 + type Easing = "linear" | "easeIn" | "easeOut" | "easeInOut"; 293 + ``` 294 + 295 + Each value maps to a wire byte (see Section 8). The byte space is deliberately 296 + larger than this set so additional easings can be added later without breaking 297 + serialized frames. A future parametric easing (e.g., cubic bezier) would extend 298 + the type to a discriminated union: 299 + `"linear" | "easeIn" | ... | { cubicBezier: [number, number, number, number] }`. 300 + Today all values are non-parametric, so the type is a plain string union. 301 + 302 + ### 7.3 Property names 303 + 304 + ```ts 305 + type TransitionProperty = 306 + | "x" 307 + | "y" 308 + | "position" 309 + | "width" 310 + | "height" 311 + | "size" 312 + | "bg" 313 + | "overlay" 314 + | "borderColor" 315 + | "borderWidth" 316 + | "all"; 317 + ``` 318 + 319 + Group names expand as follows: 320 + 321 + - `position` → `x`, `y` 322 + - `size` → `width`, `height` 323 + - `all` → every individual property above 324 + 325 + --- 326 + 327 + ## 8. Wire Encoding 328 + 329 + _This section is descriptive._ 330 + 331 + The transition block is a new optional tagged section on `OP_OPEN_ELEMENT`. Its 332 + presence is indicated by a bit in the open-element property mask. When present, 333 + the block is a fixed 8-byte record: 334 + 335 + ``` 336 + transition_block { 337 + duration: f32 // seconds, non-negative 338 + properties: u16 // Clay-native bitmask (see below) 339 + easing: u8 // easing kind (0 = linear, 1 = easeIn, 2 = easeOut, 3 = easeInOut) 340 + flags: u8 // bit 0: interactive (0 = disable, 1 = allow) 341 + } 342 + ``` 343 + 344 + The `properties` value is the Clay transition property bitmask: 345 + 346 + ``` 347 + CLAY_TRANSITION_PROPERTY_X = 1 348 + CLAY_TRANSITION_PROPERTY_Y = 2 349 + CLAY_TRANSITION_PROPERTY_WIDTH = 4 350 + CLAY_TRANSITION_PROPERTY_HEIGHT = 8 351 + CLAY_TRANSITION_PROPERTY_BACKGROUND_COLOR = 16 352 + CLAY_TRANSITION_PROPERTY_OVERLAY_COLOR = 32 353 + CLAY_TRANSITION_PROPERTY_BORDER_COLOR = 128 354 + CLAY_TRANSITION_PROPERTY_BORDER_WIDTH = 256 355 + ``` 356 + 357 + (Value 64, `CLAY_TRANSITION_PROPERTY_CORNER_RADIUS`, is defined upstream but has 358 + no field in `Clay_TransitionData` and is not emitted by clayterm.) 359 + 360 + The property-name helpers on the TS side expand to this bitmask during packing. 361 + 362 + ### 8.1 Validation 363 + 364 + `validate()` checks: 365 + 366 + - `duration >= 0`. 367 + - `easing` is one of the defined enum values (0-3). 368 + - Property names are from the defined set (Section 7.3). 369 + 370 + --- 371 + 372 + ## 9. Cancellation Semantics 373 + 374 + _This section is normative._ 375 + 376 + A caller cancels an in-flight transition by emitting a new frame whose directive 377 + for that element describes a different target state. The transition 378 + infrastructure re-anchors the interpolation: 379 + 380 + - The new `initial` value becomes the element's currently-visible value. 381 + - `elapsedTime` resets to zero. 382 + - The new `target` is the value declared in the current frame. 383 + 384 + The transition duration is unchanged. A cancelled-and-reversed transition takes 385 + its full configured duration regardless of how far it had progressed at the time 386 + of cancellation. 387 + 388 + There is no `term.cancelTransition(id)` call. The frame-snapshot model makes 389 + cancellation a structural consequence of re-describing the desired state rather 390 + than an imperative operation. 391 + 392 + --- 393 + 394 + ## 10. Interaction with Line Mode 395 + 396 + _This section is descriptive._ 397 + 398 + Line mode emits cells as newline-separated rows without absolute cursor 399 + positioning. Position transitions (`x`, `y`) have no meaningful effect in this 400 + mode: rows are placed at the current cursor, not at absolute coordinates. 401 + 402 + Expected behavior in line mode: 403 + 404 + - Color and size transitions proceed normally. 405 + - Position transitions are silently skipped (the property bits for x and y are 406 + cleared before the configuration reaches Clay). 407 + - The `animating` signal reports accurately regardless of mode. 408 + 409 + --- 410 + 411 + ## 11. Testing Strategy 412 + 413 + _This section is descriptive._ 414 + 415 + The `deltaTime` override enables deterministic, snapshot-friendly tests. A test 416 + sequence looks like: 417 + 418 + ```ts 419 + term.render(opsA, { deltaTime: 0 }); 420 + term.render(opsB, { deltaTime: 0 }); // target change, no time elapsed 421 + term.render(opsB, { deltaTime: 0.1 }); // 50% through a 0.2s transition 422 + term.render(opsB, { deltaTime: 0.1 }); // 100%, completed 423 + ``` 424 + 425 + Test coverage should include, at minimum: 426 + 427 + - Property change mid-stream interpolates and completes. 428 + - `animating` is false on static frames, true during interpolation, false again 429 + when the transition completes. 430 + - Mid-transition target change re-anchors initial to current value. 431 + - Multiple concurrent transitions on multiple elements. 432 + - Line mode: color and size transitions apply, position transitions are silently 433 + skipped. 434 + - Each easing enum produces distinct progression (linear, easeIn, easeOut, 435 + easeInOut). 436 + 437 + --- 438 + 439 + ## 12. Implementation Notes 440 + 441 + _This section is descriptive and may change without affecting contract._ 442 + 443 + ### 12.1 Clay submodule pin 444 + 445 + clayterm pins Clay at a specific commit that includes the transition API 446 + introduced upstream in commit `ee192f4`. The pin is recorded in the `clay` 447 + submodule pointer. Advancing the pin is a prerequisite when upstream adds 448 + capabilities clayterm depends on (Section 13). 449 + 450 + ### 12.2 Handler architecture 451 + 452 + Each `Term` registers one C-side transition handler per easing kind (four total 453 + for v1: linear, easeIn, easeOut, easeInOut). At element-configuration time the 454 + decoder selects the handler matching the element's easing enum and stores it on 455 + the `Clay_TransitionElementConfig`. 456 + 457 + Each handler: 458 + 459 + 1. Computes progress as `clamp(elapsedTime / duration, 0, 1)`. 460 + 2. Applies its easing curve to progress. 461 + 3. Lerps each property named in the `properties` bitmask from `initial` to 462 + `target`. 463 + 4. Increments the Term context's `animating_count` unless progress is 1.0. 464 + 5. Returns `true` if progress is 1.0 (transition complete), `false` otherwise. 465 + 466 + At the start of each `render()`, the Term resets `animating_count` to zero. At 467 + the end, the value is copied into the result struct as the `animating` flag 468 + (`true` if count > 0). 469 + 470 + ### 12.3 Per-Term isolation 471 + 472 + The `animating_count` lives on the Term's C-side context, not as module-level 473 + state. Multiple Terms created in the same process remain isolated. 474 + 475 + ### 12.4 Resolving the active Term inside the handler 476 + 477 + Clay's transition-handler signature does not carry a `userData` pointer or 478 + element ID. Each `reduce()` call records the currently-active Term pointer in a 479 + module-level variable (`ct_active_context`) and clears it at the end. The 480 + handler reads this variable to reach the Term's `animating_count`. A single 481 + render pass cannot overlap with another (renders are synchronous), so there is 482 + no concurrency concern. 483 + 484 + --- 485 + 486 + ## 13. Deferred Until Upstream Clay 487 + 488 + These capabilities are intentionally not in v1 because the required Clay 489 + primitives are either missing or in flight upstream. The absence is motivated; 490 + re-adding them is straightforward once Clay lands the pieces. 491 + 492 + ### 13.1 Per-property easing and duration 493 + 494 + The directive API could allow each property to have its own duration and easing 495 + (e.g., "fade bg in 150ms, slide x in 300ms"). Clay's 496 + `Clay_TransitionElementConfig` carries a single `duration`, a single `handler`, 497 + and a single `properties` bitmask per element, so the handler has no way to 498 + distinguish per-property timing. Working around this requires per-element 499 + metadata addressable from inside the handler. 500 + 501 + **Unblocked by:** Clay adding `void* userData` to the transition arguments 502 + (upstream PR [nicbarker/clay#603](https://github.com/nicbarker/clay/pull/603)). 503 + 504 + ### 13.2 Enter and exit transitions 505 + 506 + Elements mounted or removed between frames cannot express per-element initial or 507 + final state deltas. Clay exposes `setInitialState` and `setFinalState` callbacks 508 + with signatures that take no element identifier or user pointer, so there is no 509 + way to look up per-element deltas from inside the callbacks. Additionally, exit 510 + transitions require their configuration to survive past the frame on which the 511 + element was last declared, which requires a lifetime signal. 512 + 513 + **Unblocked by:** 514 + 515 + - Clay `userData` on transition arguments (PR #603, above). 516 + - An exit-completion callback or an `exiting` flag on the render command, both 517 + of which have been discussed upstream with Clay's maintainer as forthcoming. 518 + 519 + ### 13.3 `cubicBezier` easing 520 + 521 + Custom cubic-bezier curves need per-element control-point parameters, and Clay's 522 + fixed handler signature has no mechanism to thread parameters to a shared 523 + handler. 524 + 525 + **Unblocked by:** the same Clay `userData` addition as 13.1. 526 + 527 + ### 13.4 Corner-radius transitions 528 + 529 + `CLAY_TRANSITION_PROPERTY_CORNER_RADIUS` is defined in the Clay property enum, 530 + but `Clay_TransitionData` has no field carrying corner radius. Upstream 531 + `Clay_EaseOut` does not interpolate it. Clayterm cannot either. 532 + 533 + **Unblocked by:** Clay adding a `cornerRadius` field to `Clay_TransitionData` 534 + and interpolating it in layout. 535 + 536 + --- 537 + 538 + ## Appendix A. Relationship to the Renderer Specification 539 + 540 + This specification extends, but does not modify, the renderer specification. 541 + Specifically: 542 + 543 + - **INV-1 (Zero IO).** Transitions introduce reading of a monotonic clock for 544 + `deltaTime` computation. A clock read is not terminal IO and does not violate 545 + this invariant. The renderer still produces bytes only; it does not read or 546 + write terminals. 547 + 548 + - **INV-2 (Single transaction per frame).** Transitions preserve this. All 549 + transition configuration is serialized into the single directive buffer; no 550 + additional boundary crossings occur during rendering. 551 + 552 + - **INV-3 (Frame-snapshot independence).** Transitions preserve this at the API 553 + level. Each directive array still fully describes the desired state. Element 554 + IDs carry more weight (Section 4.1) but callers do not acquire new cross-frame 555 + bookkeeping responsibilities. 556 + 557 + - **INV-4 (ANSI byte output).** Unchanged. 558 + 559 + - **INV-5 (Layout/render/diff ownership).** The renderer additionally owns 560 + transition interpolation. Interpolated values feed into the existing layout 561 + and diff pipeline at the same pipeline stage that resolved values would. 562 + 563 + The "Deferred/Future Areas" section of the renderer specification should be 564 + updated to reference this specification rather than list transitions as a single 565 + bullet.
+37 -3
src/clayterm.c
··· 12 12 */ 13 13 14 14 #include "clayterm.h" 15 + #include "transitions.h" 15 16 #include "../clay/clay.h" 16 17 #include "buffer.h" 17 18 #include "cell.h" ··· 19 20 #include "utf8.h" 20 21 #include "wcwidth.h" 21 22 23 + /* Module-level pointer to the Term currently executing reduce(). 24 + * Set/cleared around each render pass so transition handlers (which Clay 25 + * invokes with no userData — see Clay_TransitionCallbackArguments) can 26 + * report back to the right Term's animating_count. Revisit once 27 + * nicbarker/clay#603 lands userData on transition callbacks; then the 28 + * handler can resolve its Term from args directly and this can go away. */ 29 + struct Clayterm *ct_active_context = NULL; 30 + 22 31 /* ── Command buffer protocol ──────────────────────────────────────── */ 23 32 24 33 #define OP_BEGIN_LAYOUT 0x01 ··· 33 42 #define PROP_BORDER 0x08 34 43 #define PROP_CLIP 0x10 35 44 #define PROP_FLOATING 0x20 45 + #define PROP_TRANSITION 0x40 36 46 37 47 /* ── Instance state ───────────────────────────────────────────────── */ 38 48 ··· 73 83 /* error collection */ 74 84 Clay_ErrorData errors[MAX_ERRORS]; 75 85 int error_count; 86 + int animating_count; 76 87 }; 77 88 78 89 /* Memory layout inside the arena provided by the host: ··· 536 547 return ct; 537 548 } 538 549 539 - void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row) { 550 + void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, 551 + float deltaTime) { 540 552 int i = 0; 553 + ct_active_context = ct; 541 554 ct->error_count = 0; 555 + ct->animating_count = 0; 542 556 543 557 Clay_BeginLayout(); 544 558 ··· 635 649 decl.floating.zIndex = (int16_t)(fd >> 8); 636 650 } 637 651 652 + if (mask & PROP_TRANSITION) { 653 + float duration = rdf(buf, len, &i); 654 + uint32_t props_and_flags = rd(buf, len, &i); 655 + uint16_t props = props_and_flags & 0xFFFF; 656 + uint8_t easing = (props_and_flags >> 16) & 0xFF; 657 + uint8_t interactive = (props_and_flags >> 24) & 0xFF; 658 + 659 + decl.transition.handler = ct_handler_for(easing); 660 + decl.transition.duration = duration; 661 + decl.transition.properties = (Clay_TransitionProperty)props; 662 + decl.transition.interactionHandling = 663 + interactive 664 + ? CLAY_TRANSITION_ALLOW_INTERACTIONS_WHILE_TRANSITIONING_POSITION 665 + : CLAY_TRANSITION_DISABLE_INTERACTIONS_WHILE_TRANSITIONING_POSITION; 666 + } 667 + 638 668 Clay__ConfigureOpenElement(decl); 639 669 break; 640 670 } ··· 659 689 /* attrs byte -> alpha channel for render_text to extract */ 660 690 config.textColor.a = (float)((cfg >> 24) & 0xff); 661 691 662 - Clay__OpenTextElement(text, Clay__StoreTextElementConfig(config)); 692 + Clay__OpenTextElement(text, config); 663 693 break; 664 694 } 665 695 ··· 672 702 } 673 703 } 674 704 675 - Clay_RenderCommandArray cmds = Clay_EndLayout(); 705 + Clay_RenderCommandArray cmds = Clay_EndLayout(deltaTime); 676 706 677 707 /* reset output state */ 678 708 ct->out.length = 0; ··· 771 801 } else { 772 802 present_cups(ct, row); 773 803 } 804 + 805 + ct_active_context = NULL; 774 806 } 775 807 776 808 char *output(struct Clayterm *ct) { return ct->out.data; } 777 809 778 810 int length(struct Clayterm *ct) { return ct->out.length; } 811 + 812 + int animating(struct Clayterm *ct) { return ct->animating_count; } 779 813 780 814 int get_element_bounds(const char *name, int name_len, float *out) { 781 815 Clay_String str = {.length = name_len, .chars = name};
+3 -1
src/clayterm.h
··· 12 12 /* WASM exports */ 13 13 int clayterm_size(int w, int h); 14 14 struct Clayterm *init(void *mem, int w, int h); 15 - void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row); 15 + void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, 16 + float deltaTime); 16 17 char *output(struct Clayterm *ct); 17 18 int length(struct Clayterm *ct); 19 + int animating(struct Clayterm *ct); 18 20 void measure(int ret, int txt); 19 21 20 22 int get_element_bounds(const char *name, int name_len, float *out);
+1
src/module.c
··· 8 8 #include "utf8.c" 9 9 #include "wcwidth.c" 10 10 #include "clayterm.c" 11 + #include "transitions.c" 11 12 #include "trie.c" 12 13 #include "input.c"
+132
src/transitions.c
··· 1 + #include "transitions.h" 2 + #include "clayterm.h" 3 + 4 + extern struct Clayterm *ct_active_context; 5 + 6 + static float clampf(float v, float lo, float hi) { 7 + if (v < lo) { 8 + return lo; 9 + } else if (v > hi) { 10 + return hi; 11 + } else { 12 + return v; 13 + } 14 + } 15 + 16 + static float ease_in(float t) { return t * t; } 17 + 18 + static float ease_out(float t) { 19 + float inv = 1.0f - t; 20 + return 1.0f - inv * inv; 21 + } 22 + 23 + static float ease_in_out(float t) { 24 + if (t < 0.5f) { 25 + return 2.0f * t * t; 26 + } else { 27 + float inv = 1.0f - t; 28 + return 1.0f - 2.0f * inv * inv; 29 + } 30 + } 31 + 32 + static float lerpf(float a, float b, float t) { return a + (b - a) * t; } 33 + 34 + static Clay_Color lerp_color(Clay_Color a, Clay_Color b, float t) { 35 + Clay_Color out; 36 + out.r = lerpf(a.r, b.r, t); 37 + out.g = lerpf(a.g, b.g, t); 38 + out.b = lerpf(a.b, b.b, t); 39 + out.a = lerpf(a.a, b.a, t); 40 + return out; 41 + } 42 + 43 + static bool apply(Clay_TransitionCallbackArguments args, float eased, 44 + bool done) { 45 + if (args.properties & CLAY_TRANSITION_PROPERTY_X) { 46 + args.current->boundingBox.x = 47 + lerpf(args.initial.boundingBox.x, args.target.boundingBox.x, eased); 48 + } 49 + if (args.properties & CLAY_TRANSITION_PROPERTY_Y) { 50 + args.current->boundingBox.y = 51 + lerpf(args.initial.boundingBox.y, args.target.boundingBox.y, eased); 52 + } 53 + if (args.properties & CLAY_TRANSITION_PROPERTY_WIDTH) { 54 + args.current->boundingBox.width = lerpf( 55 + args.initial.boundingBox.width, args.target.boundingBox.width, eased); 56 + } 57 + if (args.properties & CLAY_TRANSITION_PROPERTY_HEIGHT) { 58 + args.current->boundingBox.height = lerpf( 59 + args.initial.boundingBox.height, args.target.boundingBox.height, eased); 60 + } 61 + if (args.properties & CLAY_TRANSITION_PROPERTY_BACKGROUND_COLOR) { 62 + args.current->backgroundColor = lerp_color( 63 + args.initial.backgroundColor, args.target.backgroundColor, eased); 64 + } 65 + if (args.properties & CLAY_TRANSITION_PROPERTY_OVERLAY_COLOR) { 66 + args.current->overlayColor = 67 + lerp_color(args.initial.overlayColor, args.target.overlayColor, eased); 68 + } 69 + if (args.properties & CLAY_TRANSITION_PROPERTY_BORDER_COLOR) { 70 + args.current->borderColor = 71 + lerp_color(args.initial.borderColor, args.target.borderColor, eased); 72 + } 73 + if (args.properties & CLAY_TRANSITION_PROPERTY_BORDER_WIDTH) { 74 + args.current->borderWidth.left = (uint16_t)lerpf( 75 + args.initial.borderWidth.left, args.target.borderWidth.left, eased); 76 + args.current->borderWidth.right = (uint16_t)lerpf( 77 + args.initial.borderWidth.right, args.target.borderWidth.right, eased); 78 + args.current->borderWidth.top = (uint16_t)lerpf( 79 + args.initial.borderWidth.top, args.target.borderWidth.top, eased); 80 + args.current->borderWidth.bottom = (uint16_t)lerpf( 81 + args.initial.borderWidth.bottom, args.target.borderWidth.bottom, eased); 82 + args.current->borderWidth.betweenChildren = 83 + (uint16_t)lerpf(args.initial.borderWidth.betweenChildren, 84 + args.target.borderWidth.betweenChildren, eased); 85 + } 86 + if (ct_active_context && !done) { 87 + ct_active_context->animating_count++; 88 + } 89 + return done; 90 + } 91 + 92 + static float progress(Clay_TransitionCallbackArguments args) { 93 + if (args.duration <= 0.0f) { 94 + return 1.0f; 95 + } else { 96 + return clampf(args.elapsedTime / args.duration, 0.0f, 1.0f); 97 + } 98 + } 99 + 100 + bool ct_handler_linear(Clay_TransitionCallbackArguments args) { 101 + float p = progress(args); 102 + return apply(args, p, p >= 1.0f); 103 + } 104 + 105 + bool ct_handler_ease_in(Clay_TransitionCallbackArguments args) { 106 + float p = progress(args); 107 + return apply(args, ease_in(p), p >= 1.0f); 108 + } 109 + 110 + bool ct_handler_ease_out(Clay_TransitionCallbackArguments args) { 111 + float p = progress(args); 112 + return apply(args, ease_out(p), p >= 1.0f); 113 + } 114 + 115 + bool ct_handler_ease_in_out(Clay_TransitionCallbackArguments args) { 116 + float p = progress(args); 117 + return apply(args, ease_in_out(p), p >= 1.0f); 118 + } 119 + 120 + bool (*ct_handler_for(int kind))(Clay_TransitionCallbackArguments) { 121 + switch (kind) { 122 + case CT_EASING_EASE_IN: 123 + return ct_handler_ease_in; 124 + case CT_EASING_EASE_OUT: 125 + return ct_handler_ease_out; 126 + case CT_EASING_EASE_IN_OUT: 127 + return ct_handler_ease_in_out; 128 + case CT_EASING_LINEAR: 129 + default: 130 + return ct_handler_linear; 131 + } 132 + }
+19
src/transitions.h
··· 1 + #ifndef CLAYTERM_TRANSITIONS_H 2 + #define CLAYTERM_TRANSITIONS_H 3 + 4 + #include <stdbool.h> 5 + #include "../clay/clay.h" 6 + 7 + #define CT_EASING_LINEAR 0 8 + #define CT_EASING_EASE_IN 1 9 + #define CT_EASING_EASE_OUT 2 10 + #define CT_EASING_EASE_IN_OUT 3 11 + 12 + bool ct_handler_linear(Clay_TransitionCallbackArguments args); 13 + bool ct_handler_ease_in(Clay_TransitionCallbackArguments args); 14 + bool ct_handler_ease_out(Clay_TransitionCallbackArguments args); 15 + bool ct_handler_ease_in_out(Clay_TransitionCallbackArguments args); 16 + 17 + bool (*ct_handler_for(int kind))(Clay_TransitionCallbackArguments); 18 + 19 + #endif
+12 -1
term-native.ts
··· 29 29 memory: WebAssembly.Memory; 30 30 statePtr: number; 31 31 opsBuf: number; 32 - reduce(ct: number, buf: number, len: number, mode: number, row: number): void; 32 + reduce( 33 + ct: number, 34 + buf: number, 35 + len: number, 36 + mode: number, 37 + row: number, 38 + deltaTime: number, 39 + ): void; 33 40 output(ct: number): number; 34 41 length(ct: number): number; 35 42 setPointer(x: number, y: number, down: boolean): void; 36 43 getPointerOverIds(): string[]; 37 44 getElementBounds(id: string): BoundingBox | undefined; 45 + animating(ct: number): number; 38 46 errorCount(ct: number): number; 39 47 errorType(ct: number, index: number): number; 40 48 errorMessage(ct: number, index: number): string; ··· 84 92 len: number, 85 93 mode: number, 86 94 row: number, 95 + deltaTime: number, 87 96 ): void; 88 97 output(ct: number): number; 89 98 length(ct: number): number; ··· 92 101 pointer_over_id_string_length(index: number): number; 93 102 pointer_over_id_string_ptr(index: number): number; 94 103 get_element_bounds(name: number, len: number, out: number): number; 104 + animating(ct: number): number; 95 105 error_count(ct: number): number; 96 106 error_type(ct: number, index: number): number; 97 107 error_message_length(ct: number, index: number): number; ··· 124 134 reduce: ct.reduce, 125 135 output: ct.output, 126 136 length: ct.length, 137 + animating: ct.animating, 127 138 setPointer(x: number, y: number, down: boolean) { 128 139 let view = new DataView(memory.buffer); 129 140 view.setFloat32(opsBuf, x, true);
+18 -2
term.ts
··· 25 25 y: number; 26 26 down: boolean; 27 27 }; 28 + deltaTime?: number; 28 29 } 29 30 30 31 export type PointerEvent = ··· 65 66 events: PointerEvent[]; 66 67 info: RenderInfo; 67 68 errors: ClayError[]; 69 + animating: boolean; 68 70 } 69 71 70 72 export interface Term { ··· 79 81 let prev = new Set<string>(); 80 82 let pressed = new Set<string>(); 81 83 let wasDown = false; 84 + let lastRenderAt: number | undefined; 85 + let wasAnimating = false; 82 86 83 87 return { 84 88 render(ops: Op[], options?: RenderOptions): RenderResult { 85 89 let len = pack(ops, memory.buffer, opsBuf, memory.buffer.byteLength); 86 90 let mode = options?.mode === "line" ? 1 : 0; 87 91 let row = options?.row ?? 1; 88 - native.reduce(statePtr, opsBuf, len, mode, row); 92 + let now = performance.now() / 1000; 93 + let dt: number; 94 + if (options?.deltaTime !== undefined) { 95 + dt = options.deltaTime; 96 + } else if (!wasAnimating || lastRenderAt === undefined) { 97 + dt = 0; 98 + } else { 99 + dt = now - lastRenderAt; 100 + } 101 + lastRenderAt = now; 102 + native.reduce(statePtr, opsBuf, len, mode, row, dt); 89 103 90 104 if (options?.pointer) { 91 105 let { x, y, down } = options.pointer; ··· 153 167 }); 154 168 } 155 169 156 - return { output, events, info, errors }; 170 + let animating = native.animating(statePtr) > 0; 171 + wasAnimating = animating; 172 + return { output, events, info, errors, animating }; 157 173 }, 158 174 }; 159 175 }
+105
test/transitions.test.ts
··· 1 + import { describe, expect, it } from "./suite.ts"; 2 + import { 3 + close, 4 + createTerm, 5 + fixed, 6 + grow, 7 + type Op, 8 + open, 9 + rgba, 10 + text, 11 + } from "../mod.ts"; 12 + 13 + describe("transitions", () => { 14 + describe("deltaTime", () => { 15 + it("accepts explicit deltaTime without throwing", async () => { 16 + let term = await createTerm({ width: 40, height: 10 }); 17 + let result = term.render([ 18 + open("root", { layout: { width: grow(), height: grow() } }), 19 + text("hi"), 20 + close(), 21 + ], { deltaTime: 0.016 }); 22 + expect(result.output).toBeInstanceOf(Uint8Array); 23 + }); 24 + }); 25 + 26 + describe("animating", () => { 27 + it("reports animating=false for a static frame", async () => { 28 + let term = await createTerm({ width: 40, height: 10 }); 29 + let result = term.render([ 30 + open("root", { layout: { width: grow(), height: grow() } }), 31 + text("hi"), 32 + close(), 33 + ]); 34 + expect(result.animating).toBe(false); 35 + }); 36 + }); 37 + 38 + describe("lifecycle", () => { 39 + it("animates bg change between frames", async () => { 40 + let term = await createTerm({ width: 20, height: 5 }); 41 + let frame = (bg: number): Op[] => [ 42 + open("box", { 43 + layout: { width: fixed(10), height: fixed(3) }, 44 + bg, 45 + transition: { 46 + duration: 0.2, 47 + easing: "easeInOut", 48 + properties: ["bg"], 49 + }, 50 + }), 51 + close(), 52 + ]; 53 + 54 + let r0 = term.render(frame(rgba(255, 0, 0)), { deltaTime: 0 }); 55 + expect(r0.animating).toBe(false); 56 + 57 + term.render(frame(rgba(0, 0, 255)), { deltaTime: 0 }); 58 + let mid = term.render(frame(rgba(0, 0, 255)), { deltaTime: 0.1 }); 59 + expect(mid.animating).toBe(true); 60 + 61 + term.render(frame(rgba(0, 0, 255)), { deltaTime: 0.15 }); 62 + let done = term.render(frame(rgba(0, 0, 255)), { deltaTime: 0.05 }); 63 + expect(done.animating).toBe(false); 64 + }); 65 + 66 + it("reports animating=false when duration is 0", async () => { 67 + let term = await createTerm({ width: 10, height: 3 }); 68 + let frame = (bg: number): Op[] => [ 69 + open("box", { 70 + layout: { width: fixed(5), height: fixed(2) }, 71 + bg, 72 + transition: { duration: 0, properties: ["bg"] }, 73 + }), 74 + close(), 75 + ]; 76 + 77 + term.render(frame(rgba(255, 0, 0)), { deltaTime: 0 }); 78 + let r = term.render(frame(rgba(0, 0, 255)), { deltaTime: 0.1 }); 79 + expect(r.animating).toBe(false); 80 + }); 81 + }); 82 + 83 + describe("line mode", () => { 84 + it("runs color transitions in line mode", async () => { 85 + let term = await createTerm({ width: 20, height: 5 }); 86 + let frame = (bg: number): Op[] => [ 87 + open("box", { 88 + layout: { width: fixed(10), height: fixed(2) }, 89 + bg, 90 + transition: { duration: 0.2, properties: ["bg"] }, 91 + }), 92 + close(), 93 + ]; 94 + 95 + term.render(frame(rgba(255, 0, 0)), { deltaTime: 0, mode: "line" }); 96 + term.render(frame(rgba(0, 255, 0)), { deltaTime: 0, mode: "line" }); 97 + let r = term.render(frame(rgba(0, 255, 0)), { 98 + deltaTime: 0.1, 99 + mode: "line", 100 + }); 101 + expect(r.animating).toBe(true); 102 + expect(r.output).toBeInstanceOf(Uint8Array); 103 + }); 104 + }); 105 + });
+31
test/validate.test.ts
··· 19 19 expect(validate([])).toBe(true); 20 20 }); 21 21 22 + it("accepts transition ops", () => { 23 + expect(validate([ 24 + open("x", { 25 + transition: { 26 + duration: 0.2, 27 + easing: "easeOut", 28 + properties: ["x", "bg"], 29 + interactive: true, 30 + }, 31 + }), 32 + close(), 33 + ])).toBe(true); 34 + }); 35 + 22 36 it("rejects ops with wrong directive", () => { 23 37 expect(validate([{ directive: 0xff }])).toBe(false); 24 38 }); ··· 29 43 30 44 it("rejects text missing content", () => { 31 45 expect(validate([{ directive: 0x03 }])).toBe(false); 46 + }); 47 + 48 + it("rejects invalid transition properties", () => { 49 + expect(validate([ 50 + open("x", { 51 + // deno-lint-ignore no-explicit-any 52 + transition: { duration: 0.2, properties: ["opacity" as any] }, 53 + }), 54 + close(), 55 + ])).toBe(false); 56 + }); 57 + 58 + it("rejects negative transition duration", () => { 59 + expect(validate([ 60 + open("x", { transition: { duration: -1, properties: ["x"] } }), 61 + close(), 62 + ])).toBe(false); 32 63 }); 33 64 34 65 it("rejects non-array", () => {
+29
validate.ts
··· 150 150 zIndex: Type.Optional(i16), 151 151 }); 152 152 153 + const TransitionProperty = Type.Union([ 154 + Type.Literal("x"), 155 + Type.Literal("y"), 156 + Type.Literal("position"), 157 + Type.Literal("width"), 158 + Type.Literal("height"), 159 + Type.Literal("size"), 160 + Type.Literal("bg"), 161 + Type.Literal("overlay"), 162 + Type.Literal("borderColor"), 163 + Type.Literal("borderWidth"), 164 + Type.Literal("all"), 165 + ]); 166 + 167 + const Easing = Type.Union([ 168 + Type.Literal("linear"), 169 + Type.Literal("easeIn"), 170 + Type.Literal("easeOut"), 171 + Type.Literal("easeInOut"), 172 + ]); 173 + 174 + const Transition = Type.Object({ 175 + duration: Type.Number({ minimum: 0 }), 176 + easing: Type.Optional(Easing), 177 + properties: Type.Array(TransitionProperty), 178 + interactive: Type.Optional(Type.Boolean()), 179 + }); 180 + 153 181 /* ── Op types (discriminated on `directive`) ──────────────────────── */ 154 182 155 183 const CloseElement = Type.Object({ directive: Type.Literal(0x04) }); ··· 163 191 border: Type.Optional(Border), 164 192 clip: Type.Optional(Clip), 165 193 floating: Type.Optional(Floating), 194 + transition: Type.Optional(Transition), 166 195 }); 167 196 168 197 const TextOp = Type.Object({