import { isArrowDown, isArrowUp, isCtrlC, isEnter, isEscape } from "../keys.js"; import type { Component } from "../tui.js"; import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils.js"; 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 */ values?: string[]; /** If provided, Enter opens this submenu. Receives current value and done callback. */ submenu?: (currentValue: string, done: (selectedValue?: string) => void) => Component; } 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 class SettingsList implements Component { private items: SettingItem[]; private theme: SettingsListTheme; private selectedIndex = 0; private maxVisible: number; private onChange: (id: string, newValue: string) => void; private onCancel: () => void; // Submenu state private submenuComponent: Component | null = null; private submenuItemIndex: number | null = null; constructor( items: SettingItem[], maxVisible: number, theme: SettingsListTheme, onChange: (id: string, newValue: string) => void, onCancel: () => void, ) { this.items = items; this.maxVisible = maxVisible; this.theme = theme; this.onChange = onChange; this.onCancel = onCancel; } /** 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.items.length === 0) { lines.push(this.theme.hint(" No settings available")); return lines; } // Calculate visible range with scrolling 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); // 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 = this.items[i]; if (!item) continue; const isSelected = i === this.selectedIndex; 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; 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 < this.items.length) { const scrollText = ` (${this.selectedIndex + 1}/${this.items.length})`; lines.push(this.theme.hint(truncateToWidth(scrollText, width - 2, ""))); } // Add description for selected item const selectedItem = this.items[this.selectedIndex]; if (selectedItem?.description) { lines.push(""); const wrappedDesc = wrapTextWithAnsi(selectedItem.description, width - 4); for (const line of wrappedDesc) { lines.push(this.theme.description(` ${line}`)); } } // Add hint lines.push(""); lines.push(this.theme.hint(" Enter/Space to change ยท Esc to cancel")); return lines; } handleInput(data: string): void { // If submenu is active, delegate all input to it // The submenu's onCancel (triggered by escape) will call done() which closes it if (this.submenuComponent) { this.submenuComponent.handleInput?.(data); return; } // Main list input handling if (isArrowUp(data)) { this.selectedIndex = this.selectedIndex === 0 ? this.items.length - 1 : this.selectedIndex - 1; } else if (isArrowDown(data)) { this.selectedIndex = this.selectedIndex === this.items.length - 1 ? 0 : this.selectedIndex + 1; } else if (isEnter(data) || data === " ") { this.activateItem(); } else if (isEscape(data) || isCtrlC(data)) { this.onCancel(); } } private activateItem(): void { const item = this.items[this.selectedIndex]; if (!item) 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); } } 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; } } }