diff --git a/README.md b/README.md index 7e2fd70..4d55961 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,8 @@ for consumers. ### Examples -The application in this demo uses Clayterm for all layout and input parsing +See this keyboard example and more in the [examples folder](examples/README.md). +This demo uses Clayterm for all layout and input parsing. #### Keyboard Events diff --git a/examples/README.md b/examples/README.md index 212f7a0..9009edc 100644 --- a/examples/README.md +++ b/examples/README.md @@ -6,7 +6,8 @@ with information about your terminal, shell, operating system and any other information that could be pertinent to reproducing the issue. > [!NOTE] -> Run the commands in this document from the repository root. +> Run the commands in this document from the repository root. These examples use +> `node:` terminal APIs so the same files can be run with either Deno or Node. ## Prerequisites @@ -24,6 +25,8 @@ Run it with: ```sh deno run examples/keyboard/index.ts +# or +node examples/keyboard/index.ts ``` What it shows: @@ -40,6 +43,20 @@ Related files: events - `examples/keyboard/use-stdin.ts` adapts stdin into a byte stream for the demo +#### Keyboard Events + +The input parser decodes raw terminal bytes into structured events. Here you can +see each key event as the string "hello world" is typed. + +![Keyboard events demo](keyboard/keyboard-key-events.gif) + +#### Pointer Events + +Here we see hover styles applied to UI elements in response to the pointer +state. Clay drives the hit testing; no manual coordinate math required. + +![Pointer events demo](keyboard/keyboard-pointer-events.gif) + ## Inline Regions Path: `examples/inline-regions/index.ts` @@ -48,12 +65,15 @@ Run it with: ```sh deno run examples/inline-regions/index.ts +# or +node examples/inline-regions/index.ts ``` What it shows: - rendering animated regions into normal terminal scrollback -- querying cursor position with DSR to place later frames correctly +- querying cursor position with Device Status Report (DSR) to place later frames + correctly - updating a previously allocated region without taking over the whole screen - small animated demos including a spinner, a progress bar, and a nyan-cat-style sequence diff --git a/examples/inline-regions/index.ts b/examples/inline-regions/index.ts index 601dea8..6968ff4 100644 --- a/examples/inline-regions/index.ts +++ b/examples/inline-regions/index.ts @@ -3,12 +3,15 @@ * * Shows the region lifecycle: * 1. Allocate space with raw newlines - * 2. DSR — queries cursor position to compute `top` + * 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 { main, type Operation, sleep, until } from "effection"; +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, @@ -29,7 +32,7 @@ import { cursor, settings } from "../../settings.ts"; import { validated } from "../../validate.ts"; const encode = (s: string) => new TextEncoder().encode(s); -const write = (b: Uint8Array) => Deno.stdout.writeSync(b); +const write = (b: Uint8Array) => process.stdout.write(Buffer.from(b)); const GREEN = rgba(80, 250, 123); const GRAY = rgba(100, 100, 100); @@ -45,114 +48,19 @@ const RAINBOW = [RED, ORANGE, YELLOW, NGREEN, BLUE, VIOLET]; const BRAILLE = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; -function* queryCursor(): Operation { - let parser = yield* until(createInput({ escLatency: 100 })); - write(DSR()); - - let buf = new Uint8Array(32); - while (true) { - let n = Deno.stdin.readSync(buf); - if (n === null) continue; - let result = parser.scan(buf.subarray(0, n)); - for (let ev of result.events) { - if (ev.type === "cursor") { - return ev; - } - } - } -} - -function waitKey() { - let buf = new Uint8Array(32); - while (true) { - let n = Deno.stdin.readSync(buf); - if (n === null) continue; - for (let i = 0; i < n; i++) { - if (buf[i] === 0x03) { - Deno.stdin.setRaw(false); - write(SHOWCURSOR()); - Deno.exit(0); - } - } - return; - } -} - -function box(msg: string, fg: number, border: 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: 2, - }, - border: { - color: border, - 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 { - let { columns } = Deno.consoleSize(); - - 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() { - waitKey(); - write(encode("\n")); -} - await main(function* () { - let { columns } = Deno.consoleSize(); - Deno.stdin.setRaw(true); + 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'); @@ -338,6 +246,145 @@ await main(function* () { write(CSI("0m")); write(encode("\n")); - write(tty.revert); - Deno.stdin.setRaw(false); }); + +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 { + 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): Op[] { + return [ + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + }), + open("box", { + layout: { + width: grow(), + height: grow(), + direction: "ttb", + padding: { left: 1 }, + alignY: 2, + }, + border: { + color: border, + 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 { + 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")); +} diff --git a/examples/keyboard/index.ts b/examples/keyboard/index.ts index ef8fec8..bf7a7c8 100644 --- a/examples/keyboard/index.ts +++ b/examples/keyboard/index.ts @@ -1,4 +1,6 @@ // deno-lint-ignore-file no-fallthrough +import { Buffer } from "node:buffer"; +import process from "node:process"; import { createChannel, each, @@ -42,6 +44,160 @@ const highlight = rgba(255, 220, 80); const KEY_W = 5; const GAP = 1; +const hovered = rgba(80, 80, 100); + +const flagNames: + (keyof Omit)[] = + [ + "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(), + 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; @@ -58,8 +214,6 @@ function matches(k: KeyDef, event: InputEvent | PointerEvent): boolean { event.code.toUpperCase() === k.code.toUpperCase(); } -const hovered = rgba(80, 80, 100); - 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}`); @@ -328,30 +482,6 @@ function toggle(ops: Op[], enabled: boolean, name: string): void { ); } -const flagNames: - (keyof Omit)[] = - [ - "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" }, -]; - function logToggle( ops: Op[], entries: typeof logEntries, @@ -562,90 +692,6 @@ function ttyFlags(ctx: AppContext): Setting { return settings(...parts); } -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 })); - - let tty = settings(alternateBuffer(), cursor(false)); - Deno.stdout.writeSync(tty.apply); - - let modality = recognizer(); - let context = modality.next().value; - - let flags = ttyFlags(context); - Deno.stdout.writeSync(flags.apply); - - yield* ensure(() => { - Deno.stdout.writeSync(flags.revert); - Deno.stdout.writeSync(tty.revert); - }); - - let { output } = term.render(keyboard(context)); - - Deno.stdout.writeSync(output); - - let pointer = { - events: createChannel(), - 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 }; - } - - Deno.stdout.writeSync(flags.revert); - flags = ttyFlags(context); - Deno.stdout.writeSync(flags.apply); - - 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); - } - - Deno.stdout.writeSync(output); - - yield* each.next(); - } -}); - function* recognizer(): Iterator { let current: AppContext = { mode: "input", diff --git a/examples/keyboard/use-stdin.ts b/examples/keyboard/use-stdin.ts index fc55e5d..7150ed5 100644 --- a/examples/keyboard/use-stdin.ts +++ b/examples/keyboard/use-stdin.ts @@ -8,12 +8,12 @@ import { type Stream, until, } from "effection"; +import process from "node:process"; export function useStdin(): Operation> { return resource(function* (provide) { let channel = createChannel(); - - let iterator = Deno.stdin.readable[Symbol.asyncIterator](); + let iterator = process.stdin.iterator() as AsyncIterator; yield* spawn(function* () { let next = yield* until(iterator.next());