pdf-tools/cli/selectMenu.ts
Emma 0f9c377853 change: selects now use inputmanager
fix: bad exit logic
feat: field rename now supports renaming things with multiple widgets
2025-05-27 12:44:45 -06:00

306 lines
8.4 KiB
TypeScript

import type { callback } from "../types.ts";
import { type CLICharEvent, InputManager } from "./InputManager.ts";
import { colorize } from "./style.ts";
import { TerminalBlock, TerminalLayout } from "./TerminalLayout.ts";
interface ISelectMenuConfig {
terminalBlock?: TerminalBlock;
initialSelection?: number;
initialSelections?: number[];
}
export function selectMenu(items: string[]) {
const menu = items.map((i, index) => `${index + 1}. ${i}`).join("\n");
console.log(menu);
const index = parseInt(prompt("Please select an option:") || "1") - 1;
return items[index];
}
export async function selectMenuInteractive(
q: string,
options: string[],
config?: ISelectMenuConfig,
): Promise<string | null> {
Deno.stdin.setRaw(true);
let selected = 0;
const terminalBlock = config?.terminalBlock || new TerminalBlock();
if (!config?.terminalBlock) {
terminalBlock.setRenderHeight(Deno.consoleSize().rows);
}
let range: [number, number] = [terminalBlock.lineCount, 1];
function renderMenu() {
const { rows } = Deno.consoleSize();
const terminalHeight = terminalBlock.getRenderHeight() || rows;
const maxHeight = Math.min(terminalHeight - 1, options.length);
let startPoint = Math.max(0, selected - Math.floor(maxHeight / 2));
const endPoint = Math.min(options.length, startPoint + maxHeight);
if (endPoint - startPoint < maxHeight) {
startPoint = Math.max(0, options.length - maxHeight);
}
const lines: string[] = [];
lines.push(colorize(q, "green"));
for (let i = startPoint; i < endPoint; i++) {
const option = options[i];
if (i === selected) {
lines.push(`${numberAndPadding(i, ">")}${colorize(option, "porple")}`);
} else {
lines.push(`${numberAndPadding(i)}${option}`);
}
}
range = terminalBlock.setLines(lines, range);
}
function numberAndPadding(i: number, prefix?: string) {
const padded = `${i + 1}. `.padStart(
options.length.toString().length + 4,
);
return prefix ? padded.replace(" ", prefix.substring(0, 1)) : padded;
}
let inputBuffer = "";
const im = InputManager.getInstance();
im.activate();
const onUp = (e: Event) => {
e.stopImmediatePropagation();
selected = (selected - 1 + options.length) % options.length;
renderMenu();
};
const onDown = (e: Event) => {
e.stopImmediatePropagation();
selected = (selected + 1) % options.length;
renderMenu();
};
const onKey = (e: CLICharEvent) => {
e.stopImmediatePropagation();
const ke = e.detail;
const char = String.fromCharCode(ke.key);
inputBuffer += char;
};
const onBackspace = (e: Event) => {
e.stopImmediatePropagation();
inputBuffer = inputBuffer.slice(0, -1);
};
let resolve: null | ((value: string) => void) = null;
const onEnter = (e: Event) => {
e.stopImmediatePropagation();
if (inputBuffer) {
const parsed = parseInt(inputBuffer);
if (!isNaN(parsed)) {
selected = parsed - 1;
}
inputBuffer = "";
}
im.removeEventListener("arrow-up", onUp);
im.removeEventListener("arrow-down", onDown);
im.removeEventListener("char", onKey);
im.removeEventListener("backspace", onBackspace);
im.removeEventListener("enter", onEnter);
resolve?.(options[selected]);
};
renderMenu();
await new Promise<string>((res) => {
resolve = res;
im.addEventListener("char", onKey);
im.addEventListener("backspace", onBackspace);
im.addEventListener("enter", onEnter);
im.addEventListener("arrow-up", onUp);
im.addEventListener("arrow-down", onDown);
});
terminalBlock.setLines(["Selected: " + options[selected]], range);
return options[selected];
}
export async function multiSelectMenuInteractive(
q: string,
options: string[] | [string, callback][],
config?: ISelectMenuConfig,
): Promise<string[] | null> {
Deno.stdin.setRaw(true);
let selected = 0;
let selectedOptions: number[] = config?.initialSelections || [];
const rawValues = options.map((i) => typeof i === "string" ? i : i[0]);
if (rawValues.length !== options.length) {
throw new Error("Duplicate options in multi-select menu");
}
const terminalBlock = config?.terminalBlock || new TerminalBlock();
if (!config?.terminalBlock) {
terminalBlock.setRenderHeight(Deno.consoleSize().rows);
}
let range: [number, number] = [terminalBlock.lineCount, 1];
function renderMenu() {
const { rows } = Deno.consoleSize();
const terminalHeight = terminalBlock.getRenderHeight() || rows;
const maxHeight = Math.min(terminalHeight - 1, options.length);
let startPoint = Math.max(0, selected - Math.floor(maxHeight / 2));
const endPoint = Math.min(options.length, startPoint + maxHeight);
if (endPoint - startPoint < maxHeight) {
startPoint = Math.max(0, options.length - maxHeight);
}
const lines: string[] = [];
lines.push(colorize(q, "green"));
for (let i = startPoint; i < endPoint; i++) {
const option = rawValues[i];
const checkbox = selectedOptions.includes(i)
? colorize("◼", "green")
: "◻";
if (i === selected) {
lines.push(`> ${checkbox} ${colorize(option, "porple")}`);
} else {
lines.push(` ${checkbox} ${option}`);
}
}
range = terminalBlock.setLines(lines, range);
}
const im = InputManager.getInstance();
im.activate();
let resolve = null as null | ((value: number[]) => void);
const onUp = (e: Event) => {
e.stopImmediatePropagation();
selected = (selected - 1 + options.length) % options.length;
renderMenu();
};
const onDown = (e: Event) => {
e.stopImmediatePropagation();
selected = (selected + 1) % options.length;
renderMenu();
};
const onSpace = (e: CLICharEvent) => {
if (e.detail.char !== " ") return;
e.stopImmediatePropagation();
if (selectedOptions.includes(selected)) {
selectedOptions = selectedOptions.filter((i) => i !== selected);
} else {
selectedOptions.push(selected);
}
renderMenu();
};
const onEnter = (e: Event) => {
e.stopImmediatePropagation();
resolve?.(selectedOptions);
im.removeEventListener("arrow-up", onUp);
im.removeEventListener("arrow-down", onDown);
im.removeEventListener("char", onSpace);
im.removeEventListener("enter", onEnter);
};
renderMenu();
const selections = await new Promise<number[]>((res) => {
resolve = res;
im.addEventListener("arrow-up", onUp);
im.addEventListener("arrow-down", onDown);
im.addEventListener("char", onSpace);
im.addEventListener("enter", onEnter);
});
for (const optionI of selections) {
const option = options[optionI];
if (Array.isArray(option)) {
await option[1](option[0]);
}
}
const final = selectedOptions.map((i) => rawValues[i]);
terminalBlock.setLines(["Selected: " + final.join(", ")], range);
return final;
}
if (import.meta.main) {
const layout = new TerminalLayout();
const block = new TerminalBlock();
const titleBlock = new TerminalBlock();
const postBlock = new TerminalBlock();
InputManager.addEventListener("exit", () => layout.clearAll());
titleBlock.setLines(["An incredible fruit menu!"]);
postBlock.setLines(["I'm here too!"]);
titleBlock.setFixedHeight(1);
postBlock.setFixedHeight(1);
layout.register("title", titleBlock);
layout.register("block", block);
// const val = await selectMenuInteractive("choose a fruit", [
// "apple",
// "banana",
// "cherry",
// "date",
// "elderberry",
// "fig",
// "grape",
// "honeydew",
// "ilama",
// "jackfruit",
// "kiwi",
// "lemon",
// "mango",
// "nectarine",
// "orange",
// "papaya",
// "peach",
// "pineapple",
// "pomegranate",
// "quince",
// "raspberry",
// "strawberry",
// "tangerine",
// "watermelon",
// ], { terminalBlock: block });
// layout.clearAll();
// console.log(val);
const val = await multiSelectMenuInteractive("choose a fruit", [
"apple",
"banana",
"cherry",
"date",
"elderberry",
"fig",
"grape",
"honeydew",
"ilama",
"jackfruit",
"kiwi",
"lemon",
"mango",
"nectarine",
"orange",
"papaya",
"quince",
"raspberry",
"strawberry",
"tangerine",
"udara",
"vogelbeere",
"watermelon",
"ximenia",
"yuzu",
"zucchini",
], { terminalBlock: block });
// console.log(val);
// Deno.stdout.writeSync(new TextEncoder().encode("\x07"));
}