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,
{ enableSearch: true },
);
this.addChild(this.settingsList);

View file

@ -1,6 +1,8 @@
import { fuzzyFilter } from "../fuzzy.js";
import { getEditorKeybindings } from "../keybindings.js";
import type { Component } from "../tui.js";
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils.js";
import { Input } from "./input.js";
export interface SettingItem {
/** Unique identifier for this setting */
@ -25,13 +27,20 @@ export interface SettingsListTheme {
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;
@ -43,12 +52,18 @@ export class SettingsList implements Component {
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 */
@ -75,24 +90,39 @@ export class SettingsList implements Component {
private renderMainList(width: number): string[] {
const lines: string[] = [];
if (this.searchEnabled && this.searchInput) {
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), 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
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];
const item = displayItems[i];
if (!item) continue;
const isSelected = i === this.selectedIndex;
@ -114,13 +144,13 @@ export class SettingsList implements Component {
}
// Add scroll indicator if needed
if (startIndex > 0 || endIndex < this.items.length) {
const scrollText = ` (${this.selectedIndex + 1}/${this.items.length})`;
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 = this.items[this.selectedIndex];
const selectedItem = displayItems[this.selectedIndex];
if (selectedItem?.description) {
lines.push("");
const wrappedDesc = wrapTextWithAnsi(selectedItem.description, width - 4);
@ -130,8 +160,7 @@ export class SettingsList implements Component {
}
// Add hint
lines.push("");
lines.push(this.theme.hint(" Enter/Space to change · Esc to cancel"));
this.addHintLine(lines);
return lines;
}
@ -146,19 +175,29 @@ export class SettingsList implements Component {
// Main list input handling
const kb = getEditorKeybindings();
const displayItems = this.searchEnabled ? this.filteredItems : this.items;
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")) {
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 === " ") {
this.activateItem();
} else if (kb.matches(data, "selectCancel")) {
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 item = this.items[this.selectedIndex];
const item = this.searchEnabled ? this.filteredItems[this.selectedIndex] : this.items[this.selectedIndex];
if (!item) return;
if (item.submenu) {
@ -189,4 +228,20 @@ export class SettingsList implements Component {
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",
),
);
}
}