import { Input, getKeybindings, truncateToWidth, visibleWidth, type Component, type Focusable, type SettingsListTheme, } from "@earendil-works/pi-tui"; export interface SettingsListChoice { value: string; label: string; } export interface SettingsListItem { id: string; label: string; type: "choice" | "string"; value: string; choices: readonly SettingsListChoice[]; } export interface SettingsListComponentOptions { maxVisible: number; theme: SettingsListTheme; onChange(id: string, value: string | undefined): void; onCancel(): void; } function copyItems(items: readonly SettingsListItem[]): SettingsListItem[] { return items.map((item) => ({ ...item, choices: [...item.choices] })); } export class SettingsListComponent implements Component, Focusable { private items: SettingsListItem[]; private selectedIndex = 0; private readonly maxVisible: number; private readonly options: SettingsListComponentOptions; private editing?: { id: string; input: Input; originalValue: string }; private _focused = false; get focused(): boolean { return this._focused; } set focused(value: boolean) { this._focused = value; if (this.editing) this.editing.input.focused = value; } constructor(items: readonly SettingsListItem[], options: SettingsListComponentOptions) { this.items = copyItems(items); this.maxVisible = Math.max(1, options.maxVisible); this.options = options; } getSelectedItem(): SettingsListItem | undefined { return this.items[this.selectedIndex]; } isEditing(): boolean { return this.editing !== undefined; } setItems(items: readonly SettingsListItem[], preferredId?: string): void { const selectedId = preferredId ?? this.getSelectedItem()?.id; this.items = copyItems(items); if (this.editing && !this.items.some((item) => item.id === this.editing?.id)) this.editing = undefined; const nextIndex = this.items.findIndex((item) => item.id === selectedId); this.selectedIndex = nextIndex < 0 ? 0 : nextIndex; } render(width: number): string[] { if (this.items.length === 0) return []; const startIndex = Math.max( 0, Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.items.length - this.maxVisible), ); const endIndex = Math.min(startIndex + this.maxVisible, this.items.length); const maxLabelWidth = Math.min( 30, Math.max(...this.items.map((item) => visibleWidth(item.label))), ); const lines: string[] = []; for (let index = startIndex; index < endIndex; index++) { const item = this.items[index]!; const selected = index === this.selectedIndex; const prefix = selected ? this.options.theme.cursor : " "; const label = item.label + " ".repeat(Math.max(0, maxLabelWidth - visibleWidth(item.label))); const separator = " "; const valueMaxWidth = width - visibleWidth(prefix) - maxLabelWidth - visibleWidth(separator) - 2; const choice = item.choices.find((candidate) => candidate.value === item.value); const value = this.editing?.id === item.id ? (this.editing.input.render(Math.max(1, valueMaxWidth + 2))[0] ?? "").slice(2) : choice?.label ?? item.value; lines.push(truncateToWidth( prefix + this.options.theme.label(label, selected) + separator + this.options.theme.value( truncateToWidth(value, Math.max(1, valueMaxWidth), ""), selected, ), width, )); } return lines; } invalidate(): void {} handleInput(data: string): void { const keybindings = getKeybindings(); if (this.editing) { if (keybindings.matches(data, "tui.select.confirm")) { this.confirmEdit(); } else if (keybindings.matches(data, "tui.select.cancel")) { this.cancelEdit(); } else { this.editing.input.handleInput(data); } return; } if (keybindings.matches(data, "tui.select.up")) { this.move(-1); } else if (keybindings.matches(data, "tui.select.down")) { this.move(1); } else if (keybindings.matches(data, "tui.select.confirm")) { this.activateSelected(); } else if (data === " " && this.getSelectedItem()?.type === "choice") { this.activateSelected(); } else if (keybindings.matches(data, "tui.select.cancel")) { this.options.onCancel(); } } private move(delta: number): void { if (this.items.length === 0) return; this.selectedIndex = (this.selectedIndex + delta + this.items.length) % this.items.length; } private activateSelected(): void { const item = this.getSelectedItem(); if (!item) return; if (item.type === "string") { this.editing = { id: item.id, input: new Input(), originalValue: item.value }; this.editing.input.setValue(item.value); this.editing.input.handleInput("\x1b[F"); this.editing.input.focused = this.focused; return; } if (item.choices.length === 0) return; const currentIndex = item.choices.findIndex((choice) => choice.value === item.value); const nextIndex = (currentIndex + 1) % item.choices.length; const nextValue = item.choices[nextIndex]!.value; item.value = nextValue; this.options.onChange(item.id, nextValue); } private confirmEdit(): void { const editing = this.editing; if (!editing) return; const value = editing.input.getValue(); this.editing = undefined; const item = this.items.find((candidate) => candidate.id === editing.id); if (item) item.value = value; this.options.onChange(editing.id, value || undefined); } private cancelEdit(): void { const editing = this.editing; if (!editing) return; this.editing = undefined; const item = this.items.find((candidate) => candidate.id === editing.id); if (item) item.value = editing.originalValue; } }