Add fuzzy search to settings list

This commit is contained in:
ninlds 2026-01-11 20:10:03 -03:00 committed by Mario Zechner
parent 0138eee6f7
commit ec2b7b5a00
2 changed files with 67 additions and 11 deletions

View file

@ -309,6 +309,7 @@ export class SettingsSelectorComponent extends Container {
} }
}, },
callbacks.onCancel, callbacks.onCancel,
{ enableSearch: true },
); );
this.addChild(this.settingsList); this.addChild(this.settingsList);

View file

@ -1,6 +1,8 @@
import { fuzzyFilter } from "../fuzzy.js";
import { getEditorKeybindings } from "../keybindings.js"; import { getEditorKeybindings } from "../keybindings.js";
import type { Component } from "../tui.js"; import type { Component } from "../tui.js";
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils.js"; import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils.js";
import { Input } from "./input.js";
export interface SettingItem { export interface SettingItem {
/** Unique identifier for this setting */ /** Unique identifier for this setting */
@ -25,13 +27,20 @@ export interface SettingsListTheme {
hint: (text: string) => string; hint: (text: string) => string;
} }
export interface SettingsListOptions {
enableSearch?: boolean;
}
export class SettingsList implements Component { export class SettingsList implements Component {
private items: SettingItem[]; private items: SettingItem[];
private filteredItems: SettingItem[];
private theme: SettingsListTheme; private theme: SettingsListTheme;
private selectedIndex = 0; private selectedIndex = 0;
private maxVisible: number; private maxVisible: number;
private onChange: (id: string, newValue: string) => void; private onChange: (id: string, newValue: string) => void;
private onCancel: () => void; private onCancel: () => void;
private searchInput?: Input;
private searchEnabled: boolean;
// Submenu state // Submenu state
private submenuComponent: Component | null = null; private submenuComponent: Component | null = null;
@ -43,12 +52,18 @@ export class SettingsList implements Component {
theme: SettingsListTheme, theme: SettingsListTheme,
onChange: (id: string, newValue: string) => void, onChange: (id: string, newValue: string) => void,
onCancel: () => void, onCancel: () => void,
options: SettingsListOptions = {},
) { ) {
this.items = items; this.items = items;
this.filteredItems = items;
this.maxVisible = maxVisible; this.maxVisible = maxVisible;
this.theme = theme; this.theme = theme;
this.onChange = onChange; this.onChange = onChange;
this.onCancel = onCancel; this.onCancel = onCancel;
this.searchEnabled = options.enableSearch ?? false;
if (this.searchEnabled) {
this.searchInput = new Input();
}
} }
/** Update an item's currentValue */ /** Update an item's currentValue */
@ -75,24 +90,39 @@ export class SettingsList implements Component {
private renderMainList(width: number): string[] { private renderMainList(width: number): string[] {
const lines: string[] = []; const lines: string[] = [];
if (this.searchEnabled && this.searchInput) {
lines.push(...this.searchInput.render(width));
lines.push("");
}
if (this.items.length === 0) { if (this.items.length === 0) {
lines.push(this.theme.hint(" No settings available")); 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; return lines;
} }
// Calculate visible range with scrolling // Calculate visible range with scrolling
const startIndex = Math.max( const startIndex = Math.max(
0, 0,
Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.items.length - this.maxVisible), Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), displayItems.length - this.maxVisible),
); );
const endIndex = Math.min(startIndex + this.maxVisible, this.items.length); const endIndex = Math.min(startIndex + this.maxVisible, displayItems.length);
// Calculate max label width for alignment // Calculate max label width for alignment
const maxLabelWidth = Math.min(30, Math.max(...this.items.map((item) => visibleWidth(item.label)))); const maxLabelWidth = Math.min(30, Math.max(...this.items.map((item) => visibleWidth(item.label))));
// Render visible items // Render visible items
for (let i = startIndex; i < endIndex; i++) { for (let i = startIndex; i < endIndex; i++) {
const item = this.items[i]; const item = displayItems[i];
if (!item) continue; if (!item) continue;
const isSelected = i === this.selectedIndex; const isSelected = i === this.selectedIndex;
@ -114,13 +144,13 @@ export class SettingsList implements Component {
} }
// Add scroll indicator if needed // Add scroll indicator if needed
if (startIndex > 0 || endIndex < this.items.length) { if (startIndex > 0 || endIndex < displayItems.length) {
const scrollText = ` (${this.selectedIndex + 1}/${this.items.length})`; const scrollText = ` (${this.selectedIndex + 1}/${displayItems.length})`;
lines.push(this.theme.hint(truncateToWidth(scrollText, width - 2, ""))); lines.push(this.theme.hint(truncateToWidth(scrollText, width - 2, "")));
} }
// Add description for selected item // Add description for selected item
const selectedItem = this.items[this.selectedIndex]; const selectedItem = displayItems[this.selectedIndex];
if (selectedItem?.description) { if (selectedItem?.description) {
lines.push(""); lines.push("");
const wrappedDesc = wrapTextWithAnsi(selectedItem.description, width - 4); const wrappedDesc = wrapTextWithAnsi(selectedItem.description, width - 4);
@ -130,8 +160,7 @@ export class SettingsList implements Component {
} }
// Add hint // Add hint
lines.push(""); this.addHintLine(lines);
lines.push(this.theme.hint(" Enter/Space to change · Esc to cancel"));
return lines; return lines;
} }
@ -146,19 +175,29 @@ export class SettingsList implements Component {
// Main list input handling // Main list input handling
const kb = getEditorKeybindings(); const kb = getEditorKeybindings();
const displayItems = this.searchEnabled ? this.filteredItems : this.items;
if (kb.matches(data, "selectUp")) { if (kb.matches(data, "selectUp")) {
this.selectedIndex = this.selectedIndex === 0 ? this.items.length - 1 : this.selectedIndex - 1; if (displayItems.length === 0) return;
this.selectedIndex = this.selectedIndex === 0 ? displayItems.length - 1 : this.selectedIndex - 1;
} else if (kb.matches(data, "selectDown")) { } else if (kb.matches(data, "selectDown")) {
this.selectedIndex = this.selectedIndex === this.items.length - 1 ? 0 : this.selectedIndex + 1; if (displayItems.length === 0) return;
this.selectedIndex = this.selectedIndex === displayItems.length - 1 ? 0 : this.selectedIndex + 1;
} else if (kb.matches(data, "selectConfirm") || data === " ") { } else if (kb.matches(data, "selectConfirm") || data === " ") {
this.activateItem(); this.activateItem();
} else if (kb.matches(data, "selectCancel")) { } else if (kb.matches(data, "selectCancel")) {
this.onCancel(); 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 { private activateItem(): void {
const item = this.items[this.selectedIndex]; const item = this.searchEnabled ? this.filteredItems[this.selectedIndex] : this.items[this.selectedIndex];
if (!item) return; if (!item) return;
if (item.submenu) { if (item.submenu) {
@ -189,4 +228,20 @@ export class SettingsList implements Component {
this.submenuItemIndex = null; 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("");
lines.push(
this.theme.hint(
this.searchEnabled
? " Type to search · Enter/Space to change · Esc to cancel"
: " Enter/Space to change · Esc to cancel",
),
);
}
} }