/** * Select dialog — a short menu in a box centered over the panel, like * lazygit's menus and pi's "Summarize branch?" selector: * * ┌─ Summarize branch? ──────────── assistant: hi ─┐ * │ › No summary │ * │ Summarize │ * │ Summarize with custom prompt │ * └────────────────────────────────────────────────┘ * RESTORE │ j/k move Enter select Esc cancel <- footer while open * * Generic like ./input-dialog.ts: title, items, initial cursor, subject and * the callbacks arrive with `open(spec)`, so the same widget serves the * summary menu now and delete confirmations, sort pickers and so on later. * Keys: j / k / ↑ / ↓ move, Enter confirms, Esc cancels (plus q when the spec * asks for it); everything else is swallowed. Key hints go to the footer (see * `hints`). * * 通用的居中选择菜单:标题 / 选项 / 初始光标 / 回调都在 open 时传入,本身不做 I/O; * 快捷键提示由 app.ts 放在底部 footer。 */ import type { Theme } from "@earendil-works/pi-coding-agent"; import { truncateToWidth } from "@earendil-works/pi-tui"; import { matchesKeyId } from "../../config/keys.ts"; import type { KeyHint } from "../../types.ts"; import { clamp } from "../../utils/indices.ts"; import { boxCell, boxContains, centeredRect, dialogWidth, fit, frame, metaBudget, overlayCentered, type BoxRect } from "../frame.ts"; /** What one menu looks like; passed to `open`. */ export interface SelectDialogSpec { /** Title on the top border, e.g. "Summarize branch?". */ title: string; /** Menu entries, top to bottom. */ items: string[]; /** Cursor position when the menu opens (clamped into `items`). */ initialIndex?: number; /** One-line description shown at the right end of the title bar (e.g. the node being restored to). */ subject?: string; /** Footer hints while this menu is open. */ hints: KeyHint[]; /** * Keys (pi-tui key ids, e.g. "y") that pick an entry directly, without * moving the cursor first — a confirmation's `y` / `n`. */ shortcuts?: Record; /** * Cap on how many entries are visible at once; a longer list scrolls a * window around the cursor (the box is then `maxRows + 2` tall). Unset = * show every entry. Used by the fork selector, which can be long. */ maxRows?: number; /** * Border + title color of the box (default: the accent border / title). An * alert passes "error" so a warning box reads in red. */ tone?: Parameters[0]; /** Enter (or a shortcut): the index of the picked entry. */ onSelect: (index: number) => void; onCancel: () => void; /** * Also cancel on `q` (the share dialogs show an `Esc/q close` hint in the * title bar). Other menus keep swallowing `q`, so a stray key cannot close * a confirmation. */ closeOnQ?: boolean; } export interface SelectDialogOptions { theme: Theme; /** Called after every cursor move so the panel can re-render. */ onChange: () => void; } export class SelectDialog { private spec: SelectDialogSpec | undefined; private index = 0; /** Index of the first visible entry when the list scrolls (see `maxRows`). */ private scroll = 0; /** 上一次绘制时这个菜单在屏幕上的位置(`overlay()` 里记);鼠标命中只认这一份,没画过就点不到。 */ private boxRect: BoxRect | undefined; constructor(private readonly o: SelectDialogOptions) {} /** True between `open` and `close`. */ get isOpen(): boolean { return this.spec !== undefined; } /** Footer hints of the current menu (empty when closed). */ get hints(): KeyHint[] { return this.spec?.hints ?? []; } /** Index of the highlighted entry. */ get selectedIndex(): number { return this.index; } /** Start a menu with the cursor on `spec.initialIndex` (default: the first entry). */ open(spec: SelectDialogSpec): void { this.spec = spec; this.index = clamp(spec.initialIndex ?? 0, 0, spec.items.length - 1); this.scroll = 0; this.boxRect = undefined; this.ensureVisible(); } /** Drop the current menu without firing a callback. */ close(): void { this.spec = undefined; this.boxRect = undefined; } handleInput(data: string): void { const spec = this.spec; if (!spec) return; if (matchesKeyId(data, "escape") || (spec.closeOnQ && matchesKeyId(data, "q"))) { spec.onCancel(); return; } if (matchesKeyId(data, "return")) { this.activate(); return; } if (matchesKeyId(data, "j") || matchesKeyId(data, "down")) { this.move(1); return; } if (matchesKeyId(data, "k") || matchesKeyId(data, "up")) { this.move(-1); return; } // 快捷选择(确认框的 y / n):直接选中对应的项,不用先移动光标。 for (const [key, index] of Object.entries(spec.shortcuts ?? {})) { if (index >= 0 && index < spec.items.length && matchesKeyId(data, key)) { this.index = index; spec.onSelect(index); return; } } // 其他按键一律吞掉,不能漏到下面的面板去。 } /** Enter(以及鼠标双击):选中光标所在的项。 */ activate(): void { const spec = this.spec; if (spec?.items.length) spec.onSelect(this.index); } /** * Move the cursor by `delta` entries (j / k 和滚轮共用)。 */ move(delta: number): void { const items = this.spec?.items ?? []; const next = clamp(this.index + delta, 0, items.length - 1); if (next === this.index) return; this.index = next; this.ensureVisible(); this.o.onChange(); } /** Put the cursor straight on `index` (clamped) — what a single click does. */ moveTo(index: number): void { const items = this.spec?.items ?? []; if (!items.length) return; const next = clamp(index, 0, items.length - 1); if (next === this.index) return; this.index = next; this.ensureVisible(); this.o.onChange(); } /** * Entry under the cell (x, y), using the box position of the last draw: undefined * on the border, outside the menu, or past the last entry. * * 正文不补白行(画几项就是几行),所以第 row 行就是可见项里的第 row 项。 */ indexAt(x: number, y: number): number | undefined { const cell = this.boxRect ? boxCell(this.boxRect, x, y) : undefined; if (!cell) return undefined; const index = this.scroll + cell.row; return index < (this.spec?.items.length ?? 0) ? index : undefined; } /** Wheel: move the cursor, but only while the pointer is over the menu (like the panes). */ wheel(x: number, y: number, delta: number): void { if (delta !== 0 && this.boxRect && boxContains(this.boxRect, x, y)) this.move(delta); } /** How many entries are drawn at once (all of them, unless `maxRows` caps it). */ private visibleRows(): number { const count = this.spec?.items.length ?? 0; const max = this.spec?.maxRows; return max && max > 0 ? Math.min(count, max) : count; } /** Slide the scroll window so the cursor stays inside it, without showing empty space past the end. */ private ensureVisible(): void { const rows = this.visibleRows(); const count = this.spec?.items.length ?? 0; if (rows <= 0) { this.scroll = 0; return; } if (this.index < this.scroll) this.scroll = this.index; else if (this.index >= this.scroll + rows) this.scroll = this.index - rows + 1; this.scroll = Math.max(0, Math.min(this.scroll, count - rows)); } /** Render the box itself: one row per visible entry plus the borders, every line exactly `width` columns. */ render(width: number): string[] { const { theme } = this.o; const title = this.spec?.title ?? ""; const items = this.spec?.items ?? []; const rows = this.visibleRows(); const inner = width - 2; // tone 覆盖强调色(警告框传 "error":红色的边框 / 标题 / 选中项),默认沿用 accent。 const accentColor = this.spec?.tone ?? "accent"; const borderColor = this.spec?.tone ?? "borderAccent"; const body = items.slice(this.scroll, this.scroll + rows).map((item, k) => { const i = this.scroll + k; const selected = i === this.index; // 选中行:accent(或 tone)色的 › 标记 + 整行选中背景,和三个面板里的光标行一致。 const marker = selected ? "› " : " "; const line = ` ${theme.fg(accentColor, marker)}${selected ? theme.fg(accentColor, item) : theme.fg("text", item)}`; return selected ? theme.bg("selectedBg", fit(line, inner)) : line; }); const meta = truncateToWidth(this.spec?.subject ?? "", metaBudget(width, title) - 3, "…", false); return frame(body, { width, height: rows + 2, title, ...(meta ? { meta } : {}), border: (s) => theme.fg(borderColor, s), titleStyle: (s) => theme.bold(theme.fg(accentColor, s)), metaStyle: (s) => theme.fg("dim", s), }); } /** Composite the box centered over the already-rendered panel `lines`. */ overlay(lines: string[], termW: number): string[] { const width = dialogWidth(termW); const box = this.render(width); // 记下这次画出来的位置:鼠标的点按(indexAt / wheel)只认它,画哪儿就点哪儿。 this.boxRect = centeredRect(termW, lines.length, width, box.length); return overlayCentered(lines, box, width, termW, (s) => this.o.theme.fg("dim", s), this.boxRect.top); } }