Quelle searchable-select-list.ts
Sprache: JAVA
import {
type Component,
Input,
isKeyRelease,
matchesKey,
type SelectItem,
type SelectListTheme,
truncateToWidth,
} from "@mariozechner/pi-tui" ;
import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js" ;
import { stripAnsi, visibleWidth } from "../../terminal/ansi.js" ;
import { findWordBoundaryIndex, fuzzyFilterLower } from "./fuzzy-filter.js" ;
const ANSI_ESCAPE = String.fromCharCode(27 );
const ANSI_SGR_REGEX = new RegExp(`${ANSI_ESCAPE}\\[[0 -9 ;]*m`, "g" );
export interface SearchableSelectListTheme extends SelectListTheme {
searchPrompt: (text: string) => string;
searchInput: (text: string) => string;
matchHighlight: (text: string) => string;
}
/**
* A select list with a search input at the top for fuzzy filtering.
*/
export class SearchableSelectList implements Component {
private items: SelectItem[];
private filteredItems: SelectItem[];
private selectedIndex = 0 ;
private maxVisible: number;
private theme: SearchableSelectListTheme;
private searchInput: Input;
private regexCache = new Map<string, RegExp>();
onSelect?: (item: SelectItem) => void ;
onCancel?: () => void ;
onSelectionChange?: (item: SelectItem) => void ;
private static readonly DESCRIPTION_LAYOUT_MIN_WIDTH = 40 ;
private static readonly DESCRIPTION_MIN_WIDTH = 12 ;
private static readonly DESCRIPTION_SPACING_WIDTH = 2 ;
// Keep a small right margin so we don't risk wrapping due to styling/terminal quirks.
private static readonly RIGHT_MARGIN_WIDTH = 2 ;
constructor(items: SelectItem[], maxVisible: number, theme: SearchableSelectListTheme) {
this .items = items;
this .filteredItems = items;
this .maxVisible = maxVisible;
this .theme = theme;
this .searchInput = new Input();
}
private getCachedRegex(pattern: string): RegExp {
let regex = this .regexCache.get(pattern);
if (!regex) {
regex = new RegExp(this .escapeRegex(pattern), "gi" );
this .regexCache.set(pattern, regex);
}
return regex;
}
private updateFilter() {
const query = this .searchInput.getValue().trim();
if (!query) {
this .filteredItems = this .items;
} else {
this .filteredItems = this .smartFilter(query);
}
// Reset selection when filter changes
this .selectedIndex = 0 ;
this .notifySelectionChange();
}
/**
* Smart filtering that prioritizes:
* 1. Exact substring match in label (highest priority)
* 2. Word-boundary prefix match in label
* 3. Exact substring in description
* 4. Fuzzy match (lowest priority)
*/
private smartFilter(query: string): SelectItem[] {
const q = normalizeLowercaseStringOrEmpty(query);
type ScoredItem = { item: SelectItem; tier: number; score: number };
type FuzzyCandidate = { item: SelectItem; searchTextLower: string };
const scoredItems: ScoredItem[] = [];
const fuzzyCandidates: FuzzyCandidate[] = [];
for (const item of this .items) {
const rawLabel = this .getItemLabel(item);
const rawDesc = item.description ?? "" ;
const label = normalizeLowercaseStringOrEmpty(stripAnsi(rawLabel));
const desc = normalizeLowercaseStringOrEmpty(stripAnsi(rawDesc));
// Tier 1: Exact substring in label
const labelIndex = label.indexOf(q);
if (labelIndex !== -1 ) {
scoredItems.push({ item, tier: 0 , score: labelIndex });
continue ;
}
// Tier 2: Word-boundary prefix in label
const wordBoundaryIndex = findWordBoundaryIndex(label, q);
if (wordBoundaryIndex !== null ) {
scoredItems.push({ item, tier: 1 , score: wordBoundaryIndex });
continue ;
}
// Tier 3: Exact substring in description
const descIndex = desc.indexOf(q);
if (descIndex !== -1 ) {
scoredItems.push({ item, tier: 2 , score: descIndex });
continue ;
}
// Tier 4: Fuzzy match (score 300+)
const searchText = (item as { searchText?: string }).searchText ?? "" ;
fuzzyCandidates.push({
item,
searchTextLower: normalizeLowercaseStringOrEmpty(
[rawLabel, rawDesc, searchText]
.map((value) => stripAnsi(value))
.filter(Boolean )
.join(" " ),
),
});
}
scoredItems.sort(this .compareByScore);
const fuzzyMatches = fuzzyFilterLower(fuzzyCandidates, q);
return [...scoredItems.map((s) => s.item), ...fuzzyMatches.map((entry) => entry.item)];
}
private escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&" );
}
private compareByScore = (
a: { item: SelectItem; tier: number; score: number },
b: { item: SelectItem; tier: number; score: number },
) => {
if (a.tier !== b.tier) {
return a.tier - b.tier;
}
if (a.score !== b.score) {
return a.score - b.score;
}
return this .getItemLabel(a.item).localeCompare(this .getItemLabel(b.item));
};
private getItemLabel(item: SelectItem): string {
return item.label || item.value;
}
private splitAnsiParts(text: string): Array<{ text: string; isAnsi: boolean }> {
const parts: Array<{ text: string; isAnsi: boolean }> = [];
ANSI_SGR_REGEX.lastIndex = 0 ;
let lastIndex = 0 ;
let match: RegExpExecArray | null ;
while ((match = ANSI_SGR_REGEX.exec(text)) !== null ) {
if (match.index > lastIndex) {
parts.push({ text: text.slice(lastIndex, match.index), isAnsi: false });
}
parts.push({ text: match[0 ], isAnsi: true });
lastIndex = match.index + match[0 ].length;
}
if (lastIndex < text.length) {
parts.push({ text: text.slice(lastIndex), isAnsi: false });
}
return parts;
}
private highlightMatch(text: string, query: string): string {
const tokens = query
.trim()
.split(/\s+/)
.map((token) => normalizeLowercaseStringOrEmpty(token))
.filter((token) => token.length > 0 );
if (tokens.length === 0 ) {
return text;
}
const uniqueTokens = Array.from(new Set(tokens)).toSorted((a, b) => b.length - a.length);
let parts = this .splitAnsiParts(text);
for (const token of uniqueTokens) {
const regex = this .getCachedRegex(token);
const nextParts: Array<{ text: string; isAnsi: boolean }> = [];
for (const part of parts) {
if (part.isAnsi) {
nextParts.push(part);
continue ;
}
regex.lastIndex = 0 ;
const replaced = part.text.replace(regex, (match) => this .theme.matchHighlight(match));
if (replaced === part.text) {
nextParts.push(part);
continue ;
}
nextParts.push(...this .splitAnsiParts(replaced));
}
parts = nextParts;
}
return parts.map((part) => part.text).join("" );
}
setSelectedIndex(index: number) {
this .selectedIndex = Math.max(0 , Math.min(index, this .filteredItems.length - 1 ));
}
invalidate() {
this .searchInput.invalidate();
}
render(width: number): string[] {
const lines: string[] = [];
// Search input line
const promptText = "search: " ;
const prompt = this .theme.searchPrompt(promptText);
const inputWidth = Math.max(1 , width - visibleWidth(prompt));
const inputLines = this .searchInput.render(inputWidth);
const inputText = inputLines[0 ] ?? "" ;
lines.push(`${prompt}${this .theme.searchInput(inputText)}`);
lines.push("" ); // Spacer
const query = this .searchInput.getValue().trim();
// If no items match filter, show message
if (this .filteredItems.length === 0 ) {
lines.push(this .theme.noMatch(" No matches" ));
return lines;
}
// Calculate visible range with scrolling
const startIndex = Math.max(
0 ,
Math.min(
this .selectedIndex - Math.floor(this .maxVisible / 2 ),
this .filteredItems.length - this .maxVisible,
),
);
const endIndex = Math.min(startIndex + this .maxVisible, this .filteredItems.length);
// Render visible items
for (let i = startIndex; i < endIndex; i++) {
const item = this .filteredItems[i];
if (!item) {
continue ;
}
const isSelected = i === this .selectedIndex;
lines.push(this .renderItemLine(item, isSelected, width, query));
}
// Show scroll indicator if needed
if (this .filteredItems.length > this .maxVisible) {
const scrollInfo = `${this .selectedIndex + 1 }/${this .filteredItems.length}`;
lines.push(this .theme.scrollInfo(` ${scrollInfo}`));
}
return lines;
}
private renderItemLine(
item: SelectItem,
isSelected: boolean ,
width: number,
query: string,
): string {
const prefix = isSelected ? "→ " : " " ;
const prefixWidth = prefix.length;
const displayValue = this .getItemLabel(item);
const description = item.description;
if (description) {
const descriptionLayout = this .getDescriptionLayout(width, prefixWidth);
if (descriptionLayout) {
const truncatedValue = truncateToWidth(displayValue, descriptionLayout.maxValueWidth, "" );
const valueText = this .highlightMatch(truncatedValue, query);
const usedByValue = visibleWidth(valueText);
const remainingWidth = descriptionLayout.availableWidth - usedByValue;
const descriptionWidth = remainingWidth - descriptionLayout.spacingWidth;
if (descriptionWidth >= SearchableSelectList.DESCRIPTION_MIN_WIDTH) {
const spacing = " " .repeat(descriptionLayout.spacingWidth);
const truncatedDesc = truncateToWidth(description, descriptionWidth, "" );
// Highlight plain text first, then apply theme styling to avoid corrupting ANSI codes
const highlightedDesc = this .highlightMatch(truncatedDesc, query);
const descText = isSelected ? highlightedDesc : this .theme.description(highlightedDesc);
const line = `${prefix}${valueText}${spacing}${descText}`;
return isSelected ? this .theme.selectedText(line) : line;
}
}
}
const maxWidth = width - prefixWidth - 2 ;
const truncatedValue = truncateToWidth(displayValue, maxWidth, "" );
const valueText = this .highlightMatch(truncatedValue, query);
const line = `${prefix}${valueText}`;
return isSelected ? this .theme.selectedText(line) : line;
}
private getDescriptionLayout(
width: number,
prefixWidth: number,
): { availableWidth: number; maxValueWidth: number; spacingWidth: number } | null {
if (width <= SearchableSelectList.DESCRIPTION_LAYOUT_MIN_WIDTH) {
return null ;
}
const availableWidth = Math.max(
1 ,
width - prefixWidth - SearchableSelectList.RIGHT_MARGIN_WIDTH,
);
const maxValueWidth =
availableWidth -
SearchableSelectList.DESCRIPTION_MIN_WIDTH -
SearchableSelectList.DESCRIPTION_SPACING_WIDTH;
if (maxValueWidth < 1 ) {
return null ;
}
return {
availableWidth,
maxValueWidth,
spacingWidth: SearchableSelectList.DESCRIPTION_SPACING_WIDTH,
};
}
handleInput(keyData: string): void {
if (isKeyRelease(keyData)) {
return ;
}
// Navigation keys
if (matchesKey(keyData, "up" ) || matchesKey(keyData, "ctrl+p" )) {
this .selectedIndex = Math.max(0 , this .selectedIndex - 1 );
this .notifySelectionChange();
return ;
}
if (matchesKey(keyData, "down" ) || matchesKey(keyData, "ctrl+n" )) {
this .selectedIndex = Math.min(this .filteredItems.length - 1 , this .selectedIndex + 1 );
this .notifySelectionChange();
return ;
}
if (matchesKey(keyData, "enter" )) {
const item = this .filteredItems[this .selectedIndex];
if (item && this .onSelect) {
this .onSelect(item);
}
return ;
}
if (matchesKey(keyData, "escape" ) || keyData === "\u0003" ) {
if (this .onCancel) {
this .onCancel();
}
return ;
}
// Pass other keys to search input
const prevValue = this .searchInput.getValue();
this .searchInput.handleInput(keyData);
const newValue = this .searchInput.getValue();
if (prevValue !== newValue) {
this .updateFilter();
}
}
private notifySelectionChange() {
const item = this .filteredItems[this .selectedIndex];
if (item && this .onSelectionChange) {
this .onSelectionChange(item);
}
}
getSelectedItem(): SelectItem | null {
return this .filteredItems[this .selectedIndex] ?? null ;
}
}
Messung V0.5 in Prozent C=95 H=96 G=95
¤ Dauer der Verarbeitung: 0.16 Sekunden
(vorverarbeitet am 2026-05-26)
¤
*© Formatika GbR, Deutschland
2026-05-26