[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.

Merge PR #27 transitions

Ryan Rauh (May 10, 2026, 7:38 AM EDT) da915f56 9470772d

+1754 -7
+430
demo/clay-transitions.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: 2, 143 + alignY: 2, 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: 2, 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: 2, 241 + alignY: 2, 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
demo/transitions.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 + });
+1
mod.ts
··· 1 1 export * from "./ops.ts"; 2 + export * from "./ops-transitions.ts"; 2 3 export * from "./term.ts"; 3 4 export * from "./input.ts"; 4 5 export * from "./settings.ts";
+80
ops-transitions.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 const TP_X = 1; 15 + export const TP_Y = 2; 16 + export const TP_WIDTH = 4; 17 + export const TP_HEIGHT = 8; 18 + export const TP_BG = 16; 19 + export const TP_OVERLAY = 32; 20 + export const TP_BORDER_COLOR = 128; 21 + export const TP_BORDER_WIDTH = 256; 22 + 23 + export const TP_POSITION = TP_X | TP_Y; 24 + export const TP_SIZE = TP_WIDTH | TP_HEIGHT; 25 + export const TP_ALL = TP_X | TP_Y | TP_WIDTH | TP_HEIGHT | 26 + TP_BG | TP_OVERLAY | TP_BORDER_COLOR | TP_BORDER_WIDTH; 27 + 28 + export function propertyMask(name: TransitionProperty): number { 29 + switch (name) { 30 + case "x": 31 + return TP_X; 32 + case "y": 33 + return TP_Y; 34 + case "position": 35 + return TP_POSITION; 36 + case "width": 37 + return TP_WIDTH; 38 + case "height": 39 + return TP_HEIGHT; 40 + case "size": 41 + return TP_SIZE; 42 + case "bg": 43 + return TP_BG; 44 + case "overlay": 45 + return TP_OVERLAY; 46 + case "borderColor": 47 + return TP_BORDER_COLOR; 48 + case "borderWidth": 49 + return TP_BORDER_WIDTH; 50 + case "all": 51 + return TP_ALL; 52 + } 53 + } 54 + 55 + export type Easing = "linear" | "easeIn" | "easeOut" | "easeInOut"; 56 + 57 + export const EASING_LINEAR = 0; 58 + export const EASING_EASE_IN = 1; 59 + export const EASING_EASE_OUT = 2; 60 + export const EASING_EASE_IN_OUT = 3; 61 + 62 + export function easingByte(easing: Easing): number { 63 + switch (easing) { 64 + case "linear": 65 + return EASING_LINEAR; 66 + case "easeIn": 67 + return EASING_EASE_IN; 68 + case "easeOut": 69 + return EASING_EASE_OUT; 70 + case "easeInOut": 71 + return EASING_EASE_IN_OUT; 72 + } 73 + } 74 + 75 + export interface Transition { 76 + duration: number; 77 + easing?: Easing; 78 + properties: TransitionProperty[]; 79 + interactive?: boolean; 80 + }
+21
ops.ts
··· 1 + import type { Transition } from "./ops-transitions.ts"; 2 + import { easingByte, propertyMask } from "./ops-transitions.ts"; 3 + 1 4 /* Command buffer opcodes — mirrors ops.h */ 2 5 const OP_OPEN_ELEMENT = 0x02; 3 6 const OP_TEXT = 0x03; ··· 10 13 const PROP_BORDER = 0x08; 11 14 const PROP_CLIP = 0x10; 12 15 const PROP_FLOATING = 0x20; 16 + const PROP_TRANSITION = 0x40; 13 17 14 18 const encoder = new TextEncoder(); 15 19 ··· 91 95 if (op.border) mask |= PROP_BORDER; 92 96 if (op.clip) mask |= PROP_CLIP; 93 97 if (op.floating) mask |= PROP_FLOATING; 98 + if (op.transition) mask |= PROP_TRANSITION; 94 99 view.setUint32(o, mask, true); 95 100 o += 4; 96 101 ··· 173 178 ); 174 179 o += 4; 175 180 } 181 + 182 + if (op.transition) { 183 + let t = op.transition; 184 + let pmask = 0; 185 + for (let name of t.properties) pmask |= propertyMask(name); 186 + 187 + view.setFloat32(o, t.duration, true); 188 + o += 4; 189 + view.setUint16(o, pmask, true); 190 + o += 2; 191 + view.setUint8(o, easingByte(t.easing ?? "linear")); 192 + o += 1; 193 + view.setUint8(o, t.interactive ? 1 : 0); 194 + o += 1; 195 + } 176 196 break; 177 197 } 178 198 ··· 269 289 attachPoints?: number; 270 290 zIndex?: number; 271 291 }; 292 + transition?: Transition; 272 293 } 273 294 274 295 export interface Text {
+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
+575
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 + ## 14. Demos 539 + 540 + One demo accompanies v1: 541 + 542 + **`demo/transitions.ts`** — exercises v1 transitions meaningfully in a terminal 543 + context (e.g., a collapsing sidebar or a colored highlight that fades between 544 + states). Purpose: surface real-world API sharp edges. 545 + 546 + --- 547 + 548 + ## Appendix A. Relationship to the Renderer Specification 549 + 550 + This specification extends, but does not modify, the renderer specification. 551 + Specifically: 552 + 553 + - **INV-1 (Zero IO).** Transitions introduce reading of a monotonic clock for 554 + `deltaTime` computation. A clock read is not terminal IO and does not violate 555 + this invariant. The renderer still produces bytes only; it does not read or 556 + write terminals. 557 + 558 + - **INV-2 (Single transaction per frame).** Transitions preserve this. All 559 + transition configuration is serialized into the single directive buffer; no 560 + additional boundary crossings occur during rendering. 561 + 562 + - **INV-3 (Frame-snapshot independence).** Transitions preserve this at the API 563 + level. Each directive array still fully describes the desired state. Element 564 + IDs carry more weight (Section 4.1) but callers do not acquire new cross-frame 565 + bookkeeping responsibilities. 566 + 567 + - **INV-4 (ANSI byte output).** Unchanged. 568 + 569 + - **INV-5 (Layout/render/diff ownership).** The renderer additionally owns 570 + transition interpolation. Interpolated values feed into the existing layout 571 + and diff pipeline at the same pipeline stage that resolved values would. 572 + 573 + The "Deferred/Future Areas" section of the renderer specification should be 574 + updated to reference this specification rather than list transitions as a single 575 + 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 ··· 51 61 /* error collection */ 52 62 Clay_ErrorData errors[MAX_ERRORS]; 53 63 int error_count; 64 + int animating_count; 54 65 }; 55 66 56 67 /* Memory layout inside the arena provided by the host: ··· 467 478 return ct; 468 479 } 469 480 470 - void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row) { 481 + void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, 482 + float deltaTime) { 471 483 int i = 0; 484 + ct_active_context = ct; 472 485 ct->error_count = 0; 486 + ct->animating_count = 0; 473 487 474 488 Clay_BeginLayout(); 475 489 ··· 555 569 decl.floating.zIndex = (int16_t)((fc >> 24) & 0xff); 556 570 } 557 571 572 + if (mask & PROP_TRANSITION) { 573 + float duration = rdf(buf, len, &i); 574 + uint32_t props_and_flags = rd(buf, len, &i); 575 + uint16_t props = props_and_flags & 0xFFFF; 576 + uint8_t easing = (props_and_flags >> 16) & 0xFF; 577 + uint8_t interactive = (props_and_flags >> 24) & 0xFF; 578 + 579 + decl.transition.handler = ct_handler_for(easing); 580 + decl.transition.duration = duration; 581 + decl.transition.properties = (Clay_TransitionProperty)props; 582 + decl.transition.interactionHandling = 583 + interactive 584 + ? CLAY_TRANSITION_ALLOW_INTERACTIONS_WHILE_TRANSITIONING_POSITION 585 + : CLAY_TRANSITION_DISABLE_INTERACTIONS_WHILE_TRANSITIONING_POSITION; 586 + } 587 + 558 588 Clay__ConfigureOpenElement(decl); 559 589 break; 560 590 } ··· 577 607 /* attrs byte -> alpha channel for render_text to extract */ 578 608 config.textColor.a = (float)((cfg >> 24) & 0xff); 579 609 580 - Clay__OpenTextElement(text, Clay__StoreTextElementConfig(config)); 610 + Clay__OpenTextElement(text, config); 581 611 break; 582 612 } 583 613 ··· 590 620 } 591 621 } 592 622 593 - Clay_RenderCommandArray cmds = Clay_EndLayout(); 623 + Clay_RenderCommandArray cmds = Clay_EndLayout(deltaTime); 594 624 595 625 /* reset output state */ 596 626 ct->out.length = 0; ··· 638 668 } else { 639 669 present_cups(ct, row); 640 670 } 671 + 672 + ct_active_context = NULL; 641 673 } 642 674 643 675 char *output(struct Clayterm *ct) { return ct->out.data; } 644 676 645 677 int length(struct Clayterm *ct) { return ct->out.length; } 678 + 679 + int animating(struct Clayterm *ct) { return ct->animating_count; } 646 680 647 681 int get_element_bounds(const char *name, int name_len, float *out) { 648 682 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
··· 20 20 memory: WebAssembly.Memory; 21 21 statePtr: number; 22 22 opsBuf: number; 23 - reduce(ct: number, buf: number, len: number, mode: number, row: number): void; 23 + reduce( 24 + ct: number, 25 + buf: number, 26 + len: number, 27 + mode: number, 28 + row: number, 29 + deltaTime: number, 30 + ): void; 24 31 output(ct: number): number; 25 32 length(ct: number): number; 26 33 setPointer(x: number, y: number, down: boolean): void; 27 34 getPointerOverIds(): string[]; 28 35 getElementBounds(id: string): BoundingBox | undefined; 36 + animating(ct: number): number; 29 37 errorCount(ct: number): number; 30 38 errorType(ct: number, index: number): number; 31 39 errorMessage(ct: number, index: number): string; ··· 75 83 len: number, 76 84 mode: number, 77 85 row: number, 86 + deltaTime: number, 78 87 ): void; 79 88 output(ct: number): number; 80 89 length(ct: number): number; ··· 83 92 pointer_over_id_string_length(index: number): number; 84 93 pointer_over_id_string_ptr(index: number): number; 85 94 get_element_bounds(name: number, len: number, out: number): number; 95 + animating(ct: number): number; 86 96 error_count(ct: number): number; 87 97 error_type(ct: number, index: number): number; 88 98 error_message_length(ct: number, index: number): number; ··· 110 120 reduce: ct.reduce, 111 121 output: ct.output, 112 122 length: ct.length, 123 + animating: ct.animating as Native["animating"], 113 124 setPointer(x: number, y: number, down: boolean) { 114 125 let view = new DataView(memory.buffer); 115 126 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 = ··· 64 65 events: PointerEvent[]; 65 66 info: RenderInfo; 66 67 errors: ClayError[]; 68 + animating: boolean; 67 69 } 68 70 69 71 export interface Term { ··· 78 80 let prev = new Set<string>(); 79 81 let pressed = new Set<string>(); 80 82 let wasDown = false; 83 + let lastRenderAt: number | undefined; 84 + let wasAnimating = false; 81 85 82 86 return { 83 87 render(ops: Op[], options?: RenderOptions): RenderResult { 84 88 let len = pack(ops, memory.buffer, opsBuf, memory.buffer.byteLength); 85 89 let mode = options?.mode === "line" ? 1 : 0; 86 90 let row = options?.row ?? 1; 87 - native.reduce(statePtr, opsBuf, len, mode, row); 91 + let now = performance.now() / 1000; 92 + let dt: number; 93 + if (options?.deltaTime !== undefined) { 94 + dt = options.deltaTime; 95 + } else if (!wasAnimating || lastRenderAt === undefined) { 96 + dt = 0; 97 + } else { 98 + dt = now - lastRenderAt; 99 + } 100 + lastRenderAt = now; 101 + native.reduce(statePtr, opsBuf, len, mode, row, dt); 88 102 89 103 if (options?.pointer) { 90 104 let { x, y, down } = options.pointer; ··· 152 166 }); 153 167 } 154 168 155 - return { output, events, info, errors }; 169 + let animating = native.animating(statePtr) > 0; 170 + wasAnimating = animating; 171 + return { output, events, info, errors, animating }; 156 172 }, 157 173 }; 158 174 }
+42
test/transitions-pack.test.ts
··· 1 + import { describe, expect, it } from "./suite.ts"; 2 + import { close, open, pack } from "../mod.ts"; 3 + 4 + describe("pack transition", () => { 5 + it("encodes a transition without throwing", () => { 6 + let mem = new ArrayBuffer(4096); 7 + let len = pack( 8 + [ 9 + open("a", { 10 + transition: { 11 + duration: 0.2, 12 + easing: "easeOut", 13 + properties: ["x", "bg"], 14 + }, 15 + }), 16 + close(), 17 + ], 18 + mem, 19 + 0, 20 + 4096, 21 + ); 22 + expect(len).toBeGreaterThan(0); 23 + }); 24 + 25 + it("writes a longer buffer when a transition is present", () => { 26 + let mem1 = new ArrayBuffer(4096); 27 + let withoutLen = pack([open("a", {}), close()], mem1, 0, 4096); 28 + let mem2 = new ArrayBuffer(4096); 29 + let withLen = pack( 30 + [ 31 + open("a", { transition: { duration: 0.2, properties: ["x"] } }), 32 + close(), 33 + ], 34 + mem2, 35 + 0, 36 + 4096, 37 + ); 38 + expect(withLen).toBeGreaterThan(withoutLen); 39 + // The transition block is exactly 8 bytes = 2 words. 40 + expect(withLen - withoutLen).toBe(2); 41 + }); 42 + });
+66
test/transitions-run.test.ts
··· 1 + import { describe, expect, it } from "./suite.ts"; 2 + import { close, createTerm, fixed, type Op, open, rgba } from "../mod.ts"; 3 + 4 + describe("transition lifecycle", () => { 5 + it("animates bg change between frames", async () => { 6 + let term = await createTerm({ width: 20, height: 5 }); 7 + let frame = (bg: number): Op[] => [ 8 + open("box", { 9 + layout: { width: fixed(10), height: fixed(3) }, 10 + bg, 11 + transition: { duration: 0.2, easing: "easeInOut", properties: ["bg"] }, 12 + }), 13 + close(), 14 + ]; 15 + 16 + let r0 = term.render(frame(rgba(255, 0, 0)), { deltaTime: 0 }); 17 + expect(r0.animating).toBe(false); 18 + 19 + term.render(frame(rgba(0, 0, 255)), { deltaTime: 0 }); 20 + let mid = term.render(frame(rgba(0, 0, 255)), { deltaTime: 0.1 }); 21 + expect(mid.animating).toBe(true); 22 + 23 + term.render(frame(rgba(0, 0, 255)), { deltaTime: 0.15 }); 24 + let done = term.render(frame(rgba(0, 0, 255)), { deltaTime: 0.05 }); 25 + expect(done.animating).toBe(false); 26 + }); 27 + 28 + it("reports animating=false when duration is 0", async () => { 29 + let term = await createTerm({ width: 10, height: 3 }); 30 + let frame = (bg: number): Op[] => [ 31 + open("box", { 32 + layout: { width: fixed(5), height: fixed(2) }, 33 + bg, 34 + transition: { duration: 0, properties: ["bg"] }, 35 + }), 36 + close(), 37 + ]; 38 + 39 + term.render(frame(rgba(255, 0, 0)), { deltaTime: 0 }); 40 + let r = term.render(frame(rgba(0, 0, 255)), { deltaTime: 0.1 }); 41 + expect(r.animating).toBe(false); 42 + }); 43 + }); 44 + 45 + describe("transitions in line mode", () => { 46 + it("runs color transitions in line mode", async () => { 47 + let term = await createTerm({ width: 20, height: 5 }); 48 + let frame = (bg: number): Op[] => [ 49 + open("box", { 50 + layout: { width: fixed(10), height: fixed(2) }, 51 + bg, 52 + transition: { duration: 0.2, properties: ["bg"] }, 53 + }), 54 + close(), 55 + ]; 56 + 57 + term.render(frame(rgba(255, 0, 0)), { deltaTime: 0, mode: "line" }); 58 + term.render(frame(rgba(0, 255, 0)), { deltaTime: 0, mode: "line" }); 59 + let r = term.render(frame(rgba(0, 255, 0)), { 60 + deltaTime: 0.1, 61 + mode: "line", 62 + }); 63 + expect(r.animating).toBe(true); 64 + expect(r.output).toBeInstanceOf(Uint8Array); 65 + }); 66 + });
+26
test/transitions.test.ts
··· 1 + import { describe, expect, it } from "./suite.ts"; 2 + import { close, createTerm, grow, open, text } from "../mod.ts"; 3 + 4 + describe("deltaTime", () => { 5 + it("accepts explicit deltaTime without throwing", async () => { 6 + let term = await createTerm({ width: 40, height: 10 }); 7 + let result = term.render([ 8 + open("root", { layout: { width: grow(), height: grow() } }), 9 + text("hi"), 10 + close(), 11 + ], { deltaTime: 0.016 }); 12 + expect(result.output).toBeInstanceOf(Uint8Array); 13 + }); 14 + }); 15 + 16 + describe("animating", () => { 17 + it("reports animating=false for a static frame", async () => { 18 + let term = await createTerm({ width: 40, height: 10 }); 19 + let result = term.render([ 20 + open("root", { layout: { width: grow(), height: grow() } }), 21 + text("hi"), 22 + close(), 23 + ]); 24 + expect(result.animating).toBe(false); 25 + }); 26 + });