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; } }); }); }