350 lines
9.9 KiB
TypeScript
350 lines
9.9 KiB
TypeScript
import type { callback } from "../types.ts";
|
|
import { type CLICharEvent, InputManager } from "./InputManager.ts";
|
|
import { cliLog } from "./prompts.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 | null) => void) = null;
|
|
|
|
const onEscape = () => {
|
|
im.removeEventListener("arrow-up", onUp);
|
|
im.removeEventListener("arrow-down", onDown);
|
|
im.removeEventListener("char", onKey);
|
|
im.removeEventListener("backspace", onBackspace);
|
|
im.removeEventListener("enter", onEnter);
|
|
im.removeEventListener("escape", onEscape);
|
|
resolve?.(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);
|
|
im.removeEventListener("escape", onEscape);
|
|
resolve?.(options[selected]);
|
|
};
|
|
|
|
renderMenu();
|
|
const final = await new Promise<string | null>((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);
|
|
im.addEventListener("escape", onEscape);
|
|
});
|
|
|
|
terminalBlock.setLines(["Selected: " + final], range);
|
|
|
|
return final;
|
|
}
|
|
|
|
export async function multiSelectMenuInteractive(
|
|
q: string,
|
|
options: (string | [string, callback])[],
|
|
config?: ISelectMenuConfig & { allOption?: boolean },
|
|
): Promise<string[] | null> {
|
|
Deno.stdin.setRaw(true);
|
|
let selected = 0;
|
|
let selectedOptions: number[] = config?.initialSelections || [];
|
|
|
|
if (config?.allOption) {
|
|
options.unshift("Select All");
|
|
}
|
|
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);
|
|
}
|
|
|
|
const checkSelectAll = () => {
|
|
if (selectedOptions.includes(0)) {
|
|
selectedOptions = [];
|
|
} else {
|
|
selectedOptions = Array.from(options).map((_, i) => i);
|
|
}
|
|
};
|
|
|
|
const validateSelectAll = () => {
|
|
const allPresent = selectedOptions.length == options.length;
|
|
if (!allPresent) selectedOptions = selectedOptions.filter((e) => e != 0);
|
|
};
|
|
|
|
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[] | null) => 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 (config?.allOption && selected === 0) {
|
|
checkSelectAll();
|
|
} else if (selectedOptions.includes(selected)) {
|
|
selectedOptions = selectedOptions.filter((i) => i !== selected);
|
|
} else {
|
|
selectedOptions.push(selected);
|
|
}
|
|
validateSelectAll();
|
|
renderMenu();
|
|
};
|
|
|
|
const onEscape = () => {
|
|
im.removeEventListener("arrow-up", onUp);
|
|
im.removeEventListener("arrow-down", onDown);
|
|
im.removeEventListener("char", onSpace);
|
|
im.removeEventListener("enter", onEnter);
|
|
im.removeEventListener("escape", onEscape);
|
|
resolve?.(null);
|
|
};
|
|
|
|
const onEnter = (e: Event) => {
|
|
e.stopImmediatePropagation();
|
|
im.removeEventListener("arrow-up", onUp);
|
|
im.removeEventListener("arrow-down", onDown);
|
|
im.removeEventListener("char", onSpace);
|
|
im.removeEventListener("enter", onEnter);
|
|
im.removeEventListener("escape", onEscape);
|
|
resolve?.(selectedOptions);
|
|
};
|
|
|
|
renderMenu();
|
|
|
|
const selections = await new Promise<number[] | null>((res) => {
|
|
resolve = res;
|
|
im.addEventListener("arrow-up", onUp);
|
|
im.addEventListener("arrow-down", onDown);
|
|
im.addEventListener("char", onSpace);
|
|
im.addEventListener("enter", onEnter);
|
|
im.addEventListener("escape", onEscape);
|
|
});
|
|
if (!selections) return null;
|
|
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, allOption: true });
|
|
cliLog(val || "No value", block);
|
|
|
|
// Deno.stdout.writeSync(new TextEncoder().encode("\x07"));
|
|
}
|