[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.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853// deno-lint-ignore-file no-fallthroughimport { Buffer } from "node:buffer";import process from "node:process";import { createChannel, each, ensure, main, race, resource, type Stream, until,} from "effection";import { close, createTerm, fixed, grow, type InputEvent, type KeyEvent, type Op, open, type PointerEvent, rgba, text,} from "../../mod.ts";import { alternateBuffer, cursor, mouseTracking, progressiveInput, type Setting, settings,} from "../../settings.ts";import { useInput } from "../use-input.ts";import { useStdin } from "../use-stdin.ts";
const active = rgba(60, 120, 220);const inactive = rgba(50, 50, 60);const on = rgba(40, 180, 80);const label = rgba(220, 220, 220);const dim = rgba(100, 100, 120);const highlight = rgba(255, 220, 80);
const KEY_W = 5;const GAP = 1;const hovered = rgba(80, 80, 100);
const flagNames: (keyof Omit<AppContext, "mode" | "event" | "logged" | "log" | "entered">)[] = [ "Disambiguate escape codes", "Report event types", "Report alternate keys", "Report all keys as escapes", "Report associated text", ];
const logEntries: { key: string; name: keyof EventFilter }[] = [ { key: "a", name: "keydown" }, { key: "b", name: "keyup" }, { key: "c", name: "keyrepeat" }, { key: "d", name: "mousedown" }, { key: "e", name: "mouseup" }, { key: "f", name: "mousemove" }, { key: "g", name: "wheel" }, { key: "h", name: "resize" }, { key: "i", name: "pointerenter" }, { key: "j", name: "pointerleave" }, { key: "k", name: "pointerclick" },];
await main(function* () { let { columns, rows } = terminalSize();
setRawMode(true);
let stdin = yield* useStdin(); let input = useInput(stdin);
let term = yield* until(createTerm({ width: columns, height: rows }));
let tty = settings(alternateBuffer(), cursor(false)); writeStdout(tty.apply);
let modality = recognizer(); let context = modality.next().value;
let flags = ttyFlags(context); writeStdout(flags.apply);
yield* ensure(() => { setRawMode(false); writeStdout(flags.revert); writeStdout(tty.revert); });
let { output } = term.render(keyboard(context));
writeStdout(output);
let pointer = { events: createChannel<PointerEvent, void>(), state: undefined as { x: number; y: number; down: boolean } | undefined, };
for (let event of yield* each(merge(input, pointer.events))) { if (event.type === "keydown" && event.ctrl && event.key === "c") { break; } if (event.type === "pointerenter") { context.entered.add(event.id); } if (event.type === "pointerleave") { context.entered.delete(event.id); }
let prev = context.logged; context = modality.next(event).value; if (context.event && context.log[context.event.type as keyof EventFilter]) { context = { ...context, logged: context.event }; } else { context = { ...context, logged: prev }; }
flags = updateFlagsIfChanged(flags, ttyFlags(context));
if (context["Capture mouse events"]) { if ("x" in event) { pointer.state = { x: event.x, y: event.y, down: event.type === "mousedown", }; } } else { pointer.state = undefined; }
let { output, events } = term.render(keyboard(context), { pointer: pointer.state, });
for (let event of events) { yield* pointer.events.send(event); }
writeStdout(output);
yield* each.next(); }});
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 writeStdout(bytes: Uint8Array): void { process.stdout.write(Buffer.from(bytes));}
function equalBytes(a: Uint8Array, b: Uint8Array): boolean { if (a.length !== b.length) { return false; } for (let i = 0; i < a.length; i++) { if (a[i] !== b[i]) { return false; } } return true;}
function equalSetting(a: Setting, b: Setting): boolean { return equalBytes(a.apply, b.apply) && equalBytes(a.revert, b.revert);}
// Avoid rewriting terminal input modes on every mousemove. Deno's `node:` TTY// compatibility layer on Windows is sensitive to that churn even when the// effective settings are unchanged.function updateFlagsIfChanged(current: Setting, next: Setting): Setting { if (equalSetting(current, next)) { return current; }
writeStdout(current.revert); writeStdout(next.apply); return next;}
interface KeyDef { label: string; code: string; width?: number;}
function isKeyEvent(e: InputEvent | PointerEvent): e is KeyEvent { return e.type === "keydown" || e.type === "keyrepeat" || e.type === "keyup";}
function matches(k: KeyDef, event: InputEvent | PointerEvent): boolean { return isKeyEvent(event) && event.type === "keydown" && event.code.toUpperCase() === k.code.toUpperCase();}
function key(ops: Op[], k: KeyDef, ctx: AppContext): void { let pressed = ctx.event && matches(k, ctx.event); let hover = ctx.entered.has(`key:${k.code}`); let bg = pressed ? active : hover ? hovered : inactive; let w = k.width ?? KEY_W; ops.push( open(`key:${k.code}`, { layout: { width: fixed(w), height: grow(), padding: { left: 1, right: 1 }, alignX: "center", alignY: "center", }, bg, border: hover ? { color: highlight, left: 1, right: 1, top: 1, bottom: 1 } : undefined, }), text(k.label, { color: hover ? highlight : label }), close(), );}
function row(ops: Op[], keys: KeyDef[], ctx: AppContext): void { ops.push( open("", { layout: { direction: "ltr", gap: GAP, height: fixed(3) } }), ); for (let k of keys) { key(ops, k, ctx); } ops.push(close());}
function spacer(ops: Op[], width: number): void { ops.push( open("", { layout: { width: fixed(width), height: grow() } }), close(), );}
function mainKeys(ops: Op[], ctx: AppContext): void { ops.push( open("main-keys", { layout: { direction: "ttb", gap: GAP } }), );
row(ops, [ { label: "Esc", code: "Escape", width: 11 }, { label: "F1", code: "F1" }, { label: "F2", code: "F2" }, { label: "F3", code: "F3" }, { label: "F4", code: "F4" }, { label: "F5", code: "F5" }, { label: "F6", code: "F6" }, { label: "F7", code: "F7" }, { label: "F8", code: "F8" }, { label: "F9", code: "F9" }, { label: "F10", code: "F10" }, { label: "F11", code: "F11" }, { label: "F12", code: "F12" }, ], ctx);
row(ops, [ { label: "`", code: "`" }, { label: "1", code: "1" }, { label: "2", code: "2" }, { label: "3", code: "3" }, { label: "4", code: "4" }, { label: "5", code: "5" }, { label: "6", code: "6" }, { label: "7", code: "7" }, { label: "8", code: "8" }, { label: "9", code: "9" }, { label: "0", code: "0" }, { label: "-", code: "-" }, { label: "=", code: "=" }, { label: "Bksp", code: "Backspace", width: 9 }, ], ctx);
row(ops, [ { label: "Tab", code: "Tab", width: 7 }, { label: "Q", code: "q" }, { label: "W", code: "w" }, { label: "E", code: "e" }, { label: "R", code: "r" }, { label: "T", code: "t" }, { label: "Y", code: "y" }, { label: "U", code: "u" }, { label: "I", code: "i" }, { label: "O", code: "o" }, { label: "P", code: "p" }, { label: "[", code: "[" }, { label: "]", code: "]" }, { label: "\\", code: "\\", width: 7 }, ], ctx);
row(ops, [ { label: "Caps", code: "CapsLock", width: 9 }, { label: "A", code: "a" }, { label: "S", code: "s" }, { label: "D", code: "d" }, { label: "F", code: "f" }, { label: "G", code: "g" }, { label: "H", code: "h" }, { label: "J", code: "j" }, { label: "K", code: "k" }, { label: "L", code: "l" }, { label: ";", code: ";" }, { label: "'", code: "'" }, { label: "Enter", code: "Enter", width: 10 }, ], ctx);
row(ops, [ { label: "Shift", code: "ShiftLeft", width: 11 }, { label: "Z", code: "z" }, { label: "X", code: "x" }, { label: "C", code: "c" }, { label: "V", code: "v" }, { label: "B", code: "b" }, { label: "N", code: "n" }, { label: "M", code: "m" }, { label: ",", code: "," }, { label: ".", code: "." }, { label: "/", code: "/" }, { label: "Shift", code: "ShiftRight", width: 13 }, ], ctx);
row(ops, [ { label: "Ctrl", code: "ControlLeft", width: 7 }, { label: "Win", code: "SuperLeft", width: 6 }, { label: "Alt", code: "AltLeft", width: 6 }, { label: "", code: " ", width: 33 }, { label: "Alt", code: "AltRight", width: 6 }, { label: "Win", code: "SuperRight", width: 6 }, { label: "Menu", code: "Menu", width: 6 }, { label: "Ctrl", code: "ControlRight", width: 7 }, ], ctx);
ops.push(close());}
function navKeys(ops: Op[], ctx: AppContext): void { ops.push( open("nav-keys", { layout: { direction: "ttb", gap: GAP } }), );
// top section: Ins/Home/PgUp, Del/End/PgDn row(ops, [ { label: "Ins", code: "Insert", width: 6 }, { label: "Home", code: "Home", width: 6 }, { label: "PgUp", code: "PageUp", width: 6 }, ], ctx);
row(ops, [ { label: "Del", code: "Delete", width: 6 }, { label: "End", code: "End", width: 6 }, { label: "PgDn", code: "PageDown", width: 6 }, ], ctx);
// gap before arrows ops.push( open("", { layout: { height: fixed(3) } }), close(), );
// arrow up ops.push( open("", { layout: { direction: "ltr", gap: GAP, height: fixed(3) } }), ); spacer(ops, 6); key(ops, { label: "\u2191", code: "ArrowUp", width: 6 }, ctx); spacer(ops, 6); ops.push(close());
// arrow left/down/right row(ops, [ { label: "\u2190", code: "ArrowLeft", width: 6 }, { label: "\u2193", code: "ArrowDown", width: 6 }, { label: "\u2192", code: "ArrowRight", width: 6 }, ], ctx);
ops.push(close());}
function numpad(ops: Op[], ctx: AppContext): void { ops.push( open("numpad", { layout: { direction: "ttb", gap: GAP } }), );
row(ops, [ { label: "Num", code: "NumLock", width: 6 }, { label: "/", code: "NumpadDivide", width: 6 }, { label: "*", code: "NumpadMultiply", width: 6 }, { label: "-", code: "NumpadSubtract", width: 6 }, ], ctx);
// rows 2-3 grouped horizontally so + spans both ops.push( open("", { layout: { direction: "ltr", gap: GAP } }), );
// left side: 7-8-9 and 4-5-6 stacked ops.push( open("", { layout: { direction: "ttb", gap: GAP } }), ); row(ops, [ { label: "7", code: "Numpad7", width: 6 }, { label: "8", code: "Numpad8", width: 6 }, { label: "9", code: "Numpad9", width: 6 }, ], ctx); row(ops, [ { label: "4", code: "Numpad4", width: 6 }, { label: "5", code: "Numpad5", width: 6 }, { label: "6", code: "Numpad6", width: 6 }, ], ctx); ops.push(close());
// + spanning both rows key(ops, { label: "+", code: "NumpadAdd" }, ctx);
ops.push(close());
// rows 4-5 grouped horizontally so Enter spans both ops.push( open("", { layout: { direction: "ltr", gap: GAP } }), );
// left side: 1-2-3 and 0-. stacked ops.push( open("", { layout: { direction: "ttb", gap: GAP } }), ); row(ops, [ { label: "1", code: "Numpad1", width: 6 }, { label: "2", code: "Numpad2", width: 6 }, { label: "3", code: "Numpad3", width: 6 }, ], ctx); row(ops, [ { label: "0", code: "Numpad0", width: 13 }, { label: ".", code: "NumpadDecimal", width: 6 }, ], ctx); ops.push(close());
// Enter spanning both rows key(ops, { label: "Ent", code: "NumpadEnter" }, ctx);
ops.push(close());
ops.push(close());}
function toggle(ops: Op[], enabled: boolean, name: string): void { let indicator = enabled ? "\u25cf\u2500\u2500\u2500" : "\u2500\u2500\u2500\u25cb"; ops.push( open("", { layout: { direction: "ltr", height: fixed(1), gap: 1, }, }), text(indicator, { color: enabled ? on : dim }), text(name, { color: enabled ? label : dim }), close(), );}
function logToggle( ops: Op[], entries: typeof logEntries, ctx: AppContext,): void { for (let entry of entries) { ops.push( open(`log:${entry.name}`, { layout: { direction: "ltr", height: fixed(1), gap: 1 }, }), ); ops.push(text(`${entry.key}.`, { color: dim })); toggle(ops, ctx.log[entry.name], entry.name); ops.push(close()); }}
function configPanel(ops: Op[], ctx: AppContext): void { let color = ctx.mode === "config" ? active : rgba(0, 0, 0, 0); ops.push(open("config", { layout: { direction: "ltr", gap: 3, padding: { left: 1, right: 1, top: 1, bottom: 1 }, }, border: { color, left: 1, right: 1, top: 1, bottom: 1 }, }));
// keyboard protocol level column ops.push(open("protocol-level", { layout: { direction: "ttb", gap: 1 } })); ops.push( open("", { layout: { height: fixed(1) } }), text("Keyboard Protocol Level", { color: highlight }), close(), ); for (let i = 0; i < flagNames.length; i++) { let name = flagNames[i]; ops.push( open(`flag:${name}`, { layout: { direction: "ltr", height: fixed(1), gap: 1 }, }), ); ops.push(text(`${i + 1}.`, { color: dim })); toggle(ops, ctx[name], name); ops.push(close()); } ops.push(close());
// log events column 1 let col1 = logEntries.slice(0, 6); let col2 = logEntries.slice(6);
ops.push(open("log-events", { layout: { direction: "ttb", gap: 1 } })); ops.push( open("", { layout: { height: fixed(1) } }), text("Log Events", { color: highlight }), close(), ); logToggle(ops, col1, ctx); ops.push(close());
// log events column 2 ops.push(open("log-events-2", { layout: { direction: "ttb", gap: 1 } })); ops.push( open("", { layout: { height: fixed(1) } }), close(), ); logToggle(ops, col2, ctx); ops.push(close());
ops.push(close());}
function keyboard(ctx: AppContext): Op[] { let ops: Op[] = [];
// root ops.push( open("root", { layout: { width: grow(), height: grow(), direction: "ttb", alignX: "center", alignY: "center", padding: { left: 2, top: 1 }, }, }), );
// keyboard + toggles wrapper ops.push( open("", { layout: { direction: "ttb" } }), );
// badges + config row ops.push( open("", { layout: { width: grow(), direction: "ltr", alignY: "top", padding: { bottom: 1 }, }, }), );
// badges column (left, bottom-aligned) let badgeBg = ctx.mode === "input" ? rgba(40, 120, 200) : rgba(200, 120, 40); let badgeLabel = ctx.mode === "input" ? "input" : "config"; let badgeHint = ctx.mode === "input" ? "Ctrl+X Ctrl+X to enter config" : "Set flags with keys [0-5], Enter to save"; let mouseBg = ctx["Capture mouse events"] ? rgba(40, 180, 80) : rgba(80, 80, 80); let mouseLabel = ctx["Capture mouse events"] ? "capture" : "system"; ops.push( open("badges", { layout: { direction: "ttb", gap: 1, padding: { top: 1 } }, }), open("badge:mode", { layout: { direction: "ltr", height: fixed(1), padding: { bottom: 1 } }, }), open("", { layout: { padding: { left: 1, right: 1 } }, bg: rgba(60, 60, 60), }), text("mode", { color: rgba(220, 220, 220) }), close(), open("", { layout: { padding: { left: 1, right: 1 } }, bg: badgeBg }), text(badgeLabel, { color: rgba(255, 255, 255) }), close(), text(` ${badgeHint}`, { color: dim }), close(), open("badge:mouse", { layout: { direction: "ltr", height: fixed(1) } }), open("", { layout: { padding: { left: 1, right: 1 } }, bg: rgba(60, 60, 60), }), text("mouse", { color: rgba(220, 220, 220) }), close(), open("", { layout: { padding: { left: 1, right: 1 } }, bg: mouseBg }), text(mouseLabel, { color: rgba(255, 255, 255) }), close(), text(" Ctrl+X Ctrl+M to toggle", { color: dim }), close(), close(), );
// config panel (right) ops.push( open("", { layout: { width: grow(), direction: "ltr", alignX: "right" } }), ); configPanel(ops, ctx); ops.push(close());
ops.push(close()); // badges + config row
// three keyboard groups side by side, bottom-aligned let kbColor = ctx.mode === "input" ? active : rgba(0, 0, 0, 0); ops.push( open("keyboard", { layout: { direction: "ltr", gap: 3, alignY: "bottom", padding: { left: 1, right: 1, top: 1, bottom: 1 }, }, border: { color: kbColor, left: 1, right: 1, top: 1, bottom: 1 }, }), );
mainKeys(ops, ctx); navKeys(ops, ctx); numpad(ops, ctx);
ops.push(close());
ops.push(close()); // keyboard + toggles wrapper
// raw event display ops.push( open("event-log", { layout: { height: fixed(1), padding: { top: 1 } } }), text(ctx.logged ? JSON.stringify(ctx.logged) : "Press any key...", { color: highlight, }), close(), );
ops.push(close());
return ops;}
function ttyFlags(ctx: AppContext): Setting { let parts: Setting[] = []; let bits = 0; if (ctx["Disambiguate escape codes"]) bits |= 1; if (ctx["Report event types"]) bits |= 2; if (ctx["Report alternate keys"]) bits |= 4; if (ctx["Report all keys as escapes"]) bits |= 8; if (ctx["Report associated text"]) bits |= 16; parts.push(progressiveInput(bits)); if (ctx["Capture mouse events"]) { parts.push(mouseTracking()); } return settings(...parts);}
function* recognizer(): Iterator<AppContext, never, InputEvent | PointerEvent> { let current: AppContext = { mode: "input", "Disambiguate escape codes": true, "Report event types": true, "Report alternate keys": true, "Report all keys as escapes": true, "Report associated text": true, "Capture mouse events": true, log: { keydown: true, keyrepeat: false, keyup: false, mousedown: false, mouseup: false, mousemove: false, wheel: true, resize: true, pointerenter: false, pointerleave: false, pointerclick: true, }, entered: new Set(), event: null, logged: null, };
let event = yield current;
let mode = inputmode({ ...current, event });
while (true) { mode = yield* mode; }}
type Mode = Iterable<AppContext, Mode, InputEvent | PointerEvent>;
function* inputmode(context: AppContext): Mode { context = { ...context, mode: "input" }; let event = context.event ? context.event : yield context; while (true) { context = { ...context, event }; if (event.type === "keydown" && event.key === "x" && event.ctrl) { let next = yield context; while (next.type !== "keydown") { context = { ...context, event: next }; next = yield context; } context = { ...context, event: next }; if (next.key === "x" && next.ctrl) { return configmode({ ...context, event: null, }); } else if (next.key === "m" && next.ctrl) { context = { ...context, "Capture mouse events": !context["Capture mouse events"], event: null, }; event = yield context; continue; } } event = yield context; }}
function* configmode(context: AppContext): Mode { context = { ...context, mode: "config" }; let event = yield context; while (true) { if (event.type === "keydown" && event.key === "Enter") { return inputmode({ ...context, event: null }); } if (event.type === "keydown") { let k = (event as KeyEvent).key; let entry = logEntries.find((e) => e.key === k); if (entry) { context = { ...context, log: { ...context.log, [entry.name]: !context.log[entry.name] }, }; } if ("012345".indexOf(event.key) >= 0) { context = { ...context }; context["Report associated text"] = false; context["Report all keys as escapes"] = false; context["Report alternate keys"] = false; context["Report event types"] = false; context["Disambiguate escape codes"] = false; switch (event.key) { case "5": context["Report associated text"] = true; case "4": context["Report all keys as escapes"] = true; case "3": context["Report alternate keys"] = true; case "2": context["Report event types"] = true; case "1": context["Disambiguate escape codes"] = true; break; case "0": break; } } } event = yield context; }}
function merge<A, B, TClose>( a: Stream<A, TClose>, b: Stream<B, TClose>,): Stream<A | B, TClose> { return resource(function* (provide) { let subscription = { a: yield* a, b: yield* b, };
return yield* provide({ *next() { return yield* race([subscription.a.next(), subscription.b.next()]); }, }); });}
type EventFilter = { keydown: boolean; keyrepeat: boolean; keyup: boolean; mousedown: boolean; mouseup: boolean; mousemove: boolean; wheel: boolean; resize: boolean; pointerenter: boolean; pointerleave: boolean; pointerclick: boolean;};
type AppContext = { mode: "input" | "config"; event: InputEvent | PointerEvent | null; logged: InputEvent | PointerEvent | null; log: EventFilter; entered: Set<string>; ["Disambiguate escape codes"]: boolean; ["Report event types"]: boolean; ["Report alternate keys"]: boolean; ["Report all keys as escapes"]: boolean; ["Report associated text"]: boolean; ["Capture mouse events"]: boolean;};