Files
pi-config/extensions/pi-crew/src/ui/crew-select-list.ts

112 lines
3.9 KiB
TypeScript

import { pad, truncate } from "../utils/visual.ts";
import type { CrewTheme } from "./theme-adapter.ts";
export interface CrewSelectItem<T = string> {
value: T;
label: string;
description?: string;
}
export interface CrewSelectListOptions<T = string> {
onSelect: (item: CrewSelectItem<T>) => void;
onCancel: () => void;
onPreview?: (item: CrewSelectItem<T>) => void;
maxHeight?: number;
}
export class CrewSelectList<T = string> {
private readonly items: CrewSelectItem<T>[];
private readonly theme: CrewTheme;
private readonly options: CrewSelectListOptions<T>;
private selectedIndex = 0;
private scrollOffset = 0;
constructor(items: CrewSelectItem<T>[], theme: CrewTheme, options: CrewSelectListOptions<T>) {
this.items = [...items];
this.theme = theme;
this.options = options;
this.selectedIndex = this.items.length ? 0 : -1;
}
invalidate(): void {}
getSelected(): CrewSelectItem<T> | undefined {
return this.selectedIndex >= 0 ? this.items[this.selectedIndex] : undefined;
}
setSelectedIndex(index: number): void {
if (!this.items.length) {
this.selectedIndex = -1;
this.scrollOffset = 0;
return;
}
const next = Math.min(this.items.length - 1, Math.max(0, index));
const changed = next !== this.selectedIndex;
this.selectedIndex = next;
this.ensureVisible();
if (changed) {
const selected = this.getSelected();
if (selected) this.options.onPreview?.(selected);
}
}
handleInput(data: string): void {
if (data === "q" || data === "\u001b") {
this.options.onCancel();
return;
}
if (data === "j" || data === "\u001b[B") {
this.setSelectedIndex(this.selectedIndex + 1);
return;
}
if (data === "k" || data === "\u001b[A") {
this.setSelectedIndex(this.selectedIndex - 1);
return;
}
if (data === "\r" || data === "\n") {
const selected = this.getSelected();
if (selected) this.options.onSelect(selected);
}
}
render(width: number): string[] {
if (!this.items.length) return [this.theme.fg("muted", "(no items)")];
const maxHeight = Math.max(1, Math.floor(this.options.maxHeight ?? this.items.length));
this.ensureVisible();
const hasTop = this.scrollOffset > 0;
const availableWithoutBottom = Math.max(1, maxHeight - (hasTop ? 1 : 0));
const hasBottom = this.scrollOffset + availableWithoutBottom < this.items.length;
const slots = this.visibleItemSlots(maxHeight, hasTop, hasBottom);
const visibleItems = this.items.slice(this.scrollOffset, this.scrollOffset + slots);
const lines: string[] = [];
if (hasTop) lines.push(this.theme.fg("muted", `${this.scrollOffset} more`));
for (const [offset, item] of visibleItems.entries()) {
const index = this.scrollOffset + offset;
const prefix = index === this.selectedIndex ? " → " : " ";
const suffix = item.description ? this.theme.fg("dim", `${item.description}`) : "";
const raw = `${prefix}${item.label}${suffix}`;
const line = index === this.selectedIndex ? this.theme.inverse?.(raw) ?? raw : raw;
lines.push(pad(truncate(line, width, "..."), Math.max(1, width)));
}
if (hasBottom) lines.push(this.theme.fg("muted", `${this.items.length - (this.scrollOffset + slots)} more`));
return lines.slice(0, maxHeight);
}
private visibleItemSlots(maxHeight: number, hasTop: boolean, hasBottom: boolean): number {
return Math.max(1, maxHeight - (hasTop ? 1 : 0) - (hasBottom ? 1 : 0));
}
private ensureVisible(): void {
if (this.selectedIndex < 0) return;
const maxHeight = Math.max(1, Math.floor(this.options.maxHeight ?? this.items.length));
const reservedTop = this.scrollOffset > 0 ? 1 : 0;
const visibleSlots = Math.max(1, maxHeight - reservedTop - 1);
if (this.selectedIndex < this.scrollOffset) {
this.scrollOffset = this.selectedIndex;
} else if (this.selectedIndex >= this.scrollOffset + visibleSlots) {
this.scrollOffset = Math.max(0, this.selectedIndex - visibleSlots + 1);
}
this.scrollOffset = Math.min(this.scrollOffset, Math.max(0, this.items.length - 1));
}
}