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

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

Charles Lowell (Apr 22, 2026, 9:08 PM -0500) 896435c5 53bc7233

+451
+451
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 MODELINE_BG = rgba(30, 30, 45); 97 + const BTN_DEFAULT = rgba(60, 60, 80); 98 + const BTN_HOVER = rgba(90, 90, 120); 99 + const KEY_COLOR = rgba(255, 220, 120); 100 + const LABEL_COLOR = rgba(200, 200, 220); 101 + 102 + const COLS = 4; 103 + 104 + interface Box { 105 + id: number; 106 + color: number; 107 + } 108 + 109 + interface State { 110 + boxes: Box[]; 111 + palette: "default" | "pink"; 112 + entered: Set<string>; 113 + pointer: { x: number; y: number; down: boolean } | undefined; 114 + } 115 + 116 + function fisherYates<T>(arr: T[]): T[] { 117 + let out = arr.slice(); 118 + for (let i = out.length - 1; i > 0; i--) { 119 + let j = Math.floor(Math.random() * (i + 1)); 120 + let tmp = out[i]; 121 + out[i] = out[j]; 122 + out[j] = tmp; 123 + } 124 + return out; 125 + } 126 + 127 + function recolor(boxes: Box[], palette: "default" | "pink"): Box[] { 128 + let pal = palette === "pink" ? PINK_PALETTE : DEFAULT_PALETTE; 129 + return boxes.map((b, i) => ({ ...b, color: pal[i % pal.length] })); 130 + } 131 + 132 + function button( 133 + id: string, 134 + label: string, 135 + hovered: boolean, 136 + key: string, 137 + ): Op[] { 138 + return [ 139 + open(id, { 140 + layout: { 141 + direction: "ltr", 142 + padding: { left: 2, right: 2, top: 0, bottom: 0 }, 143 + alignX: 2, 144 + alignY: 2, 145 + height: grow(), 146 + }, 147 + bg: hovered ? BTN_HOVER : BTN_DEFAULT, 148 + border: hovered 149 + ? { color: KEY_COLOR, left: 1, right: 1, top: 1, bottom: 1 } 150 + : undefined, 151 + }), 152 + text(key, { color: KEY_COLOR }), 153 + text(` ${label}`, { color: LABEL_COLOR }), 154 + close(), 155 + ]; 156 + } 157 + 158 + function view(state: State): Op[] { 159 + let ops: Op[] = []; 160 + 161 + ops.push( 162 + open("root", { 163 + layout: { width: grow(), height: grow(), direction: "ttb" }, 164 + bg: ROOT_BG, 165 + }), 166 + ); 167 + 168 + ops.push( 169 + open("topbar", { 170 + layout: { 171 + width: grow(), 172 + height: fixed(3), 173 + direction: "ltr", 174 + padding: { left: 2, right: 2, top: 0, bottom: 0 }, 175 + gap: 2, 176 + alignY: 2, 177 + }, 178 + bg: TOPBAR_BG, 179 + }), 180 + ); 181 + 182 + ops.push( 183 + ...button( 184 + "btn:shuffle", 185 + "shuffle", 186 + state.entered.has("btn:shuffle"), 187 + "s", 188 + ), 189 + ...button( 190 + "btn:recolor", 191 + "recolor", 192 + state.entered.has("btn:recolor"), 193 + "c", 194 + ), 195 + ...button("btn:quit", "quit", state.entered.has("btn:quit"), "q"), 196 + ); 197 + 198 + ops.push(close()); 199 + 200 + ops.push( 201 + open("grid", { 202 + layout: { 203 + width: grow(), 204 + height: grow(), 205 + direction: "ttb", 206 + padding: { left: 1, right: 1, top: 1, bottom: 1 }, 207 + gap: 1, 208 + }, 209 + }), 210 + ); 211 + 212 + let boxes = state.boxes; 213 + let rows = Math.ceil(boxes.length / COLS); 214 + 215 + for (let r = 0; r < rows; r++) { 216 + ops.push( 217 + open(`row:${r}`, { 218 + layout: { 219 + width: grow(), 220 + height: fixed(3), 221 + direction: "ltr", 222 + gap: 1, 223 + }, 224 + }), 225 + ); 226 + 227 + for (let c = 0; c < COLS; c++) { 228 + let i = r * COLS + c; 229 + if (i >= boxes.length) { 230 + break; 231 + } 232 + let b = boxes[i]; 233 + let bid = `box:${b.id}`; 234 + let hov = state.entered.has(bid); 235 + let bg = hov ? lighten(b.color, HOVER_LIGHTEN) : b.color; 236 + ops.push( 237 + open(bid, { 238 + layout: { 239 + width: grow(), 240 + height: grow(), 241 + alignX: 2, 242 + alignY: 2, 243 + }, 244 + bg, 245 + transition: { 246 + duration: 0.5, 247 + easing: "easeOut", 248 + properties: ["width", "position", "bg"], 249 + interactive: true, 250 + }, 251 + }), 252 + text(`${b.id < 10 ? "0" : ""}${b.id}`, { color: LABEL_COLOR }), 253 + close(), 254 + ); 255 + } 256 + 257 + ops.push(close()); 258 + } 259 + 260 + ops.push(close()); 261 + 262 + ops.push( 263 + open("modeline", { 264 + layout: { 265 + width: grow(), 266 + height: fixed(1), 267 + direction: "ltr", 268 + padding: { left: 1, right: 1 }, 269 + gap: 2, 270 + }, 271 + bg: MODELINE_BG, 272 + }), 273 + open("ml:s", { layout: { direction: "ltr", gap: 0 } }), 274 + text("s", { color: KEY_COLOR }), 275 + text(" shuffle", { color: LABEL_COLOR }), 276 + close(), 277 + open("ml:c", { layout: { direction: "ltr", gap: 0 } }), 278 + text("c", { color: KEY_COLOR }), 279 + text(" recolor", { color: LABEL_COLOR }), 280 + close(), 281 + open("ml:q", { layout: { direction: "ltr", gap: 0 } }), 282 + text("q", { color: KEY_COLOR }), 283 + text(" quit", { color: LABEL_COLOR }), 284 + close(), 285 + close(), 286 + ); 287 + 288 + ops.push(close()); 289 + 290 + return ops; 291 + } 292 + 293 + function ticker(flag: { animating: boolean }): Stream<void, void> { 294 + return resource(function* (provide) { 295 + let ch = createChannel<void, void>(); 296 + yield* spawn(function* () { 297 + while (true) { 298 + if (flag.animating) { 299 + yield* sleep(16); 300 + yield* ch.send(); 301 + } else { 302 + yield* sleep(50); 303 + } 304 + } 305 + }); 306 + let sub = yield* ch; 307 + yield* race([provide(sub), drain(ch)]); 308 + }); 309 + } 310 + 311 + function merge<A, B, TClose>( 312 + a: Stream<A, TClose>, 313 + b: Stream<B, TClose>, 314 + ): Stream<A | B, TClose> { 315 + return resource(function* (provide) { 316 + let sub = { 317 + a: yield* a, 318 + b: yield* b, 319 + }; 320 + return yield* provide({ 321 + *next() { 322 + return yield* race([sub.a.next(), sub.b.next()]); 323 + }, 324 + }); 325 + }); 326 + } 327 + 328 + function* drain<T, TClose>(stream: Stream<T, TClose>) { 329 + for (let _ of yield* each(stream)) { 330 + yield* each.next(); 331 + } 332 + } 333 + 334 + await main(function* () { 335 + let { columns, rows } = Deno.stdout.isTerminal() 336 + ? Deno.consoleSize() 337 + : { columns: 80, rows: 24 }; 338 + 339 + Deno.stdin.setRaw(true); 340 + yield* ensure(() => Deno.stdin.setRaw(false)); 341 + 342 + let stdin = yield* useStdin(); 343 + let input = useInput(stdin); 344 + 345 + let term = yield* until(createTerm({ width: columns, height: rows })); 346 + 347 + let tty = settings(alternateBuffer(), cursor(false), mouseTracking()); 348 + Deno.stdout.writeSync(tty.apply); 349 + yield* ensure(() => { 350 + Deno.stdout.writeSync(tty.revert); 351 + }); 352 + 353 + let count = 16; 354 + let pal = DEFAULT_PALETTE; 355 + let initialBoxes: Box[] = Array.from({ length: count }, (_, i) => ({ 356 + id: i, 357 + color: pal[i % pal.length], 358 + })); 359 + 360 + let state: State = { 361 + boxes: initialBoxes, 362 + palette: "default", 363 + entered: new Set(), 364 + pointer: undefined, 365 + }; 366 + 367 + let flag = { animating: false }; 368 + 369 + function draw(): void { 370 + let { output, animating, events } = term.render(view(state), { 371 + pointer: state.pointer, 372 + }); 373 + flag.animating = animating; 374 + for (let ev of events) { 375 + if (ev.type === "pointerenter") { 376 + state = { ...state, entered: new Set([...state.entered, ev.id]) }; 377 + } else if (ev.type === "pointerleave") { 378 + let next = new Set(state.entered); 379 + next.delete(ev.id); 380 + state = { ...state, entered: next }; 381 + } 382 + } 383 + Deno.stdout.writeSync(output); 384 + } 385 + 386 + draw(); 387 + 388 + let pointer = createChannel<PointerEvent, void>(); 389 + let ticks = ticker(flag); 390 + let events = merge(merge(input, pointer), ticks); 391 + 392 + for (let ev of yield* each(events)) { 393 + if (ev !== undefined && typeof ev === "object" && "type" in ev) { 394 + let e = ev as InputEvent | PointerEvent; 395 + 396 + if (e.type === "keydown") { 397 + if (e.ctrl && e.key === "c") { 398 + break; 399 + } 400 + if (e.key === "q") { 401 + break; 402 + } 403 + if (e.key === "s") { 404 + state = { ...state, boxes: fisherYates(state.boxes) }; 405 + } 406 + if (e.key === "c") { 407 + let next: "default" | "pink" = state.palette === "default" 408 + ? "pink" 409 + : "default"; 410 + state = { 411 + ...state, 412 + palette: next, 413 + boxes: recolor(state.boxes, next), 414 + }; 415 + } 416 + } 417 + 418 + if ("x" in e && "y" in e) { 419 + let me = e as { x: number; y: number; type: string }; 420 + state = { 421 + ...state, 422 + pointer: { 423 + x: me.x, 424 + y: me.y, 425 + down: me.type === "mousedown", 426 + }, 427 + }; 428 + } 429 + } 430 + 431 + let { output, animating, events: pevents } = term.render(view(state), { 432 + pointer: state.pointer, 433 + }); 434 + flag.animating = animating; 435 + 436 + for (let pev of pevents) { 437 + if (pev.type === "pointerenter") { 438 + state = { ...state, entered: new Set([...state.entered, pev.id]) }; 439 + } else if (pev.type === "pointerleave") { 440 + let next = new Set(state.entered); 441 + next.delete(pev.id); 442 + state = { ...state, entered: next }; 443 + } 444 + yield* pointer.send(pev); 445 + } 446 + 447 + Deno.stdout.writeSync(output); 448 + 449 + yield* each.next(); 450 + } 451 + });