refactor: extract shared fuzzy filter utilities for list components
This commit is contained in:
parent
a28c271488
commit
f2666d2092
@ -8,72 +8,7 @@ import {
|
|||||||
} from "@mariozechner/pi-tui";
|
} from "@mariozechner/pi-tui";
|
||||||
import type { Component } from "@mariozechner/pi-tui";
|
import type { Component } from "@mariozechner/pi-tui";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
|
import { fuzzyFilterLower, prepareSearchItems } from "./fuzzy-filter.js";
|
||||||
/**
|
|
||||||
* Fuzzy match with pre-lowercased inputs (avoids toLowerCase on every keystroke).
|
|
||||||
* Returns score (lower = better) or null if no match.
|
|
||||||
*/
|
|
||||||
function fuzzyMatchLower(queryLower: string, textLower: string): number | null {
|
|
||||||
if (queryLower.length === 0) return 0;
|
|
||||||
if (queryLower.length > textLower.length) return null;
|
|
||||||
|
|
||||||
let queryIndex = 0;
|
|
||||||
let score = 0;
|
|
||||||
let lastMatchIndex = -1;
|
|
||||||
let consecutiveMatches = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) {
|
|
||||||
if (textLower[i] === queryLower[queryIndex]) {
|
|
||||||
const isWordBoundary = i === 0 || /[\s\-_./:]/.test(textLower[i - 1]);
|
|
||||||
if (lastMatchIndex === i - 1) {
|
|
||||||
consecutiveMatches++;
|
|
||||||
score -= consecutiveMatches * 5;
|
|
||||||
} else {
|
|
||||||
consecutiveMatches = 0;
|
|
||||||
if (lastMatchIndex >= 0) score += (i - lastMatchIndex - 1) * 2;
|
|
||||||
}
|
|
||||||
if (isWordBoundary) score -= 10;
|
|
||||||
score += i * 0.1;
|
|
||||||
lastMatchIndex = i;
|
|
||||||
queryIndex++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return queryIndex < queryLower.length ? null : score;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Filter items using pre-lowercased searchTextLower field.
|
|
||||||
* Supports space-separated tokens (all must match).
|
|
||||||
*/
|
|
||||||
function fuzzyFilterLower<T extends { searchTextLower?: string }>(
|
|
||||||
items: T[],
|
|
||||||
queryLower: string,
|
|
||||||
): T[] {
|
|
||||||
const trimmed = queryLower.trim();
|
|
||||||
if (!trimmed) return items;
|
|
||||||
|
|
||||||
const tokens = trimmed.split(/\s+/).filter((t) => t.length > 0);
|
|
||||||
if (tokens.length === 0) return items;
|
|
||||||
|
|
||||||
const results: { item: T; score: number }[] = [];
|
|
||||||
for (const item of items) {
|
|
||||||
const text = item.searchTextLower ?? "";
|
|
||||||
let totalScore = 0;
|
|
||||||
let allMatch = true;
|
|
||||||
for (const token of tokens) {
|
|
||||||
const score = fuzzyMatchLower(token, text);
|
|
||||||
if (score !== null) {
|
|
||||||
totalScore += score;
|
|
||||||
} else {
|
|
||||||
allMatch = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (allMatch) results.push({ item, score: totalScore });
|
|
||||||
}
|
|
||||||
results.sort((a, b) => a.score - b.score);
|
|
||||||
return results.map((r) => r.item);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FilterableSelectItem extends SelectItem {
|
export interface FilterableSelectItem extends SelectItem {
|
||||||
/** Additional searchable fields beyond label */
|
/** Additional searchable fields beyond label */
|
||||||
@ -102,17 +37,9 @@ export class FilterableSelectList implements Component {
|
|||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
|
|
||||||
constructor(items: FilterableSelectItem[], maxVisible: number, theme: FilterableSelectListTheme) {
|
constructor(items: FilterableSelectItem[], maxVisible: number, theme: FilterableSelectListTheme) {
|
||||||
// Pre-compute searchTextLower for each item once
|
this.allItems = prepareSearchItems(items);
|
||||||
this.allItems = items.map((item) => {
|
|
||||||
if (item.searchTextLower) return item;
|
|
||||||
const parts = [item.label];
|
|
||||||
if (item.description) parts.push(item.description);
|
|
||||||
if (item.searchText) parts.push(item.searchText);
|
|
||||||
return { ...item, searchTextLower: parts.join(" ").toLowerCase() };
|
|
||||||
});
|
|
||||||
this.maxVisible = maxVisible;
|
this.maxVisible = maxVisible;
|
||||||
this.theme = theme;
|
this.theme = theme;
|
||||||
|
|
||||||
this.input = new Input();
|
this.input = new Input();
|
||||||
this.selectList = new SelectList(this.allItems, maxVisible, theme);
|
this.selectList = new SelectList(this.allItems, maxVisible, theme);
|
||||||
}
|
}
|
||||||
|
|||||||
114
src/tui/components/fuzzy-filter.ts
Normal file
114
src/tui/components/fuzzy-filter.ts
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* Shared fuzzy filtering utilities for select list components.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Word boundary characters for matching.
|
||||||
|
*/
|
||||||
|
const WORD_BOUNDARY_CHARS = /[\s\-_./:#@]/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if position is at a word boundary.
|
||||||
|
*/
|
||||||
|
export function isWordBoundary(text: string, index: number): boolean {
|
||||||
|
return index === 0 || WORD_BOUNDARY_CHARS.test(text[index - 1] ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find index where query matches at a word boundary in text.
|
||||||
|
* Returns null if no match.
|
||||||
|
*/
|
||||||
|
export function findWordBoundaryIndex(text: string, query: string): number | null {
|
||||||
|
if (!query) return null;
|
||||||
|
const textLower = text.toLowerCase();
|
||||||
|
const queryLower = query.toLowerCase();
|
||||||
|
const maxIndex = textLower.length - queryLower.length;
|
||||||
|
if (maxIndex < 0) return null;
|
||||||
|
for (let i = 0; i <= maxIndex; i++) {
|
||||||
|
if (textLower.startsWith(queryLower, i) && isWordBoundary(textLower, i)) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fuzzy match with pre-lowercased inputs (avoids toLowerCase on every keystroke).
|
||||||
|
* Returns score (lower = better) or null if no match.
|
||||||
|
*/
|
||||||
|
export function fuzzyMatchLower(queryLower: string, textLower: string): number | null {
|
||||||
|
if (queryLower.length === 0) return 0;
|
||||||
|
if (queryLower.length > textLower.length) return null;
|
||||||
|
|
||||||
|
let queryIndex = 0;
|
||||||
|
let score = 0;
|
||||||
|
let lastMatchIndex = -1;
|
||||||
|
let consecutiveMatches = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) {
|
||||||
|
if (textLower[i] === queryLower[queryIndex]) {
|
||||||
|
const isAtWordBoundary = isWordBoundary(textLower, i);
|
||||||
|
if (lastMatchIndex === i - 1) {
|
||||||
|
consecutiveMatches++;
|
||||||
|
score -= consecutiveMatches * 5; // Reward consecutive matches
|
||||||
|
} else {
|
||||||
|
consecutiveMatches = 0;
|
||||||
|
if (lastMatchIndex >= 0) score += (i - lastMatchIndex - 1) * 2; // Penalize gaps
|
||||||
|
}
|
||||||
|
if (isAtWordBoundary) score -= 10; // Reward word boundary matches
|
||||||
|
score += i * 0.1; // Slight penalty for later matches
|
||||||
|
lastMatchIndex = i;
|
||||||
|
queryIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return queryIndex < queryLower.length ? null : score;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter items using pre-lowercased searchTextLower field.
|
||||||
|
* Supports space-separated tokens (all must match).
|
||||||
|
*/
|
||||||
|
export function fuzzyFilterLower<T extends { searchTextLower?: string }>(
|
||||||
|
items: T[],
|
||||||
|
queryLower: string,
|
||||||
|
): T[] {
|
||||||
|
const trimmed = queryLower.trim();
|
||||||
|
if (!trimmed) return items;
|
||||||
|
|
||||||
|
const tokens = trimmed.split(/\s+/).filter((t) => t.length > 0);
|
||||||
|
if (tokens.length === 0) return items;
|
||||||
|
|
||||||
|
const results: { item: T; score: number }[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
const text = item.searchTextLower ?? "";
|
||||||
|
let totalScore = 0;
|
||||||
|
let allMatch = true;
|
||||||
|
for (const token of tokens) {
|
||||||
|
const score = fuzzyMatchLower(token, text);
|
||||||
|
if (score !== null) {
|
||||||
|
totalScore += score;
|
||||||
|
} else {
|
||||||
|
allMatch = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (allMatch) results.push({ item, score: totalScore });
|
||||||
|
}
|
||||||
|
results.sort((a, b) => a.score - b.score);
|
||||||
|
return results.map((r) => r.item);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepare items for fuzzy filtering by pre-computing lowercase search text.
|
||||||
|
*/
|
||||||
|
export function prepareSearchItems<
|
||||||
|
T extends { label?: string; description?: string; searchText?: string },
|
||||||
|
>(items: T[]): (T & { searchTextLower: string })[] {
|
||||||
|
return items.map((item) => {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (item.label) parts.push(item.label);
|
||||||
|
if (item.description) parts.push(item.description);
|
||||||
|
if (item.searchText) parts.push(item.searchText);
|
||||||
|
return { ...item, searchTextLower: parts.join(" ").toLowerCase() };
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -10,6 +10,7 @@ import {
|
|||||||
truncateToWidth,
|
truncateToWidth,
|
||||||
} from "@mariozechner/pi-tui";
|
} from "@mariozechner/pi-tui";
|
||||||
import { visibleWidth } from "../../terminal/ansi.js";
|
import { visibleWidth } from "../../terminal/ansi.js";
|
||||||
|
import { findWordBoundaryIndex } from "./fuzzy-filter.js";
|
||||||
|
|
||||||
export interface SearchableSelectListTheme extends SelectListTheme {
|
export interface SearchableSelectListTheme extends SelectListTheme {
|
||||||
searchPrompt: (text: string) => string;
|
searchPrompt: (text: string) => string;
|
||||||
@ -81,7 +82,7 @@ export class SearchableSelectList implements Component {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Tier 2: Word-boundary prefix in label (score 100-199)
|
// Tier 2: Word-boundary prefix in label (score 100-199)
|
||||||
const wordBoundaryIndex = this.findWordBoundaryIndex(label, q);
|
const wordBoundaryIndex = findWordBoundaryIndex(label, q);
|
||||||
if (wordBoundaryIndex !== null) {
|
if (wordBoundaryIndex !== null) {
|
||||||
wordBoundary.push({ item, score: wordBoundaryIndex });
|
wordBoundary.push({ item, score: wordBoundaryIndex });
|
||||||
continue;
|
continue;
|
||||||
@ -112,20 +113,6 @@ export class SearchableSelectList implements Component {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private findWordBoundaryIndex(text: string, query: string): number | null {
|
|
||||||
if (!query) return null;
|
|
||||||
const maxIndex = text.length - query.length;
|
|
||||||
if (maxIndex < 0) return null;
|
|
||||||
for (let i = 0; i <= maxIndex; i++) {
|
|
||||||
if (text.startsWith(query, i)) {
|
|
||||||
if (i === 0 || /[\s\-_./:]/.test(text[i - 1] ?? "")) {
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private escapeRegex(str: string): string {
|
private escapeRegex(str: string): string {
|
||||||
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user