162 lines
4.3 KiB
JavaScript
162 lines
4.3 KiB
JavaScript
import color from "picocolors";
|
|
import { initKeypress, onKey, stopKeypress } from "./keyboard.js";
|
|
import { clearScreen, padEnd, strWidth, centerPad } from "./utils/terminal.js";
|
|
|
|
export async function gridSelect(options) {
|
|
const {
|
|
items,
|
|
cols = 3,
|
|
colWidth = 24,
|
|
title = "",
|
|
renderHeader = null,
|
|
updateInfo = null,
|
|
headerGap = 2,
|
|
menuGap = 2,
|
|
rowGap = 1,
|
|
instructions = "←→ 选择 | Enter 确认 | Esc 退出",
|
|
onCancel = "exit", // "exit" | "back"
|
|
mapper,
|
|
showUpdateButton = true,
|
|
} = options;
|
|
|
|
const hasUpdate = showUpdateButton && !!updateInfo;
|
|
const mapResult = mapper || ((item, isUpdate) => (isUpdate ? { isUpdate: true } : item));
|
|
|
|
let current = 0;
|
|
let onUpdate = hasUpdate;
|
|
let resolved = false;
|
|
const rows = Math.ceil((items?.length || 0) / cols);
|
|
const termWidth = process.stdout.columns || 80;
|
|
const totalWidth = cols * colWidth;
|
|
const pad = " ".repeat(Math.max(0, Math.floor((termWidth - totalWidth) / 2)));
|
|
|
|
function render() {
|
|
clearScreen();
|
|
|
|
if (renderHeader) {
|
|
console.log(renderHeader(onUpdate));
|
|
}
|
|
|
|
for (let i = 0; i < headerGap; i++) console.log("");
|
|
|
|
if (title) {
|
|
const titlePad = centerPad(title, termWidth - 4);
|
|
console.log(titlePad + color.bgMagenta(color.white(` ${title} `)));
|
|
}
|
|
|
|
if (instructions) {
|
|
console.log("\n" + pad + color.dim(instructions));
|
|
} else {
|
|
console.log("");
|
|
}
|
|
|
|
for (let i = 0; i < menuGap; i++) console.log("");
|
|
|
|
if (!items || items.length === 0) {
|
|
console.log(color.yellow("暂无可选项"));
|
|
return;
|
|
}
|
|
|
|
for (let row = 0; row < rows; row++) {
|
|
let line = "";
|
|
let descLine = "";
|
|
|
|
for (let col = 0; col < cols; col++) {
|
|
const idx = row * cols + col;
|
|
if (idx < items.length) {
|
|
const item = items[idx];
|
|
if (!onUpdate && idx === current) {
|
|
line += color.cyan("[" + item.name + "]");
|
|
} else {
|
|
line += " " + item.name + " ";
|
|
}
|
|
line += " ".repeat(Math.max(0, colWidth - strWidth(item.name) - 2));
|
|
descLine += padEnd(item.desc || "", colWidth);
|
|
}
|
|
}
|
|
|
|
console.log(pad + line);
|
|
console.log(pad + color.dim(descLine));
|
|
for (let i = 0; i < rowGap; i++) console.log("");
|
|
}
|
|
}
|
|
|
|
return new Promise(resolve => {
|
|
initKeypress();
|
|
render();
|
|
|
|
onKey((str, key) => {
|
|
if (!key || resolved) return;
|
|
|
|
const row = Math.floor(current / cols);
|
|
const col = current % cols;
|
|
|
|
switch (key.name) {
|
|
case "up":
|
|
if (!onUpdate && row === 0 && hasUpdate) {
|
|
onUpdate = true;
|
|
render();
|
|
} else if (!onUpdate && row > 0) {
|
|
current -= cols;
|
|
render();
|
|
}
|
|
break;
|
|
case "down":
|
|
if (onUpdate) {
|
|
onUpdate = false;
|
|
render();
|
|
} else if (row < rows - 1 && current + cols < items.length) {
|
|
current += cols;
|
|
render();
|
|
}
|
|
break;
|
|
case "left":
|
|
if (!onUpdate && col > 0) {
|
|
current--;
|
|
render();
|
|
}
|
|
break;
|
|
case "right":
|
|
if (!onUpdate && col < cols - 1 && current < items.length - 1) {
|
|
current++;
|
|
render();
|
|
}
|
|
break;
|
|
case "return":
|
|
resolved = true;
|
|
stopKeypress();
|
|
setImmediate(() => {
|
|
const isUpdateSelection = onUpdate && hasUpdate;
|
|
const payload = isUpdateSelection ? null : items[current];
|
|
resolve(mapResult(payload, isUpdateSelection));
|
|
});
|
|
break;
|
|
case "escape":
|
|
resolved = true;
|
|
stopKeypress();
|
|
clearScreen();
|
|
if (onCancel === "back") {
|
|
setImmediate(() => resolve("back"));
|
|
} else {
|
|
console.log(color.yellow("再见!"));
|
|
process.exit(0);
|
|
}
|
|
break;
|
|
case "c":
|
|
if (key.ctrl) {
|
|
resolved = true;
|
|
stopKeypress();
|
|
clearScreen();
|
|
if (onCancel === "back") {
|
|
setImmediate(() => resolve("back"));
|
|
} else {
|
|
console.log(color.yellow("再见!"));
|
|
process.exit(0);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
});
|
|
});
|
|
}
|