/** * Settings list component with support for cycling values and string input. * Based on @earendil-works/pi-tui SettingsList, extended with string editing. */ import { type Component, fuzzyFilter, getKeybindings, Input, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, } from "@earendil-works/pi-tui"; export interface SettingItem { /** Unique identifier for this setting */ id: string; /** Display label (left side) */ label: string; /** Optional description shown when selected */ description?: string; /** Current value to display (right side) */ currentValue: string; /** * If provided, Enter/Space cycles through these values. * If undefined/empty, the setting is treated as a string input. */ values?: string[]; /** If provided, Enter opens this submenu. Receives current value and done callback. */ submenu?: (currentValue: string, done: (selectedValue?: string) => void) => Component; /** If false, item is selectable but cannot be edited or changed. */ editable?: boolean; } export interface SettingsListTheme { label: (text: string, selected: boolean) => string; value: (text: string, selected: boolean) => string; description: (text: string) => string; cursor: string; hint: (text: string) => string; } export interface SettingsListOptions { enableSearch?: boolean; } export class SettingsList implements Component { private items: SettingItem[]; private filteredItems: SettingItem[]; private theme: SettingsListTheme; private selectedIndex = 0; private maxVisible: number; private onChange: (id: string, newValue: string) => void; private onCancel: () => void; private searchInput?: Input; private searchEnabled: boolean; // Submenu state private submenuComponent: Component | null = null; private submenuItemIndex: number | null = null; // String editing state private editingInput: Input | null = null; private editingItemIndex: number | null = null; constructor( items: SettingItem[], maxVisible: number, theme: SettingsListTheme, onChange: (id: string, newValue: string) => void, onCancel: () => void, options: SettingsListOptions = {}, ) { this.items = items; this.filteredItems = items; this.maxVisible = maxVisible; this.theme = theme; this.onChange = onChange; this.onCancel = onCancel; this.searchEnabled = options.enableSearch ?? false; if (this.searchEnabled) { this.searchInput = new Input(); } } /** Update an item's currentValue */ updateValue(id: string, newValue: string): void { const item = this.items.find((i) => i.id === id); if (item) { item.currentValue = newValue; } } invalidate(): void { this.submenuComponent?.invalidate?.(); } render(width: number): string[] { // If submenu is active, render it instead if (this.submenuComponent) { return this.submenuComponent.render(width); } return this.renderMainList(width); } private renderMainList(width: number): string[] { const lines: string[] = []; if (this.searchEnabled && this.searchInput && !this.editingInput) { lines.push(...this.searchInput.render(width)); lines.push(""); } if (this.items.length === 0) { lines.push(this.theme.hint(" No settings available")); if (this.searchEnabled) { this.addHintLine(lines); } return lines; } const displayItems = this.searchEnabled ? this.filteredItems : this.items; if (displayItems.length === 0) { lines.push(this.theme.hint(" No matching settings")); this.addHintLine(lines); return lines; } // Calculate visible range with scrolling const startIndex = Math.max( 0, Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), displayItems.length - this.maxVisible), ); const endIndex = Math.min(startIndex + this.maxVisible, displayItems.length); // Calculate max label width for alignment const maxLabelWidth = Math.min(30, Math.max(...this.items.map((item) => visibleWidth(item.label)))); // Render visible items for (let i = startIndex; i < endIndex; i++) { const item = displayItems[i]; if (!item) continue; const isSelected = i === this.selectedIndex; const isEditing = this.editingInput && this.editingItemIndex === i; const prefix = isSelected ? this.theme.cursor : " "; const prefixWidth = visibleWidth(prefix); // Pad label to align values const labelPadded = item.label + " ".repeat(Math.max(0, maxLabelWidth - visibleWidth(item.label))); const labelText = this.theme.label(labelPadded, isSelected); // Calculate space for value const separator = " "; const usedWidth = prefixWidth + maxLabelWidth + visibleWidth(separator); const valueMaxWidth = width - usedWidth - 2; if (isEditing && this.editingInput) { // Render inline input for string editing const inputLines = this.editingInput.render(valueMaxWidth); const inputLine = inputLines[0] ?? ""; lines.push(prefix + labelText + separator + inputLine); } else { const valueText = this.theme.value(truncateToWidth(item.currentValue, valueMaxWidth, ""), isSelected); lines.push(prefix + labelText + separator + valueText); } } // Add scroll indicator if needed if (startIndex > 0 || endIndex < displayItems.length) { const scrollText = ` (${this.selectedIndex + 1}/${displayItems.length})`; lines.push(this.theme.hint(truncateToWidth(scrollText, width - 2, ""))); } // Add description for selected item const selectedItem = displayItems[this.selectedIndex]; if (selectedItem?.description && !this.editingInput) { lines.push(""); const wrappedDesc = wrapTextWithAnsi(selectedItem.description, width - 4); for (const line of wrappedDesc) { lines.push(this.theme.description(` ${line}`)); } } // Add hint this.addHintLine(lines); return lines; } handleInput(data: string): void { // If editing a string value, handle input for the editor if (this.editingInput) { this.handleEditingInput(data); return; } // If submenu is active, delegate all input to it if (this.submenuComponent) { this.submenuComponent.handleInput?.(data); return; } // Main list input handling const kb = getKeybindings(); const displayItems = this.searchEnabled ? this.filteredItems : this.items; if (kb.matches(data, "tui.select.up")) { if (displayItems.length === 0) return; this.selectedIndex = this.selectedIndex === 0 ? displayItems.length - 1 : this.selectedIndex - 1; } else if (kb.matches(data, "tui.select.down")) { if (displayItems.length === 0) return; this.selectedIndex = this.selectedIndex === displayItems.length - 1 ? 0 : this.selectedIndex + 1; } else if (kb.matches(data, "tui.select.confirm") || data === " ") { this.activateItem(); } else if (kb.matches(data, "tui.select.cancel")) { this.onCancel(); } else if (this.searchEnabled && this.searchInput) { const sanitized = data.replace(/ /g, ""); if (!sanitized) { return; } this.searchInput.handleInput(sanitized); this.applyFilter(this.searchInput.getValue()); } } private handleEditingInput(data: string): void { if (!this.editingInput) return; // Enter: confirm edit if (matchesKey(data, "enter")) { this.confirmEdit(); return; } // Escape: cancel edit if (matchesKey(data, "escape")) { this.cancelEdit(); return; } // Pass other input to the editor this.editingInput.handleInput(data); } private confirmEdit(): void { if (!this.editingInput || this.editingItemIndex === null) return; const displayItems = this.searchEnabled ? this.filteredItems : this.items; const item = displayItems[this.editingItemIndex]; if (item) { const newValue = this.editingInput.getValue(); item.currentValue = newValue; this.onChange(item.id, newValue); } this.editingInput = null; this.editingItemIndex = null; } private cancelEdit(): void { this.editingInput = null; this.editingItemIndex = null; } private activateItem(): void { const displayItems = this.searchEnabled ? this.filteredItems : this.items; const item = displayItems[this.selectedIndex]; if (!item) return; if (item.editable === false) { return; } if (item.submenu) { // Open submenu, passing current value so it can pre-select correctly this.submenuItemIndex = this.selectedIndex; this.submenuComponent = item.submenu(item.currentValue, (selectedValue?: string) => { if (selectedValue !== undefined) { item.currentValue = selectedValue; this.onChange(item.id, selectedValue); } this.closeSubmenu(); }); } else if (item.values && item.values.length > 0) { // Cycle through values const currentIndex = item.values.indexOf(item.currentValue); const nextIndex = (currentIndex + 1) % item.values.length; const newValue = item.values[nextIndex]; item.currentValue = newValue; this.onChange(item.id, newValue); } else { // String input - start editing this.editingItemIndex = this.selectedIndex; this.editingInput = new Input(); this.editingInput.setValue(item.currentValue); } } private closeSubmenu(): void { this.submenuComponent = null; // Restore selection to the item that opened the submenu if (this.submenuItemIndex !== null) { this.selectedIndex = this.submenuItemIndex; this.submenuItemIndex = null; } } private applyFilter(query: string): void { this.filteredItems = fuzzyFilter(this.items, query, (item) => item.label); this.selectedIndex = 0; } private addHintLine(lines: string[]): void { lines.push(""); if (this.editingInput) { lines.push(this.theme.hint(" Enter to confirm · Esc to cancel")); } else if (this.searchEnabled) { lines.push(this.theme.hint(" Type to search · Enter/Space to change · Esc to cancel")); } else { lines.push(this.theme.hint(" Enter/Space to change · Esc to cancel")); } } }