[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.
6.7 kB · 288 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289/** * Interactive transitions demo — a sidebar that smoothly expands and collapses. * * Press Enter to open the menu sidebar, Esc to close it, q or Ctrl+C to quit. * Exercises v1 transitions: width + bg animated simultaneously. */
import { createChannel, each, ensure, main, race, resource, sleep, spawn, type Stream, until,} from "effection";import { close, createTerm, fixed, grow, type InputEvent, type Op, open, percent, rgba, text,} from "../../mod.ts";import { alternateBuffer, cursor, settings } from "../../settings.ts";import { useInput } from "../use-input.ts";import { useStdin } from "../use-stdin.ts";
const SIDEBAR_BG_OPEN = rgba(80, 80, 140);const SIDEBAR_BG_CLOSED = rgba(30, 30, 50);const CONTENT_BG = rgba(18, 18, 22);const MODELINE_BG = rgba(40, 40, 55);const TEXT = rgba(220, 220, 220);const DIM = rgba(130, 130, 150);const HEADING = rgba(255, 220, 120);const MENU_ITEM = rgba(180, 200, 240);const KEY_LABEL = rgba(255, 220, 120);
const MENU_ITEMS = [ "New file", "Open file…", "Save", "Save as…", "—", "Preferences", "Quit (q)",];
const BODY = [ { kind: "h1", text: "Lorem Ipsum" }, { kind: "p", text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", }, { kind: "p", text: "Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", }, { kind: "h2", text: "Section" }, { kind: "p", text: "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.", }, { kind: "p", text: "Duis aute irure dolor in reprehenderit in voluptate velit esse.", }, { kind: "p", text: "Excepteur sint occaecat cupidatat non proident, sunt in culpa qui.", },];
interface State { menuOpen: boolean;}
function view(state: State): Op[] { let ops: Op[] = [];
ops.push( open("root", { layout: { width: grow(), height: grow(), direction: "ttb" }, }), );
ops.push( open("main-row", { layout: { width: grow(), height: grow(), direction: "ltr" }, }), );
ops.push( open("sidebar", { layout: { width: state.menuOpen ? percent(0.2) : fixed(2), height: grow(), direction: "ttb", padding: { left: 1, right: 1, top: 1, bottom: 1 }, gap: 1, }, bg: state.menuOpen ? SIDEBAR_BG_OPEN : SIDEBAR_BG_CLOSED, clip: { horizontal: true }, transition: { duration: 0.2, easing: "easeInOut", properties: ["width", "bg"], }, }), );
if (state.menuOpen) { ops.push( open("menu-title", { layout: { height: fixed(1) } }), text("Menu", { color: HEADING, wrap: 2 }), close(), ); for (let item of MENU_ITEMS) { ops.push( open(`menu:${item}`, { layout: { height: fixed(1) } }), text(item, { color: item === "—" ? DIM : MENU_ITEM, wrap: 2 }), close(), ); } }
ops.push(close()); // sidebar
ops.push( open("content", { layout: { width: grow(), height: grow(), direction: "ttb", padding: { left: 3, right: 3, top: 1, bottom: 1 }, gap: 1, }, bg: CONTENT_BG, }), );
for (let { kind, text: t } of BODY) { ops.push(open(`body:${t.slice(0, 8)}`, { layout: { height: fixed(1) } })); let color = kind === "h1" ? HEADING : kind === "h2" ? KEY_LABEL : TEXT; ops.push(text(t, { color })); ops.push(close()); }
ops.push(close()); // content
ops.push(close()); // main-row
ops.push( open("modeline", { layout: { width: grow(), height: fixed(1), direction: "ltr", padding: { left: 1, right: 1 }, gap: 2, }, bg: MODELINE_BG, }), open("mod:quit", { layout: { direction: "ltr", gap: 0 } }), text("q", { color: KEY_LABEL }), text(" quit", { color: TEXT }), close(), open("mod:menu", { layout: { direction: "ltr", gap: 0 } }), text("enter", { color: KEY_LABEL }), text(" show menu", { color: TEXT }), close(), open("mod:hide", { layout: { direction: "ltr", gap: 0 } }), text("esc", { color: KEY_LABEL }), text(" hide menu", { color: TEXT }), close(), close(), // modeline );
ops.push(close()); // root
return ops;}
// A stream that emits at ~60fps intervals, but only while the shared flag is true.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(16); yield* ch.send(); } else { // Park until animating becomes true; check every 50ms. 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)); Deno.stdout.writeSync(tty.apply); yield* ensure(() => { Deno.stdout.writeSync(tty.revert); });
let state: State = { menuOpen: false }; let flag = { animating: false };
function draw(): void { let { output, animating } = term.render(view(state)); flag.animating = animating; Deno.stdout.writeSync(output); }
draw();
let ticks = ticker(flag); let events = merge(input, ticks);
for (let _ of yield* each(events)) { if (_ !== undefined && typeof _ === "object" && "type" in _) { let event = _ as InputEvent; if (event.type === "keydown") { if (event.ctrl && event.key === "c") { break; } if (event.key === "q") { break; } if (event.key === "Enter") { state = { ...state, menuOpen: true }; } if (event.key === "Escape") { state = { ...state, menuOpen: false }; } } } draw(); yield* each.next(); }});