/** * Clay-transitions demo — a port of the raylib-transitions example to clayterm. * * A grid of colored boxes that animate position, size, and background color. * Press 's' to shuffle (animates position), 'c' to recolor (animates bg). * Hover any box to see a bg-tint transition on mouse over. * Press 'q' or Ctrl+C to quit. * * Omits enter/exit transitions and "Add Box" (v1 constraints). * Overlay-color field is not yet in the v1 command buffer; hover tint is * achieved by blending the bg color toward a highlight shade instead. */ import { createChannel, each, ensure, main, race, resource, sleep, spawn, type Stream, until, } from "effection"; import { close, createTerm, fixed, grow, type InputEvent, type Op, open, type PointerEvent, rgba, text, } from "../../mod.ts"; import { alternateBuffer, cursor, mouseTracking, settings, } from "../../settings.ts"; import { useInput } from "../use-input.ts"; import { useStdin } from "../use-stdin.ts"; const DEFAULT_PALETTE = [ rgba(225, 138, 50), rgba(111, 173, 162), rgba(184, 87, 134), rgba(87, 134, 184), rgba(134, 184, 87), rgba(184, 134, 87), rgba(87, 184, 134), rgba(134, 87, 184), rgba(200, 100, 100), rgba(100, 200, 100), rgba(100, 100, 200), rgba(200, 200, 100), rgba(200, 100, 200), rgba(100, 200, 200), rgba(180, 160, 80), rgba(80, 160, 180), ]; const PINK_PALETTE = DEFAULT_PALETTE.map((c) => { let r = (c >> 24) & 0xff; let g = (c >> 16) & 0xff; let b = (c >> 8) & 0xff; let a = c & 0xff; let pr = Math.min(255, r + 80); let pg = Math.max(0, g - 60); let pb = Math.max(0, Math.min(255, b + 40)); return rgba(pr, pg, pb, a); }); // Blend a packed rgba color toward white by ratio [0,1]. function lighten(color: number, ratio: number): number { let r = (color >> 24) & 0xff; let g = (color >> 16) & 0xff; let b = (color >> 8) & 0xff; let a = color & 0xff; return rgba( Math.round(r + (255 - r) * ratio), Math.round(g + (255 - g) * ratio), Math.round(b + (255 - b) * ratio), a, ); } // Lighten ratio applied to bg when box is hovered (blends toward white). const HOVER_LIGHTEN = 0.35; const ROOT_BG = rgba(18, 18, 22); const TOPBAR_BG = rgba(40, 40, 55); const BTN_DEFAULT = rgba(60, 60, 80); const BTN_HOVER = rgba(90, 90, 120); const KEY_COLOR = rgba(255, 220, 120); const LABEL_COLOR = rgba(200, 200, 220); const COLS = 4; interface Box { id: number; color: number; } interface State { boxes: Box[]; palette: "default" | "pink"; entered: Set; pointer: { x: number; y: number; down: boolean } | undefined; } function fisherYates(arr: T[]): T[] { let out = arr.slice(); for (let i = out.length - 1; i > 0; i--) { let j = Math.floor(Math.random() * (i + 1)); let tmp = out[i]; out[i] = out[j]; out[j] = tmp; } return out; } function recolor(boxes: Box[], palette: "default" | "pink"): Box[] { let pal = palette === "pink" ? PINK_PALETTE : DEFAULT_PALETTE; return boxes.map((b, i) => ({ ...b, color: pal[i % pal.length] })); } function button( id: string, label: string, hovered: boolean, key: string, ): Op[] { return [ open(id, { layout: { direction: "ltr", padding: { left: 2, right: 2, top: 0, bottom: 0 }, alignX: "center", alignY: "center", height: grow(), }, bg: hovered ? BTN_HOVER : BTN_DEFAULT, border: hovered ? { color: KEY_COLOR, left: 1, right: 1, top: 1, bottom: 1 } : undefined, }), text(key, { color: KEY_COLOR }), text(` ${label}`, { color: LABEL_COLOR }), close(), ]; } function view(state: State): Op[] { let ops: Op[] = []; ops.push( open("root", { layout: { width: grow(), height: grow(), direction: "ttb" }, bg: ROOT_BG, }), ); ops.push( open("topbar", { layout: { width: grow(), height: fixed(3), direction: "ltr", padding: { left: 2, right: 2, top: 0, bottom: 0 }, gap: 2, alignY: "center", }, bg: TOPBAR_BG, }), ); ops.push( ...button( "btn:shuffle", "shuffle", state.entered.has("btn:shuffle"), "s", ), ...button( "btn:recolor", "recolor", state.entered.has("btn:recolor"), "c", ), ...button("btn:quit", "quit", state.entered.has("btn:quit"), "q"), ); ops.push(close()); ops.push( open("grid", { layout: { width: grow(), height: grow(), direction: "ttb", padding: { left: 1, right: 1, top: 1, bottom: 1 }, gap: 1, }, }), ); let boxes = state.boxes; let rows = Math.ceil(boxes.length / COLS); for (let r = 0; r < rows; r++) { ops.push( open(`row:${r}`, { layout: { width: grow(), height: grow(), direction: "ltr", gap: 1, }, }), ); for (let c = 0; c < COLS; c++) { let i = r * COLS + c; if (i >= boxes.length) { break; } let b = boxes[i]; let bid = `box:${b.id}`; let hov = state.entered.has(bid); let borderColor = hov ? lighten(b.color, HOVER_LIGHTEN) : b.color; ops.push( open(bid, { layout: { width: grow(), height: grow(), alignX: "center", alignY: "center", }, border: { color: borderColor, left: 1, right: 1, top: 1, bottom: 1, }, transition: { duration: 0.4, easing: "easeInOut", properties: ["width", "position", "borderColor"], interactive: true, }, }), text(`${b.id < 10 ? "0" : ""}${b.id}`, { color: b.color }), close(), ); } ops.push(close()); } ops.push(close()); ops.push(close()); return ops; } function ticker(flag: { animating: boolean }): Stream { return resource(function* (provide) { let ch = createChannel(); yield* spawn(function* () { while (true) { if (flag.animating) { yield* sleep(2); yield* ch.send(); } else { yield* sleep(50); } } }); let sub = yield* ch; yield* race([provide(sub), drain(ch)]); }); } function merge( a: Stream, b: Stream, ): Stream { return resource(function* (provide) { let sub = { a: yield* a, b: yield* b, }; return yield* provide({ *next() { return yield* race([sub.a.next(), sub.b.next()]); }, }); }); } function* drain(stream: Stream) { for (let _ of yield* each(stream)) { yield* each.next(); } } await main(function* () { let { columns, rows } = Deno.stdout.isTerminal() ? Deno.consoleSize() : { columns: 80, rows: 24 }; Deno.stdin.setRaw(true); yield* ensure(() => Deno.stdin.setRaw(false)); let stdin = yield* useStdin(); let input = useInput(stdin); let term = yield* until(createTerm({ width: columns, height: rows })); let tty = settings(alternateBuffer(), cursor(false), mouseTracking()); Deno.stdout.writeSync(tty.apply); yield* ensure(() => { Deno.stdout.writeSync(tty.revert); }); let count = 16; let pal = DEFAULT_PALETTE; let initialBoxes: Box[] = Array.from({ length: count }, (_, i) => ({ id: i, color: pal[i % pal.length], })); let state: State = { boxes: initialBoxes, palette: "default", entered: new Set(), pointer: undefined, }; let flag = { animating: false }; function draw(): void { let { output, animating, events } = term.render(view(state), { pointer: state.pointer, }); flag.animating = animating; for (let ev of events) { if (ev.type === "pointerenter") { state = { ...state, entered: new Set([...state.entered, ev.id]) }; } else if (ev.type === "pointerleave") { let next = new Set(state.entered); next.delete(ev.id); state = { ...state, entered: next }; } } Deno.stdout.writeSync(output); } draw(); let pointer = createChannel(); let ticks = ticker(flag); let events = merge(merge(input, pointer), ticks); for (let ev of yield* each(events)) { if (ev !== undefined && typeof ev === "object" && "type" in ev) { let e = ev as InputEvent | PointerEvent; if (e.type === "keydown") { if (e.ctrl && e.key === "c") { break; } if (e.key === "q") { break; } if (e.key === "s") { state = { ...state, boxes: fisherYates(state.boxes) }; } if (e.key === "c") { let next: "default" | "pink" = state.palette === "default" ? "pink" : "default"; state = { ...state, palette: next, boxes: recolor(state.boxes, next), }; } } if ("x" in e && "y" in e) { let me = e as { x: number; y: number; type: string }; state = { ...state, pointer: { x: me.x, y: me.y, down: me.type === "mousedown", }, }; } } let { output, animating, events: pevents } = term.render(view(state), { pointer: state.pointer, }); flag.animating = animating; for (let pev of pevents) { if (pev.type === "pointerenter") { state = { ...state, entered: new Set([...state.entered, pev.id]) }; } else if (pev.type === "pointerleave") { let next = new Set(state.entered); next.delete(pev.id); state = { ...state, entered: next }; } yield* pointer.send(pev); } Deno.stdout.writeSync(output); yield* each.next(); } });