/** * 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 { return resource(function* (provide) { let ch = createChannel(); 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: 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)); 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(); } });