diff --git a/cli/InputManager.ts b/cli/InputManager.ts new file mode 100644 index 0000000..16a97c6 --- /dev/null +++ b/cli/InputManager.ts @@ -0,0 +1,196 @@ +interface EventMap { + keypress: CLIKeypressEvent; + char: CLICharEvent; + activate: Event; + deactivate: Event; + exit: Event; + enter: Event; + backspace: Event; + delete: Event; + "arrow-left": Event; + "arrow-right": Event; + "arrow-up": Event; + "arrow-down": Event; + [key: string]: Event; +} + +interface EventDetailMap { + keypress: { + key: number; + sequence?: Uint8Array; + }; + char: EventDetailMap["keypress"] & { + char: string; + }; +} + +export type TypedEventTarget = { + new (): IntermediateEventTarget; +}; + +export interface IntermediateEventTarget extends EventTarget { + addEventListener( + type: K, + listener: ( + event: EventMap[K] extends Event ? EventMap[K] : Event, + ) => EventMap[K] extends Event ? void : never, + options?: boolean | AddEventListenerOptions, + ): void; + + addEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions, + ): void; + + removeEventListener( + type: K, + listener: ( + event: EventMap[K] extends Event ? EventMap[K] : Event, + ) => EventMap[K] extends Event ? void : never, + options?: boolean | AddEventListenerOptions, + ): void; + + removeEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions, + ): void; +} + +const ManagerEventTarget = EventTarget as TypedEventTarget; + +export class CLICharEvent extends CustomEvent { + constructor(detail: EventDetailMap["char"]) { + super("char", { detail, cancelable: true }); + } +} +export class CLIKeypressEvent extends CustomEvent { + constructor(detail: EventDetailMap["keypress"]) { + super("keypress", { detail, cancelable: true }); + } +} + +export class InputManager extends ManagerEventTarget { + private static instance = new InputManager(); + private active = false; + + static getInstance(): InputManager { + return this.instance ??= new InputManager(); + } + + static addEventListener = InputManager.prototype.addEventListener.bind( + this.instance, + ); + static removeEventListener = InputManager.prototype.removeEventListener.bind( + this.instance, + ); + static dispatchEvent = InputManager.prototype.dispatchEvent.bind( + this.instance, + ); + + activate({ raw = true }: { raw?: boolean } = {}) { + if (this.active) return; + this.active = true; + this.dispatchEvent(new Event("activate")); + this.listen(raw); + } + + deactivate({ dactivateRaw = true }: { dactivateRaw?: boolean } = {}) { + if (!this.active) return; + this.active = false; + this.dispatchEvent(new Event("deactivate")); + if (dactivateRaw) Deno.stdin.setRaw(false); + } + + once(type: T): Promise { + return new Promise((resolve) => { + const handler = (event: Event) => { + this.removeEventListener(type, handler); + resolve(event); + }; + this.addEventListener(type, handler); + }); + } + + private async listen(raw: boolean) { + if (raw) await Deno.stdin.setRaw(true); + const buf = new Uint8Array(64); + + while (this.active) { + const n = await Deno.stdin.read(buf); + if (n === null) break; + + let i = 0; + while (i < n) { + const byte = buf[i]; + + // Ctrl+C + if (byte === 3) { + this.dispatchEvent(new Event("exit")); + await Deno.stdin.setRaw(false); + Deno.exit(130); + } + + // Enter + if (byte === 13) { + this.dispatchEvent(new Event("enter")); + i++; + continue; + } + + // Backspace + if (byte === 127 || byte === 8) { + this.dispatchEvent(new Event("backspace")); + i++; + continue; + } + + // Escape sequences + if (byte === 27 && i + 1 < n && buf[i + 1] === 91) { + const code = buf[i + 2]; + switch (code) { + case 65: + this.dispatchEvent(new Event("arrow-up")); + break; + case 66: + this.dispatchEvent(new Event("arrow-down")); + break; + case 67: + this.dispatchEvent(new Event("arrow-right")); + break; + case 68: + this.dispatchEvent(new Event("arrow-left")); + break; + case 51: + if (i + 3 < n && buf[i + 3] === 126) { + this.dispatchEvent(new Event("delete")); + i += 4; + continue; + } + break; + } + i += 3; + continue; + } + + // Printable ASCII + if (byte >= 32 && byte <= 126) { + this.dispatchEvent( + new CLICharEvent({ key: byte, char: String.fromCharCode(byte) }), + ); + i++; + continue; + } + + // Unknown + this.dispatchEvent( + new CLIKeypressEvent({ key: byte, sequence: buf.slice(i, i + 1) }), + ); + i++; + } + } + + if (raw) await Deno.stdin.setRaw(false); + } +} diff --git a/cli/prompts.ts b/cli/prompts.ts index 37486c3..d22b3f0 100644 --- a/cli/prompts.ts +++ b/cli/prompts.ts @@ -1,8 +1,117 @@ // deno-lint-disable-must-await-calls -import { log } from "util/logfile.ts"; import { Cursor } from "./cursor.ts"; import { colorize } from "./style.ts"; import { TerminalBlock, TerminalLayout } from "./TerminalLayout.ts"; +import { type CLICharEvent, InputManager } from "./InputManager.ts"; + +// export async function cliPrompt( +// message: string, +// block?: TerminalBlock, +// ): Promise { +// const encoder = new TextEncoder(); +// const input: string[] = []; +// let cursorPos = 0; + +// await Deno.stdin.setRaw(true); + +// const cursorVisible = Cursor["visible"]; +// Cursor.show(); + +// let range: [number, number] = [0, 1]; +// if (block) { +// range = block.setLines([message + " "]); +// } else { +// Deno.stdout.writeSync(encoder.encode(message + " ")); +// } + +// const render = () => { +// const line = message + " " + input.join(""); +// const moveTo = `\x1b[${message.length + 2 + cursorPos}G`; + +// if (block) { +// block.setPostRenderAction(function () { +// Deno.stdout.writeSync( +// encoder.encode(`\x1b[${this["lastRenderRow"]};1H`), +// ); +// Deno.stdout.writeSync(encoder.encode(moveTo)); +// }); +// range = block.setLines([line], range); +// } else { +// Deno.stdout.writeSync(encoder.encode("\x1b[K" + line + moveTo)); +// } +// }; + +// render(); + +// const buf = new Uint8Array(64); // large enough for most pastes +// inputLoop: +// while (true) { +// const n = await Deno.stdin.read(buf); +// if (n === null) break; + +// for (let i = 0; i < n; i++) { +// const byte = buf[i]; + +// // Ctrl+C +// if (byte === 3) { +// block?.clear(); +// block?.["layout"]?.clearAll(); +// await Deno.stdin.setRaw(false); +// Deno.exit(130); +// } + +// if (byte === 13) { // Enter +// break inputLoop; +// } + +// // Escape sequence? +// if (byte === 27 && i + 1 < n && buf[i + 1] === 91) { +// const third = buf[i + 2]; +// if (third === 68 && cursorPos > 0) cursorPos--; // Left +// else if (third === 67 && cursorPos < input.length) cursorPos++; // Right +// else if (third === 51 && i + 3 < n && buf[i + 3] === 126) { // Delete +// if (cursorPos < input.length) input.splice(cursorPos, 1); +// i += 1; // consume tilde +// } +// i += 2; // consume ESC [ X +// continue; +// } + +// // Backspace +// if (byte === 127 || byte === 8) { +// if (cursorPos > 0) { +// input.splice(cursorPos - 1, 1); +// cursorPos--; +// } +// continue; +// } + +// // Delete (ASCII 46) +// if (byte === 46 && cursorPos < input.length) { +// input.splice(cursorPos, 1); +// continue; +// } + +// // Printable +// if (byte >= 32 && byte <= 126) { +// input.splice(cursorPos, 0, String.fromCharCode(byte)); +// cursorPos++; +// } + +// // Other cases: ignore +// } + +// render(); +// } + +// await Deno.stdin.setRaw(false); +// if (!cursorVisible) { +// Cursor.hide(); +// } +// Deno.stdout.writeSync(encoder.encode("\n")); + +// return input.join(""); +// } export async function cliPrompt( message: string, @@ -11,121 +120,140 @@ export async function cliPrompt( const encoder = new TextEncoder(); const input: string[] = []; let cursorPos = 0; + let range: [number, number] | undefined; - await Deno.stdin.setRaw(true); - - const cursorVisible = Cursor["visible"]; Cursor.show(); - let range: [number, number] = [0, 1]; - if (block) { - range = block.setLines([message + " "]); - } else { - Deno.stdout.writeSync(encoder.encode(message + " ")); - } + const im = InputManager.getInstance(); + im.activate(); const render = () => { const line = message + " " + input.join(""); const moveTo = `\x1b[${message.length + 2 + cursorPos}G`; if (block) { - block.setPostRenderAction(function () { - Deno.stdout.writeSync( - encoder.encode(`\x1b[${this["lastRenderRow"]};1H`), - ); + block.setPostRenderAction(() => { Deno.stdout.writeSync(encoder.encode(moveTo)); }); range = block.setLines([line], range); } else { - Deno.stdout.writeSync(encoder.encode("\x1b[K" + line + moveTo)); + Deno.stdout.writeSync(encoder.encode("\r\x1b[K" + line + moveTo)); } }; + const exit = () => { + im.removeEventListener("enter", onEnter); + im.removeEventListener("backspace", onBackspace); + im.removeEventListener("delete", onDelete); + im.removeEventListener("arrow-left", onLeft); + im.removeEventListener("arrow-right", onRight); + im.removeEventListener("char", onKey); + Cursor.hide(); + }; + + let resolve: null | ((value: string) => void) = null; + + const onEnter = () => { + exit(); + resolve?.(input.join("")); + }; + + const onBackspace = () => { + if (cursorPos > 0) { + input.splice(cursorPos - 1, 1); + cursorPos--; + render(); + } + }; + + const onDelete = () => { + if (cursorPos < input.length) { + input.splice(cursorPos, 1); + render(); + } + }; + + const onLeft = () => { + if (cursorPos > 0) { + cursorPos--; + render(); + } + }; + + const onRight = () => { + if (cursorPos < input.length) { + cursorPos++; + render(); + } + }; + + const onKey = (e: Event) => { + const ke = (e as CLICharEvent).detail; + input.splice(cursorPos, 0, ke.char); + cursorPos++; + render(); + }; + render(); - const buf = new Uint8Array(64); // large enough for most pastes - inputLoop: - while (true) { - const n = await Deno.stdin.read(buf); - if (n === null) break; + return await new Promise((res) => { + resolve = res; + im.addEventListener("enter", onEnter); + im.addEventListener("backspace", onBackspace); + im.addEventListener("delete", onDelete); + im.addEventListener("arrow-left", onLeft); + im.addEventListener("arrow-right", onRight); + im.addEventListener("char", onKey); + }); +} - for (let i = 0; i < n; i++) { - const byte = buf[i]; - - // Ctrl+C - if (byte === 3) { - block?.clear(); - block?.["layout"]?.clearAll(); - await Deno.stdin.setRaw(false); - Deno.exit(130); - } - - if (byte === 13) { // Enter - break inputLoop; - } - - // Escape sequence? - if (byte === 27 && i + 1 < n && buf[i + 1] === 91) { - const third = buf[i + 2]; - if (third === 68 && cursorPos > 0) cursorPos--; // Left - else if (third === 67 && cursorPos < input.length) cursorPos++; // Right - else if (third === 51 && i + 3 < n && buf[i + 3] === 126) { // Delete - if (cursorPos < input.length) input.splice(cursorPos, 1); - i += 1; // consume tilde - } - i += 2; // consume ESC [ X - continue; - } - - // Backspace - if (byte === 127 || byte === 8) { - if (cursorPos > 0) { - input.splice(cursorPos - 1, 1); - cursorPos--; - } - continue; - } - - // Delete (ASCII 46) - if (byte === 46 && cursorPos < input.length) { - input.splice(cursorPos, 1); - continue; - } - - // Printable - if (byte >= 32 && byte <= 126) { - input.splice(cursorPos, 0, String.fromCharCode(byte)); - cursorPos++; - } - - // Other cases: ignore +export async function cliConfirm(message: string, block?: TerminalBlock) { + const im = InputManager.getInstance(); + let inpout = ""; + function isValidInput(input: string) { + switch (input) { + case "y": + case "n": + return inpout.length === 0; + case "e": + return inpout === "y"; + case "s": + return inpout === "ye"; + case "o": + return inpout === "n"; + default: + return false; } - - render(); } - await Deno.stdin.setRaw(false); - if (!cursorVisible) { - Cursor.hide(); + function onKey(e: CLICharEvent) { + const ke = e.detail; + const char = String.fromCharCode(ke.key); + if (isValidInput(char)) { + inpout += char; + } else { + e.stopImmediatePropagation(); + } } - Deno.stdout.writeSync(encoder.encode("\n")); - - return input.join(""); -} - -export function cliConfirm(message: string, block?: TerminalBlock) { - return cliPrompt(message + " (y/n)", block).then((v) => - v.toLowerCase() === "y" + im.addEventListener("char", onKey); + const value = await cliPrompt(message + " (y/n)", block).then((v) => + v.charAt(0).toLowerCase() === "y" ); + im.removeEventListener("char", onKey); + return value; } -export function cliAlert(message: string, block?: TerminalBlock) { - return cliPrompt( +export async function cliAlert(message: string, block?: TerminalBlock) { + const im = InputManager.getInstance(); + const onKey = (e: CLICharEvent) => { + e.stopImmediatePropagation(); + }; + im.addEventListener("char", onKey); + await cliPrompt( message + colorize(" Press Enter to continue", "gray"), block, - ).then((v) => { - return v; - }); + ); + im.removeEventListener("char", onKey); } export function cliLog( @@ -158,6 +286,12 @@ if (import.meta.main) { layout.register("block", block); layout.register("footer", footer); + InputManager.addEventListener("exit", () => { + layout.clearAll(); + // console.clear(); + Deno.exit(0); + }); + Deno.addSignalListener("SIGINT", () => { layout.clearAll(); // console.clear();