From 8acdbff278c65253dae2a2124efcd7f9bd7c3e44 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Thu, 26 Mar 2026 11:54:44 -0500 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20add=20interactive=20keyboard=20demo?= =?UTF-8?q?=20with=20full=20104-key=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- demo/keyboard.ts | 604 ++++++++++++++++++++++++++++++++++++++++++++++ demo/use-input.ts | 65 +++++ demo/use-stdin.ts | 35 +++ deno.json | 6 +- deno.lock | 26 +- 5 files changed, 730 insertions(+), 6 deletions(-) create mode 100644 demo/keyboard.ts create mode 100644 demo/use-input.ts create mode 100644 demo/use-stdin.ts diff --git a/demo/keyboard.ts b/demo/keyboard.ts new file mode 100644 index 0000000..bb43b74 --- /dev/null +++ b/demo/keyboard.ts @@ -0,0 +1,604 @@ +import { each, ensure, main, until } from "effection"; +import { + close, + createTerm, + fixed, + grow, + type InputEvent, + type Op, + open, + rgba, + text, +} from "../mod.ts"; +import { useInput } from "./use-input.ts"; +import { useStdin } from "./use-stdin.ts"; + +let active = rgba(60, 120, 220); +let inactive = rgba(50, 50, 60); +let on = rgba(40, 180, 80); +let label = rgba(220, 220, 220); +let dim = rgba(100, 100, 120); +let highlight = rgba(255, 220, 80); + +let KEY_W = 5; +let GAP = 1; + +interface KeyDef { + label: string; + width?: number; + match: (event: InputEvent) => boolean; +} + +function is(char: string): (event: InputEvent) => boolean { + return (e) => + (e.type === "char" || e.type === "key") && + e.key.toUpperCase() === char.toUpperCase(); +} + +function mod(name: "ctrl" | "alt" | "shift"): (event: InputEvent) => boolean { + return (e) => (e.type === "char" || e.type === "key") && e[name] === true; +} + +function never(): boolean { + return false; +} + +function key(ops: Op[], k: KeyDef, ctx: AppContext): void { + let bg = ctx.event && k.match(ctx.event) ? active : inactive; + let w = k.width ?? KEY_W; + ops.push( + open("box", { + layout: { + width: fixed(w), + height: grow(), + padding: { left: 1, right: 1 }, + alignX: 2, + alignY: 2, + }, + bg, + }), + text(k.label, { color: label }), + close(), + ); +} + +function row(ops: Op[], keys: KeyDef[], ctx: AppContext): void { + ops.push( + open("box", { 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("box", { layout: { width: fixed(width), height: grow() } }), + close(), + ); +} + +function mainKeys(ops: Op[], ctx: AppContext): void { + ops.push( + open("box", { layout: { direction: "ttb", gap: GAP } }), + ); + + row(ops, [ + { label: "Esc", width: 11, match: is("Escape") }, + { label: "F1", match: is("F1") }, + { label: "F2", match: is("F2") }, + { label: "F3", match: is("F3") }, + { label: "F4", match: is("F4") }, + { label: "F5", match: is("F5") }, + { label: "F6", match: is("F6") }, + { label: "F7", match: is("F7") }, + { label: "F8", match: is("F8") }, + { label: "F9", match: is("F9") }, + { label: "F10", match: is("F10") }, + { label: "F11", match: is("F11") }, + { label: "F12", match: is("F12") }, + ], ctx); + + row(ops, [ + { label: "`", match: is("`") }, + { label: "1", match: is("1") }, + { label: "2", match: is("2") }, + { label: "3", match: is("3") }, + { label: "4", match: is("4") }, + { label: "5", match: is("5") }, + { label: "6", match: is("6") }, + { label: "7", match: is("7") }, + { label: "8", match: is("8") }, + { label: "9", match: is("9") }, + { label: "0", match: is("0") }, + { label: "-", match: is("-") }, + { label: "=", match: is("=") }, + { label: "Bksp", width: 9, match: is("Backspace") }, + ], ctx); + + row(ops, [ + { label: "Tab", width: 7, match: is("Tab") }, + { label: "Q", match: is("q") }, + { label: "W", match: is("w") }, + { label: "E", match: is("e") }, + { label: "R", match: is("r") }, + { label: "T", match: is("t") }, + { label: "Y", match: is("y") }, + { label: "U", match: is("u") }, + { label: "I", match: is("i") }, + { label: "O", match: is("o") }, + { label: "P", match: is("p") }, + { label: "[", match: is("[") }, + { label: "]", match: is("]") }, + { label: "\\", width: 7, match: is("\\") }, + ], ctx); + + row(ops, [ + { label: "Caps", width: 9, match: never }, + { label: "A", match: is("a") }, + { label: "S", match: is("s") }, + { label: "D", match: is("d") }, + { label: "F", match: is("f") }, + { label: "G", match: is("g") }, + { label: "H", match: is("h") }, + { label: "J", match: is("j") }, + { label: "K", match: is("k") }, + { label: "L", match: is("l") }, + { label: ";", match: is(";") }, + { label: "'", match: is("'") }, + { label: "Enter", width: 10, match: is("Enter") }, + ], ctx); + + row(ops, [ + { label: "Shift", width: 11, match: mod("shift") }, + { label: "Z", match: is("z") }, + { label: "X", match: is("x") }, + { label: "C", match: is("c") }, + { label: "V", match: is("v") }, + { label: "B", match: is("b") }, + { label: "N", match: is("n") }, + { label: "M", match: is("m") }, + { label: ",", match: is(",") }, + { label: ".", match: is(".") }, + { label: "/", match: is("/") }, + { label: "Shift", width: 13, match: mod("shift") }, + ], ctx); + + row(ops, [ + { label: "Ctrl", width: 7, match: mod("ctrl") }, + { label: "Win", width: 6, match: never }, + { label: "Alt", width: 6, match: mod("alt") }, + { label: "", width: 33, match: is(" ") }, + { label: "Alt", width: 6, match: mod("alt") }, + { label: "Win", width: 6, match: never }, + { label: "Menu", width: 6, match: never }, + { label: "Ctrl", width: 7, match: mod("ctrl") }, + ], ctx); + + ops.push(close()); +} + +function navKeys(ops: Op[], ctx: AppContext): void { + ops.push( + open("box", { layout: { direction: "ttb", gap: GAP } }), + ); + + // top section: Ins/Home/PgUp, Del/End/PgDn + row(ops, [ + { label: "Ins", width: 6, match: is("Insert") }, + { label: "Home", width: 6, match: is("Home") }, + { label: "PgUp", width: 6, match: is("PageUp") }, + ], ctx); + + row(ops, [ + { label: "Del", width: 6, match: is("Delete") }, + { label: "End", width: 6, match: is("End") }, + { label: "PgDn", width: 6, match: is("PageDown") }, + ], ctx); + + // gap before arrows + ops.push( + open("box", { layout: { height: fixed(3) } }), + close(), + ); + + // arrow up + ops.push( + open("box", { layout: { direction: "ltr", gap: GAP, height: fixed(3) } }), + ); + spacer(ops, 6); + key(ops, { label: "\u2191", width: 6, match: is("ArrowUp") }, ctx); + spacer(ops, 6); + ops.push(close()); + + // arrow left/down/right + row(ops, [ + { label: "\u2190", width: 6, match: is("ArrowLeft") }, + { label: "\u2193", width: 6, match: is("ArrowDown") }, + { label: "\u2192", width: 6, match: is("ArrowRight") }, + ], ctx); + + ops.push(close()); +} + +function numpad(ops: Op[], ctx: AppContext): void { + ops.push( + open("box", { layout: { direction: "ttb", gap: GAP } }), + ); + + row(ops, [ + { label: "Num", width: 6, match: never }, + { label: "/", width: 6, match: never }, + { label: "*", width: 6, match: never }, + { label: "-", width: 6, match: never }, + ], ctx); + + // rows 2-3 grouped horizontally so + spans both + ops.push( + open("box", { layout: { direction: "ltr", gap: GAP } }), + ); + + // left side: 7-8-9 and 4-5-6 stacked + ops.push( + open("box", { layout: { direction: "ttb", gap: GAP } }), + ); + row(ops, [ + { label: "7", width: 6, match: never }, + { label: "8", width: 6, match: never }, + { label: "9", width: 6, match: never }, + ], ctx); + row(ops, [ + { label: "4", width: 6, match: never }, + { label: "5", width: 6, match: never }, + { label: "6", width: 6, match: never }, + ], ctx); + ops.push(close()); + + // + spanning both rows + ops.push( + open("box", { + layout: { + width: fixed(6), + height: grow(), + padding: { left: 1, right: 1 }, + alignX: 2, + alignY: 2, + }, + bg: inactive, + }), + text("+", { color: label }), + close(), + ); + + ops.push(close()); + + // rows 4-5 grouped horizontally so Enter spans both + ops.push( + open("box", { layout: { direction: "ltr", gap: GAP } }), + ); + + // left side: 1-2-3 and 0-. stacked + ops.push( + open("box", { layout: { direction: "ttb", gap: GAP } }), + ); + row(ops, [ + { label: "1", width: 6, match: never }, + { label: "2", width: 6, match: never }, + { label: "3", width: 6, match: never }, + ], ctx); + row(ops, [ + { label: "0", width: 13, match: never }, + { label: ".", width: 6, match: never }, + ], ctx); + ops.push(close()); + + // Enter spanning both rows + ops.push( + open("box", { + layout: { + width: fixed(6), + height: grow(), + padding: { left: 1, right: 1 }, + alignX: 2, + alignY: 2, + }, + bg: inactive, + }), + text("Ent", { color: label }), + close(), + ); + + 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("box", { + layout: { + direction: "ltr", + height: fixed(1), + gap: 1, + }, + }), + text(indicator, { color: enabled ? on : dim }), + text(name, { color: enabled ? label : dim }), + close(), + ); +} + +let flagNames: (keyof Omit)[] = [ + "Disambiguate escape codes", + "Report event types", + "Report alternate keys", + "Report all keys as escapes", + "Report associated text", +]; + +function flagPanel(ops: Op[], ctx: AppContext): void { + let color = ctx.mode === "config" ? active : rgba(0, 0, 0, 0); + ops.push(open("box", { + layout: { + direction: "ttb", + gap: 1, + padding: { left: 1, right: 1, top: 1, bottom: 1 }, + }, + border: { color, left: 1, right: 1, top: 1, bottom: 1 }, + })); + + ops.push( + open("box", { layout: { height: fixed(1) } }), + text("Keyboard Protocol", { color: highlight }), + close(), + ); + + for (let i = 0; i < flagNames.length; i++) { + let name = flagNames[i]; + ops.push( + open("box", { 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()); +} + +function keyboard(ctx: AppContext): Op[] { + let ops: Op[] = []; + + // root + ops.push( + open("box", { + layout: { + width: grow(), + height: grow(), + direction: "ttb", + alignX: 2, + alignY: 2, + padding: { left: 2, top: 1 }, + }, + }), + ); + + // keyboard + toggles wrapper + ops.push( + open("box", { layout: { direction: "ttb" } }), + ); + + // mode badge + 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 [1-5], Escape to exit"; + ops.push( + open("box", { layout: { direction: "ltr", height: fixed(1), padding: { bottom: 1 } } }), + open("box", { layout: { padding: { left: 1, right: 1 } }, bg: rgba(60, 60, 60) }), + text("mode", { color: rgba(220, 220, 220) }), + close(), + open("box", { layout: { padding: { left: 1, right: 1 } }, bg: badgeBg }), + text(badgeLabel, { color: rgba(255, 255, 255) }), + close(), + text(` ${badgeHint}`, { color: dim }), + close(), + ); + + // toggles right-aligned above keyboard + ops.push( + open("box", { + layout: { + width: grow(), + direction: "ltr", + alignX: 1, + padding: { bottom: 1 }, + }, + }), + ); + flagPanel(ops, ctx); + ops.push(close()); + + // three keyboard groups side by side, bottom-aligned + let kbColor = ctx.mode === "input" ? active : rgba(0, 0, 0, 0); + ops.push( + open("box", { + layout: { + direction: "ltr", + gap: 3, + alignY: 1, + 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("box", { layout: { height: fixed(1), padding: { top: 1 } } }), + text(ctx.event ? JSON.stringify(ctx.event) : "Press any key...", { + color: highlight, + }), + close(), + ); + + ops.push(close()); + + return ops; +} + +let encoder = new TextEncoder(); +let esc = (s: string) => Deno.stdout.writeSync(encoder.encode(s)); + +function ttyFlags(ctx: AppContext): Uint8Array { + 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; + return encoder.encode(`\x1b[=${bits};3u`); +} + +await main(function* () { + let { columns, rows } = Deno.stdout.isTerminal() + ? Deno.consoleSize() + : { columns: 80, rows: 24 }; + + Deno.stdin.setRaw(true); + + let stdin = yield* useStdin(); + let input = useInput(stdin); + + let term = yield* until(createTerm({ width: columns, height: rows })); + + esc("\x1b[?1049h\x1b[?25l"); + yield* ensure(() => { + esc("\x1b[?25h\x1b[?1049l"); + }); + + let modality = recognizer(); + + let context = modality.next().value; + + Deno.stdout.writeSync(term.render(keyboard(context))); + + for (let event of yield* each(input)) { + if (event.type === "char" && event.ctrl && event.key === "c") { + break; + } + + context = modality.next(event).value; + + Deno.stdout.writeSync(ttyFlags(context)); + + Deno.stdout.writeSync(term.render(keyboard(context))); + + yield* each.next(); + } +}); + +function* recognizer(): Iterator { + let current: AppContext = { + mode: "input", + "Disambiguate escape codes": false, + "Report event types": false, + "Report alternate keys": false, + "Report all keys as escapes": false, + "Report associated text": false, + event: null, + }; + + let event: InputEvent = yield current; + + let mode = inputmode({ ...current, event }); + + while (true) { + mode = yield* mode; + } +} + +type Mode = Iterable; + +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 === "char" && event.key === "x" && event.ctrl) { + let next = yield context; + context = { + ...context, + event: next, + }; + if (next.type === "char" && next.key === "x" && next.ctrl) { + return configmode({ + ...context, + event: null, + }); + } + } + event = yield context; + } +} + +function* configmode(context: AppContext): Mode { + context = { ...context, mode: "config" }; + let event = yield context; + while (true) { + if (event.type === "key" && event.key === "Escape") { + return inputmode({...context, event: null }); + } + if (event.type === "char") { + switch (event.key) { + case "1": { + context = {...context, ["Disambiguate escape codes"]: !context["Disambiguate escape codes"]}; + break; + } + case "2": { + context = {...context, ["Report event types"]: !context["Report event types"]}; + break; + } + case "3": { + context = {...context, ["Report alternate keys"]: !context["Report alternate keys"]}; + break; + } + case "4": { + context = {...context, ["Report all keys as escapes"]: !context["Report all keys as escapes"]}; + break; + } + case "5": { + context = {...context, ["Report associated text"]: !context["Report associated text"]}; + break; + } + } + } + event = yield context; + } +} + + + +type AppContext = { + mode: "input" | "config"; + event: InputEvent | null; + ["Disambiguate escape codes"]: boolean; + ["Report event types"]: boolean; + ["Report alternate keys"]: boolean; + ["Report all keys as escapes"]: boolean; + ["Report associated text"]: boolean; +}; + diff --git a/demo/use-input.ts b/demo/use-input.ts new file mode 100644 index 0000000..73a4467 --- /dev/null +++ b/demo/use-input.ts @@ -0,0 +1,65 @@ +import { + call, + createChannel, + each, + type Operation, + race, + resource, + sleep, + spawn, + type Stream, + suspend, + until, +} from "effection"; +import { createInput, type InputEvent, type InputOptions } from "../mod.ts"; + +function nothing() { + return suspend() as unknown as Operation< + IteratorResult + >; +} + +export function useInput( + stream: Stream, + options?: InputOptions, +): Stream { + return resource(function* (provide) { + let input = yield* until(createInput(options)); + let subscription = yield* stream; + + let pending = nothing(); + + let events = createChannel(); + + yield* spawn(function* () { + let next = yield* subscription.next(); + while (!next.done) { + let result = input.scan(next.value); + pending = result.pending ? rescan(result.pending.delay) : nothing(); + for (let event of result.events) { + yield* events.send(event); + } + next = yield* race([subscription.next(), pending]); + } + yield* events.close(); + }); + + yield* race([provide(yield* events), drain(events)]); + }); +} + +function rescan(delay: number): ReturnType { + return call(function* (): Operation> { + yield* sleep(delay); + return { + done: false, + value: new Uint8Array(), + }; + }); +} + +function* drain(stream: Stream): Operation { + for (let _ of yield* each(stream)) { + yield* each.next(); + } +} diff --git a/demo/use-stdin.ts b/demo/use-stdin.ts new file mode 100644 index 0000000..fc55e5d --- /dev/null +++ b/demo/use-stdin.ts @@ -0,0 +1,35 @@ +import { + createChannel, + each, + type Operation, + race, + resource, + spawn, + type Stream, + until, +} from "effection"; + +export function useStdin(): Operation> { + return resource(function* (provide) { + let channel = createChannel(); + + let iterator = Deno.stdin.readable[Symbol.asyncIterator](); + + yield* spawn(function* () { + let next = yield* until(iterator.next()); + while (!next.done) { + yield* channel.send(next.value); + next = yield* until(iterator.next()); + } + yield* channel.close(); + }); + + yield* race([provide(channel), drain(channel)]); + }); +} + +function* drain(stream: Stream): Operation { + for (let _ of yield* each(stream)) { + yield* each.next(); + } +} diff --git a/deno.json b/deno.json index 96c5be6..713bee6 100644 --- a/deno.json +++ b/deno.json @@ -6,13 +6,15 @@ "fmt": "deno fmt && clang-format -i src/*.c src/*.h", "fmt:check": "deno fmt --check && clang-format --dry-run --Werror src/*.c src/*.h", "build:npm": "deno run -A tasks/build-npm.ts", - "build:jsr": "deno run -A tasks/build-jsr.ts" + "build:jsr": "deno run -A tasks/build-jsr.ts", + "demo": "deno run -A demo/keyboard.ts" }, "imports": { "@std/testing": "jsr:@std/testing@1", "@std/expect": "jsr:@std/expect@1", "@sinclair/typebox": "npm:@sinclair/typebox@^0.34", - "dnt": "jsr:@deno/dnt@0.42.3" + "dnt": "jsr:@deno/dnt@0.42.3", + "effection": "npm:effection@^4.0.2" }, "exports": { ".": "./mod.ts", diff --git a/deno.lock b/deno.lock index 63013ca..174fcc6 100644 --- a/deno.lock +++ b/deno.lock @@ -6,9 +6,12 @@ "jsr:@effection/effection@*": "4.0.2", "jsr:@std/assert@^1.0.14": "1.0.18", "jsr:@std/assert@^1.0.17": "1.0.18", + "jsr:@std/async@^1.1.0": "1.1.1", + "jsr:@std/data-structures@^1.0.10": "1.0.10", "jsr:@std/expect@1": "1.0.17", "jsr:@std/fmt@1": "1.0.8", "jsr:@std/fs@1": "1.0.22", + "jsr:@std/fs@^1.0.22": "1.0.22", "jsr:@std/internal@^1.0.10": "1.0.12", "jsr:@std/internal@^1.0.12": "1.0.12", "jsr:@std/path@1": "1.1.4", @@ -18,6 +21,7 @@ "jsr:@ts-morph/common@0.27": "0.27.0", "npm:@sinclair/typebox@*": "0.34.48", "npm:@sinclair/typebox@0.34": "0.34.48", + "npm:effection@^4.0.2": "4.0.2", "npm:valrs@*": "0.1.0" }, "jsr": { @@ -29,7 +33,7 @@ "dependencies": [ "jsr:@david/code-block-writer", "jsr:@std/fmt", - "jsr:@std/fs", + "jsr:@std/fs@1", "jsr:@std/path@1", "jsr:@ts-morph/bootstrap" ] @@ -43,6 +47,12 @@ "jsr:@std/internal@^1.0.12" ] }, + "@std/async@1.1.1": { + "integrity": "8a79beb3378cc229ce65ba2c746cfd03e4855ddd891d1eb6b9e32128e0d5339c" + }, + "@std/data-structures@1.0.10": { + "integrity": "f574f86b0e07c69b9edc555fcc814b57d29258bad39fd5a34ba8a80ecf033cfe" + }, "@std/expect@1.0.17": { "integrity": "316b47dd65c33e3151344eb3267bf42efba17d1415425f07ed96185d67fc04d9", "dependencies": [ @@ -73,7 +83,11 @@ "integrity": "87bdc2700fa98249d48a17cd72413352d3d3680dcfbdb64947fd0982d6bbf681", "dependencies": [ "jsr:@std/assert@^1.0.17", - "jsr:@std/internal@^1.0.12" + "jsr:@std/async", + "jsr:@std/data-structures", + "jsr:@std/fs@^1.0.22", + "jsr:@std/internal@^1.0.12", + "jsr:@std/path@^1.1.4" ] }, "@ts-morph/bootstrap@0.27.0": { @@ -85,7 +99,7 @@ "@ts-morph/common@0.27.0": { "integrity": "c7b73592d78ce8479b356fd4f3d6ec3c460d77753a8680ff196effea7a939052", "dependencies": [ - "jsr:@std/fs", + "jsr:@std/fs@1", "jsr:@std/path@1" ] } @@ -94,6 +108,9 @@ "@sinclair/typebox@0.34.48": { "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==" }, + "effection@4.0.2": { + "integrity": "sha512-O8WMGP10nPuJDwbNGILcaCNWS+CvDYjcdsUSD79nWZ+WtUQ8h1MEV7JJwCSZCSeKx8+TdEaZ/8r6qPTR2o/o8w==" + }, "valrs@0.1.0": { "integrity": "sha512-BqVkjx3qhsRLHerblLDoqEx0OEx7ms0DB6LPv40oWkMfFKUVKrqVuklaGdrPrHyubC5hSHYfEtUiQXrCkC6xHQ==" } @@ -103,7 +120,8 @@ "jsr:@deno/dnt@0.42.3", "jsr:@std/expect@1", "jsr:@std/testing@1", - "npm:@sinclair/typebox@0.34" + "npm:@sinclair/typebox@0.34", + "npm:effection@^4.0.2" ] } } -- 2.51.2