[READ-ONLY] Mirror of https://github.com/bombshell-dev/tty. Platform independent 2D layout engine for terminal applications based on Clay bomb.sh/docs/tty
Something went wrong. Try again.
10.0 kB · 430 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431/** * 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<string>; pointer: { x: number; y: number; down: boolean } | undefined;}
function fisherYates<T>(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<void, void> { return resource(function* (provide) { let ch = createChannel<void, void>(); 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, B, TClose>( a: Stream<A, TClose>, b: Stream<B, TClose>,): Stream<A | B, TClose> { 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<T, TClose>(stream: Stream<T, TClose>) { 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<PointerEvent, void>(); 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(); }});