308 lines
7.8 KiB
TypeScript
308 lines
7.8 KiB
TypeScript
import { BOLD, GREEN, CYAN, DIM, RESET } from "./terminal";
|
||
|
||
const UP = "\x1b[A";
|
||
const DOWN = "\x1b[B";
|
||
const ALT_UP = "\x1bOA";
|
||
const ALT_DOWN = "\x1bOB";
|
||
const SPACE = " ";
|
||
const ENTER = "\r";
|
||
const CTRL_C = "\x03";
|
||
|
||
export interface Choice<T> {
|
||
label: string;
|
||
value: T;
|
||
description?: string;
|
||
selected?: boolean;
|
||
}
|
||
|
||
interface BasePromptOptions {
|
||
title: string;
|
||
subtitle?: string;
|
||
cancelMessage?: string;
|
||
}
|
||
|
||
interface SinglePromptOptions<T> extends BasePromptOptions {
|
||
items: Choice<T>[];
|
||
}
|
||
|
||
interface MultiPromptOptions<T> extends BasePromptOptions {
|
||
items: Choice<T>[];
|
||
selectAllLabel?: string;
|
||
doneLabel?: string;
|
||
}
|
||
|
||
function hideCursor() {
|
||
process.stdout.write("\x1b[?25l");
|
||
}
|
||
|
||
function showCursor() {
|
||
process.stdout.write("\x1b[?25h");
|
||
}
|
||
|
||
function moveUp(lines: number) {
|
||
if (lines > 0) process.stdout.write(`\x1b[${lines}A`);
|
||
}
|
||
|
||
function clearLine() {
|
||
process.stdout.write("\r\x1b[2K");
|
||
}
|
||
|
||
function visibleLength(value: string) {
|
||
return value.replace(/\x1b\[[0-9;]*m/g, "").length;
|
||
}
|
||
|
||
function padLabel(label: string, width: number) {
|
||
return label + " ".repeat(Math.max(1, width - visibleLength(label)));
|
||
}
|
||
|
||
function controls(mode: "single" | "multi") {
|
||
if (mode === "single") {
|
||
return `${DIM}↑/↓ navigate · enter/space select · ctrl+c cancel${RESET}`;
|
||
}
|
||
return `${DIM}↑/↓ navigate · space toggle · enter confirm · ctrl+c cancel${RESET}`;
|
||
}
|
||
|
||
function renderPrompt(lines: string[], previousLines: number) {
|
||
if (previousLines > 0) {
|
||
for (let i = 0; i < previousLines; i++) {
|
||
clearLine();
|
||
process.stdout.write("\n");
|
||
}
|
||
moveUp(previousLines);
|
||
}
|
||
|
||
for (const line of lines) {
|
||
process.stdout.write(`${line}\n`);
|
||
}
|
||
moveUp(lines.length);
|
||
return lines.length;
|
||
}
|
||
|
||
function clearPrompt(lines: number) {
|
||
for (let i = 0; i < lines; i++) {
|
||
clearLine();
|
||
process.stdout.write("\n");
|
||
}
|
||
moveUp(lines);
|
||
}
|
||
|
||
function normalizeKey(key: string, escapeBuf: string) {
|
||
if (key === UP || key === ALT_UP) return { action: "up", escapeBuf: "" };
|
||
if (key === DOWN || key === ALT_DOWN) return { action: "down", escapeBuf: "" };
|
||
if (key === SPACE) return { action: "space", escapeBuf: "" };
|
||
if (key === ENTER) return { action: "enter", escapeBuf: "" };
|
||
if (key === CTRL_C) return { action: "cancel", escapeBuf: "" };
|
||
|
||
if (key === "\x1b" || key.startsWith("\x1b[")) {
|
||
return { action: null, escapeBuf: key };
|
||
}
|
||
|
||
if (escapeBuf) {
|
||
const next = escapeBuf + key;
|
||
if (next === UP || next === ALT_UP) return { action: "up", escapeBuf: "" };
|
||
if (next === DOWN || next === ALT_DOWN) return { action: "down", escapeBuf: "" };
|
||
return {
|
||
action: null,
|
||
escapeBuf: /^[A-Za-z~]$/.test(key) || next.length > 8 ? "" : next,
|
||
};
|
||
}
|
||
|
||
return { action: null, escapeBuf: "" };
|
||
}
|
||
|
||
function createLines<T>(
|
||
options: BasePromptOptions & { items: Choice<T>[] },
|
||
mode: "single" | "multi",
|
||
cursor: number,
|
||
) {
|
||
const labelWidth = Math.max(
|
||
...options.items.map((item) => visibleLength(item.label)),
|
||
0,
|
||
) + 2;
|
||
|
||
const lines = [
|
||
"",
|
||
` ${BOLD}${options.title}${RESET}`,
|
||
];
|
||
|
||
if (options.subtitle) lines.push(` ${DIM}${options.subtitle}${RESET}`);
|
||
lines.push(` ${controls(mode)}`, "");
|
||
|
||
for (let i = 0; i < options.items.length; i++) {
|
||
const item = options.items[i]!;
|
||
const active = i === cursor;
|
||
const pointer = active ? `${CYAN}❯${RESET}` : " ";
|
||
const marker = mode === "single"
|
||
? active ? `${GREEN}●${RESET}` : `${DIM}○${RESET}`
|
||
: item.selected ? `${GREEN}◼${RESET}` : `${DIM}□${RESET}`;
|
||
const label = active ? `${BOLD}${item.label}${RESET}` : item.label;
|
||
const description = item.description
|
||
? active ? item.description : `${DIM}${item.description}${RESET}`
|
||
: "";
|
||
|
||
lines.push(
|
||
` ${pointer} ${marker} ${padLabel(label, labelWidth)}${description}`,
|
||
);
|
||
}
|
||
|
||
return lines;
|
||
}
|
||
|
||
function ensureTTY(title: string) {
|
||
if (process.stdin.isTTY !== true) {
|
||
throw new Error(`${title} requires a TTY.`);
|
||
}
|
||
}
|
||
|
||
export async function selectOne<T>(
|
||
options: SinglePromptOptions<T>,
|
||
): Promise<T | null> {
|
||
ensureTTY(options.title);
|
||
|
||
let cursor = 0;
|
||
let renderedLines = 0;
|
||
let escapeBuf = "";
|
||
const wasRaw = process.stdin.isRaw;
|
||
|
||
if (wasRaw !== true) process.stdin.setRawMode(true);
|
||
process.stdin.resume();
|
||
hideCursor();
|
||
|
||
const render = () => {
|
||
renderedLines = renderPrompt(
|
||
createLines(options, "single", cursor),
|
||
renderedLines,
|
||
);
|
||
};
|
||
|
||
render();
|
||
|
||
return new Promise((resolve) => {
|
||
const finish = (value: T | null) => {
|
||
process.stdin.setRawMode(wasRaw === true);
|
||
process.stdin.pause();
|
||
process.stdin.removeListener("data", onData);
|
||
clearPrompt(renderedLines);
|
||
showCursor();
|
||
if (value === null && options.cancelMessage) {
|
||
process.stdout.write(` ${options.cancelMessage}\n`);
|
||
}
|
||
resolve(value);
|
||
};
|
||
|
||
const onData = (data: Buffer) => {
|
||
const result = normalizeKey(data.toString(), escapeBuf);
|
||
escapeBuf = result.escapeBuf;
|
||
|
||
if (result.action === "cancel") return finish(null);
|
||
if (result.action === "up" && cursor > 0) {
|
||
cursor--;
|
||
render();
|
||
} else if (result.action === "down" && cursor < options.items.length - 1) {
|
||
cursor++;
|
||
render();
|
||
} else if (result.action === "space" || result.action === "enter") {
|
||
finish(options.items[cursor]!.value);
|
||
}
|
||
};
|
||
|
||
process.stdin.on("data", onData);
|
||
});
|
||
}
|
||
|
||
export async function selectMany<T>(
|
||
options: MultiPromptOptions<T>,
|
||
): Promise<T[] | null> {
|
||
ensureTTY(options.title);
|
||
|
||
const items: Choice<T | null>[] = options.selectAllLabel
|
||
? [
|
||
{ label: options.selectAllLabel, value: null, selected: false },
|
||
...options.items.map((item) => ({ ...item })),
|
||
]
|
||
: options.items.map((item) => ({ ...item }));
|
||
|
||
let cursor = 0;
|
||
let renderedLines = 0;
|
||
let escapeBuf = "";
|
||
const wasRaw = process.stdin.isRaw;
|
||
|
||
if (wasRaw !== true) process.stdin.setRawMode(true);
|
||
process.stdin.resume();
|
||
hideCursor();
|
||
|
||
const syncSelectAll = () => {
|
||
if (!options.selectAllLabel) return;
|
||
items[0]!.selected = items.slice(1).every((item) => item.selected);
|
||
};
|
||
|
||
const toggle = (index: number) => {
|
||
const item = items[index]!;
|
||
item.selected = !item.selected;
|
||
|
||
if (options.selectAllLabel && index === 0) {
|
||
for (let i = 1; i < items.length; i++) {
|
||
items[i]!.selected = item.selected;
|
||
}
|
||
} else {
|
||
syncSelectAll();
|
||
}
|
||
};
|
||
|
||
const render = () => {
|
||
renderedLines = renderPrompt(
|
||
createLines(
|
||
{
|
||
title: options.title,
|
||
subtitle: options.subtitle,
|
||
items,
|
||
},
|
||
"multi",
|
||
cursor,
|
||
),
|
||
renderedLines,
|
||
);
|
||
};
|
||
|
||
render();
|
||
|
||
return new Promise((resolve) => {
|
||
const finish = (value: T[] | null) => {
|
||
process.stdin.setRawMode(wasRaw === true);
|
||
process.stdin.pause();
|
||
process.stdin.removeListener("data", onData);
|
||
clearPrompt(renderedLines);
|
||
showCursor();
|
||
if (value === null && options.cancelMessage) {
|
||
process.stdout.write(` ${options.cancelMessage}\n`);
|
||
}
|
||
resolve(value);
|
||
};
|
||
|
||
const onData = (data: Buffer) => {
|
||
const result = normalizeKey(data.toString(), escapeBuf);
|
||
escapeBuf = result.escapeBuf;
|
||
|
||
if (result.action === "cancel") return finish(null);
|
||
if (result.action === "up" && cursor > 0) {
|
||
cursor--;
|
||
render();
|
||
} else if (result.action === "down" && cursor < items.length - 1) {
|
||
cursor++;
|
||
render();
|
||
} else if (result.action === "space") {
|
||
toggle(cursor);
|
||
render();
|
||
} else if (result.action === "enter") {
|
||
finish(
|
||
items
|
||
.filter((item) => item.selected && item.value !== null)
|
||
.map((item) => item.value as T),
|
||
);
|
||
}
|
||
};
|
||
|
||
process.stdin.on("data", onData);
|
||
});
|
||
}
|