Something went wrong. Try again.
Shared settings UI and config loader for pi extensions
Something went wrong. Try again.
14 kB · 449 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450import type { Component } from "@earendil-works/pi-tui";import { Input, Key, matchesKey, type SettingItem, type SettingsListTheme, truncateToWidth, visibleWidth, wrapTextWithAnsi,} from "@earendil-works/pi-tui";
/** * A sectioned settings list. Items are grouped under section headers. * Cursor skips section headers and only lands on items. * * Supports the same SettingItem interface as pi-tui's SettingsList, * including value cycling and submenus. Submenu factories also receive * a `{ requestRender }` context so async submenus can trigger redraws. */
/** Context passed to submenu factories so they can request a redraw or a save. */export interface SettingsSubmenuContext { requestRender: () => void; /** Ask the host to save. No-op outside registerSettingsCommand. */ requestSave?: () => void;}
/** Setting item used by SectionedSettings, with a richer submenu contract. */export type SectionedSettingItem = Omit<SettingItem, "submenu"> & { submenu?: ( currentValue: string, done: (selectedValue?: string) => void, ctx: SettingsSubmenuContext, ) => Component;};
export interface SettingsSection { label: string; items: SectionedSettingItem[];}
export interface SectionedSettingsOptions { enableSearch?: boolean; /** Extra text appended to the hint line (e.g. "Ctrl+S to save"). */ hintSuffix?: string; /** Hide the built-in hint line (when the parent renders its own controls). */ hideHint?: boolean; /** * Render hook for submenus that load data asynchronously. * If omitted, async submenus can still be used, but they cannot request a redraw. */ requestRender?: () => void; /** * Save hook invoked on Ctrl+S. Only useful when SectionedSettings is used * standalone; registerSettingsCommand intercepts Ctrl+S at the root. */ requestSave?: () => void;}
interface FlatEntry { type: "section" | "item"; sectionLabel?: string; item?: SectionedSettingItem;}
export class SectionedSettings implements Component { private sections: SettingsSection[]; private flatEntries: FlatEntry[]; private filteredEntries: FlatEntry[]; private theme: SettingsListTheme; private selectedIndex: number; // index into selectable items only private maxVisible: number; private onChange: (id: string, newValue: string) => void; private onCancel: () => void; private searchInput?: Input; private searchEnabled: boolean; private hintSuffix: string; private hideHint: boolean; private requestRender: () => void; private requestSave: () => void; private submenuComponent: Component | null = null; private submenuItemIndex: number | null = null;
constructor( sections: SettingsSection[], maxVisible: number, theme: SettingsListTheme, onChange: (id: string, newValue: string) => void, onCancel: () => void, options: SectionedSettingsOptions = {}, ) { this.sections = sections; this.maxVisible = maxVisible; this.theme = theme; this.onChange = onChange; this.onCancel = onCancel; this.searchEnabled = options.enableSearch ?? false; this.hintSuffix = options.hintSuffix ?? ""; this.hideHint = options.hideHint ?? false; this.requestRender = options.requestRender ?? (() => {}); this.requestSave = options.requestSave ?? (() => {}); this.selectedIndex = 0;
if (this.searchEnabled) { this.searchInput = new Input(); }
this.flatEntries = this.buildFlatEntries(sections); this.filteredEntries = this.flatEntries; }
private buildFlatEntries(sections: SettingsSection[]): FlatEntry[] { const entries: FlatEntry[] = []; for (const section of sections) { entries.push({ type: "section", sectionLabel: section.label }); for (const item of section.items) { entries.push({ type: "item", item }); } } return entries; }
private getSelectableItems(): SectionedSettingItem[] { return this.filteredEntries .filter((e) => e.type === "item" && e.item) .map((e) => e.item as SectionedSettingItem); }
/** * Replace all sections while preserving the cursor position. * The cursor is restored by matching the previously selected item's ID. * If the item no longer exists, the index is clamped to the valid range. * * Use this instead of creating a new SectionedSettings instance when * only values or item counts change (e.g., after saving a setting). */ updateSections(sections: SettingsSection[]): void { const currentId = this.getSelectableItems()[this.selectedIndex]?.id;
this.sections = sections; this.flatEntries = this.buildFlatEntries(sections); this.filterEntries(this.searchInput?.getValue() ?? "");
// Restore cursor by item ID. if (currentId) { const items = this.getSelectableItems(); const idx = items.findIndex((i) => i.id === currentId); if (idx >= 0) { this.selectedIndex = idx; return; } }
// Fallback: clamp to valid range. const count = this.getSelectableItems().length; this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, count - 1)); }
updateValue(id: string, newValue: string): void { for (const section of this.sections) { const item = section.items.find((i) => i.id === id); if (item) { item.currentValue = newValue; return; } } }
/** Returns true when a submenu is open (caller should not intercept input). */ hasActiveSubmenu(): boolean { return this.submenuComponent !== null; }
invalidate(): void { this.submenuComponent?.invalidate?.(); }
render(width: number): string[] { 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) { lines.push(...this.searchInput.render(width)); lines.push(""); }
const allItems = this.getSelectableItems();
if (allItems.length === 0) { lines.push( this.theme.hint( this.searchEnabled ? " No matching settings" : " No settings available", ), ); this.addHintLine(lines); return lines; }
// Calculate max label width for alignment const maxLabelWidth = Math.min( 30, Math.max(...allItems.map((item) => visibleWidth(item.label))), );
// Build visible entries with their "selectable index" let selectableIdx = -1; const rendered: Array<{ line: string; isSelected: boolean; description?: string; }> = [];
for (const entry of this.filteredEntries) { if (entry.type === "section") { // Section header - add blank line before (except first) if (rendered.length > 0) { rendered.push({ line: "", isSelected: false }); } rendered.push({ line: this.theme.hint(` ${entry.sectionLabel}`), isSelected: false, }); continue; }
const item = entry.item; if (!item) continue;
selectableIdx++; const isSelected = selectableIdx === this.selectedIndex; const prefix = isSelected ? this.theme.cursor : " "; const prefixWidth = visibleWidth(prefix);
const labelPadded = item.label + " ".repeat(Math.max(0, maxLabelWidth - visibleWidth(item.label))); const labelText = this.theme.label(labelPadded, isSelected);
const separator = " "; const usedWidth = prefixWidth + maxLabelWidth + visibleWidth(separator); const valueMaxWidth = width - usedWidth - 2; const valueText = this.theme.value( truncateToWidth(String(item.currentValue ?? ""), valueMaxWidth, ""), isSelected, );
rendered.push({ line: prefix + labelText + separator + valueText, isSelected, description: isSelected ? item.description : undefined, }); }
// Scrolling: find the rendered index of the selected item const selectedRenderedIdx = rendered.findIndex((r) => r.isSelected); const totalLines = rendered.length; const startLine = Math.max( 0, Math.min( selectedRenderedIdx - Math.floor(this.maxVisible / 2), totalLines - this.maxVisible, ), ); const endLine = Math.min(startLine + this.maxVisible, totalLines);
for (let i = startLine; i < endLine; i++) { const r = rendered[i]; if (r) lines.push(r.line); }
// Scroll indicator if (startLine > 0 || endLine < totalLines) { lines.push( this.theme.hint(` (${this.selectedIndex + 1}/${allItems.length})`), ); }
// Description for selected item const selectedItem = allItems[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}`)); } }
if (!this.hideHint) { this.addHintLine(lines); } return lines; }
handleInput(data: string): void { if (this.submenuComponent) { if (matchesKey(data, Key.ctrl("s"))) { this.requestSave(); return; } this.submenuComponent.handleInput?.(data); return; }
const items = this.getSelectableItems();
if (matchesKey(data, Key.ctrl("s"))) { this.requestSave(); } else if (matchesKey(data, Key.up)) { if (items.length === 0) return; this.selectedIndex = this.selectedIndex === 0 ? items.length - 1 : this.selectedIndex - 1; } else if (matchesKey(data, Key.down)) { if (items.length === 0) return; this.selectedIndex = this.selectedIndex === items.length - 1 ? 0 : this.selectedIndex + 1; } else if (matchesKey(data, Key.enter) || data === " ") { this.activateItem(); } else if (matchesKey(data, Key.escape)) { 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 activateItem(): void { const items = this.getSelectableItems(); const item = items[this.selectedIndex]; if (!item) return;
if (item.submenu) { this.submenuItemIndex = this.selectedIndex; this.submenuComponent = item.submenu( item.currentValue, (selectedValue) => { if (selectedValue !== undefined) { item.currentValue = selectedValue; } this.closeSubmenu(); if (selectedValue !== undefined) { this.onChange(item.id, selectedValue); } this.requestRender(); }, { requestRender: this.requestRender, requestSave: this.requestSave }, ); } else if (item.values && item.values.length > 0) { const currentIndex = item.values.indexOf(item.currentValue); const nextIndex = (currentIndex + 1) % item.values.length; const newValue = item.values[nextIndex] as string; item.currentValue = newValue; this.onChange(item.id, newValue); } }
private closeSubmenu(): void { this.submenuComponent = null; if (this.submenuItemIndex !== null) { this.selectedIndex = this.submenuItemIndex; this.submenuItemIndex = null; } }
/** * Apply search filter to entries without resetting the cursor. * Used by updateSections() to preserve selection. * * Matches on both item labels and section labels. When a section label * matches, all items in that section are included. */ private filterEntries(query: string): void { if (!query) { this.filteredEntries = this.flatEntries; return; }
const q = query.toLowerCase(); const filtered: FlatEntry[] = []; let currentSection: FlatEntry | null = null; let sectionLabelMatches = false; let sectionHasMatch = false; let sectionItems: FlatEntry[] = [];
const flushSection = () => { if (sectionLabelMatches && currentSection) { // Section label matched: include header + all items filtered.push(currentSection); filtered.push(...sectionItems); } else if (sectionHasMatch && currentSection) { // Only some items matched: include header + matched items filtered.push(currentSection); for (const item of sectionItems) { if (item.item?.label.toLowerCase().includes(q)) { filtered.push(item); } } } };
for (const entry of this.flatEntries) { if (entry.type === "section") { flushSection(); currentSection = entry; sectionLabelMatches = (entry.sectionLabel ?? "") .toLowerCase() .includes(q); sectionHasMatch = sectionLabelMatches; sectionItems = []; continue; }
if (entry.item) { sectionItems.push(entry); if (entry.item.label.toLowerCase().includes(q)) { sectionHasMatch = true; } } } flushSection();
this.filteredEntries = filtered; }
/** Apply search filter and reset cursor to the first item. */ private applyFilter(query: string): void { this.filterEntries(query); this.selectedIndex = 0; }
private addHintLine(lines: string[]): void { const suffix = this.hintSuffix ? ` \u00B7 ${this.hintSuffix}` : ""; const base = this.searchEnabled ? " Type to search \u00B7 Enter/Space to change \u00B7 Esc to close" : " Enter/Space to change \u00B7 Esc to close"; lines.push(""); lines.push(this.theme.hint(base + suffix)); }}