[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.
11 kB · 419 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420/** * Inline region demo — renders animated regions into normal scrollback. * * Shows the region lifecycle: * 1. Allocate space with raw newlines * 2. Device Status Report (DSR) — queries cursor position to compute `top` * 3. CUP mode (all frames) — renders at `top` * 4. Commit — restore cursor past region, advance with \n */
import { Buffer } from "node:buffer";import { readSync } from "node:fs";import process from "node:process";import { ensure, main, type Operation, sleep, until } from "effection";import { close, createInput, createTerm, CSI, type CursorEvent, DSR, ESC, fixed, grow, type Op, open, rgba, SHOWCURSOR, text,} from "../../mod.ts";import { cursor, settings } from "../../settings.ts";import { validated } from "../../validate.ts";
const encode = (s: string) => new TextEncoder().encode(s);const write = (b: Uint8Array) => process.stdout.write(Buffer.from(b));
const WHITE = rgba(255, 255, 255);const GREEN = rgba(80, 250, 123);const GREEN_BG = rgba(20, 70, 38);const GRAY = rgba(100, 100, 100);const CYAN = rgba(139, 233, 253);
const RED = rgba(255, 0, 0);const ORANGE = rgba(255, 153, 0);const YELLOW = rgba(255, 255, 0);const NGREEN = rgba(51, 255, 0);const BLUE = rgba(0, 153, 255);const VIOLET = rgba(102, 0, 255);const RAINBOW = [RED, ORANGE, YELLOW, NGREEN, BLUE, VIOLET];
const BRAILLE = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
await main(function* () { let { columns } = terminalSize(); setRawMode(true); let tty = settings(cursor(false)); write(tty.apply);
yield* ensure(() => { // SGR reset sequence setRawMode(false); write(CSI("0m")); write(tty.revert); });
// Introduction say("Clayterm can render entire scenes, but it can also render"); say('"inline" for a streaming UI. This is useful for semi-interactive'); say("CLI commands that write output to the normal console screen."); say("");
// Demo 1: Spinner box write(encode("\n\n\n"));
let pos = yield* queryCursor(); /** 1-based terminal row where the region starts */ let row = pos.row - 2;
write(ESC("7"));
let frames = 30; let term = validated( yield* until(createTerm({ width: columns, height: 3 })), );
let first = term.render( box("Press any key to compile modules.", CYAN, GRAY), { row }, ); write(new Uint8Array(first.output));
waitKey();
for (let i = 0; i < frames; i++) { let done = i === frames - 1; let icon = done ? "✓" : BRAILLE[i % BRAILLE.length]; let time = `${((i + 1) * 0.08).toFixed(1)}s`; let label = done ? "Compiled modules" : "Compiling modules..."; let result = term.render( box( `${icon} ${label} ${time}`, done ? GREEN : CYAN, done ? GREEN : GRAY, ), { row }, ); write(new Uint8Array(result.output)); yield* sleep(80); }
write(ESC("8")); write(CSI("0m")); write(encode("\n"));
yield* sleep(500);
// Demo: border bg write(encode("\n\n\n"));
let bgPos = yield* queryCursor(); let bgRow = bgPos.row - 2; write(ESC("7"));
let bgTerm = validated( yield* until(createTerm({ width: columns, height: 3 })), );
let PURPLE_BG = rgba(80, 40, 120); let bgResult = bgTerm.render( box("Border backgrounds fill border cells.", WHITE, GREEN, PURPLE_BG), { row: bgRow }, ); write(new Uint8Array(bgResult.output));
waitKey();
write(ESC("8")); write(CSI("0m")); write(encode("\n"));
yield* sleep(200);
write( encode( "\nRegions can be multi-line, but they can be a single line too. (continue...)", ), ); pause();
// Demo 2: Progress bar let barWidth = Math.min(columns, 50); let barFrames = 40; yield* transaction( 1, (i) => { let done = i === barFrames - 1; if (done) { return [ open("root", { layout: { width: fixed(barWidth), height: fixed(1), direction: "ltr", }, }), text(" ✓ Frobnicated ", { color: WHITE, bg: GREEN_BG }), close(), ]; } let progress = i / (barFrames - 1); let label = "Frobnicating.. "; let remaining = barWidth - label.length - 5; let filled = Math.round(remaining * Math.min(progress, 1)); let empty = remaining - filled; let pct = `${Math.round(progress * 100)}%`; let bar = "█".repeat(filled) + "░".repeat(empty); return [ open("root", { layout: { width: fixed(barWidth), height: fixed(1), direction: "ltr", }, }), text(label, { color: CYAN }), text(bar, { color: CYAN }), text(` ${pct.padStart(4)}`, { color: GRAY }), close(), ]; }, barFrames, 50, );
write(CSI("0m")); yield* sleep(500); write(encode("\nGoodbye sadness with limitless sky. (continue...)")); pause();
// Demo 3: Nyan cat let nyanWidth = Math.min(columns, 120); let nyanFrames = 50; let cat = [ "╭─────╮", "│ ^.^ │", "╰─────╯", ]; let catWidth = cat[0].length;
yield* transaction( 3, (i) => { let done = i === nyanFrames - 1; let progress = i / (nyanFrames - 1); let trail = Math.round((nyanWidth - catWidth) * Math.min(progress, 1));
if (done) { // "IMAGINATION IS BEAUTIFUL WORLD!" in 3-row block font let font: string[] = [ "█ █▄█▄█ █▀█ █▀▀ █ █▀█ █▀█ ▀█▀ █ █▀█ █▀█ █ █▀▀ ██▄ █▀▀ █▀█ █ █ ▀█▀ █ █▀▀ █ █ █ █ █ █ █▀█ █▀█ █ █▀▄ █", "█ █ ▀ █ █▀█ █ █ █ █ █ █▀█ █ █ █ █ █ █ █ ▀▀█ █▀█ █▀▀ █▀█ █ █ █ █ █▀ █ █ █ █▄█▄█ █ █ █▀▄ █ █ █ ▀", "▀ ▀ ▀ ▀ ▀ ▀▀▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀▀▀ ▀ ▀ ▀ ▀▀ ▀▀ ▀▀▀ ▀ ▀ ▀▀▀ ▀ ▀ ▀ ▀▀▀ ▀▀▀ ▀ ▀ ▀▀▀ ▀ ▀ ▀▀▀ ▀▀ █", ]; let ops: Op[] = [ open("root", { layout: { width: fixed(nyanWidth), height: fixed(3), direction: "ttb", }, }), ]; for (let row = 0; row < 3; row++) { let color = RAINBOW[(row * 2) % RAINBOW.length]; ops.push(text(font[row], { color })); } ops.push(close()); return ops; }
let ops: Op[] = [ open("root", { layout: { width: fixed(nyanWidth), height: fixed(3), direction: "ttb", }, }), ];
for (let row = 0; row < 3; row++) { ops.push( open(`row${row}`, { layout: { width: grow(), height: fixed(1), direction: "ltr" }, }), );
if (trail > 0) { let color = RAINBOW[(row * 2 + i) % RAINBOW.length]; ops.push(text("█".repeat(trail), { color })); }
ops.push(text(cat[row], { color: CYAN }));
ops.push(close()); }
ops.push(close()); return ops; }, nyanFrames, 60, );
write(CSI("0m")); write(encode("\n"));});
function terminalSize(): { columns: number; rows: number } { return process.stdout.isTTY ? { columns: process.stdout.columns ?? 80, rows: process.stdout.rows ?? 24, } : { columns: 80, rows: 24 };}
function setRawMode(enabled: boolean): void { if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") { process.stdin.setRawMode(enabled); }}
function* queryCursor(): Operation<CursorEvent> { let parser = yield* until(createInput({ escLatency: 100 })); write(DSR());
let buf = Buffer.allocUnsafe(32); while (true) { let n: number; try { n = readSync(process.stdin.fd, buf, 0, buf.length, null); } catch (error) { if ( error && typeof error === "object" && ("code" in error && (error.code === "EAGAIN" || error.code === "EINTR")) ) { continue; } throw error; }
if (n === 0) continue; let result = parser.scan(buf.subarray(0, n)); for (let ev of result.events) { if (ev.type === "cursor") { return ev; } } }}
function waitKey(): void { let buf = Buffer.allocUnsafe(32); while (true) { let n: number; try { n = readSync(process.stdin.fd, buf, 0, buf.length, null); } catch (error) { if ( error && typeof error === "object" && ("code" in error && (error.code === "EAGAIN" || error.code === "EINTR")) ) { continue; } throw error; }
if (n === 0) continue; for (let i = 0; i < n; i++) { if (buf[i] === 0x03) { setRawMode(false); write(SHOWCURSOR()); process.exit(0); } } return; }}
function box(msg: string, fg: number, border: number, bg?: number): Op[] { return [ open("root", { layout: { width: grow(), height: grow(), direction: "ttb" }, }), open("box", { layout: { width: grow(), height: grow(), direction: "ttb", padding: { left: 1 }, alignY: "center", }, border: { color: border, bg, left: 1, right: 1, top: 1, bottom: 1, }, cornerRadius: { tl: 1, tr: 1, bl: 1, br: 1 }, }), text(msg, { color: fg }), close(), close(), ];}
function* transaction( height: number, renderFrame: (frame: number) => Op[], frames: number, interval: number,): Operation<void> { let { columns } = terminalSize();
write(encode("\n".repeat(height)));
let pos = yield* queryCursor(); /** 1-based terminal row where the region starts */ let row = pos.row - height + 1;
write(ESC("7")); let tty = settings(cursor(false)); write(tty.apply);
let term = validated( yield* until(createTerm({ width: columns, height })), ); for (let i = 0; i < frames; i++) { let result = term.render(renderFrame(i), { row }); write(new Uint8Array(result.output)); yield* sleep(interval); }
write(tty.revert); write(ESC("8")); write(encode("\n"));}
function say(msg: string) { write(encode(msg + "\n"));}
function pause(): void { waitKey(); write(encode("\n"));}