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 { 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((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 { 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((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")); }