change: input manager and prompt rewrite

This commit is contained in:
Emmaline Autumn 2025-05-21 21:32:49 -06:00
parent 569c67583d
commit 7a394c642a
2 changed files with 418 additions and 88 deletions

196
cli/InputManager.ts Normal file
View File

@ -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<EventMap extends object> = {
new (): IntermediateEventTarget<EventMap>;
};
export interface IntermediateEventTarget<EventMap> extends EventTarget {
addEventListener<K extends keyof EventMap>(
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<K extends keyof EventMap>(
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<EventMap>;
export class CLICharEvent extends CustomEvent<EventDetailMap["char"]> {
constructor(detail: EventDetailMap["char"]) {
super("char", { detail, cancelable: true });
}
}
export class CLIKeypressEvent extends CustomEvent<EventDetailMap["keypress"]> {
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<T extends string>(type: T): Promise<Event> {
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);
}
}

View File

@ -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<string> {
// 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<string>((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();