/** * @factory/ui — command palette: PURE filtering + keyboard navigation. * * Two pieces, both fully testable without React/DOM: * - {@link scoreCommand}/{@link filterCommands}: fuzzy SUBSEQUENCE scoring — * every query character must appear in order inside the target; consecutive * matches and word-boundary matches score higher. Ties break by ORIGINAL * INDEX (stable, deterministic). * - {@link commandNav}: a reducer over the selection state — ArrowUp/Down * (wrapping), Home/End, Enter (select), Escape (close). * * The .tsx `CommandPalette` wires query input + nav state to this module. */ export interface CommandItem { id: string; /** Primary label shown in the palette (match target). */ title: string; /** Optional secondary text (shortcut, hint) — not a match target. */ hint?: string; } export interface ScoredCommand { item: CommandItem; score: number; } const BONUS_CONTIGUOUS = 4; const BONUS_WORD_START = 2; /** * Fuzzy subsequence score of `query` against `target`. Returns -1 when the * query is NOT a subsequence (case-insensitive). Scoring: * - +1 per matched character * - +4 when a match continues the previous match position (contiguous run) * - +2 when a match starts a word (boundary: start of string or after space) * - -0.1 per gap character between matches (prefers tighter matches) * Empty query matches everything with score 0. */ export function scoreCommand(query: string, target: string): number { const q = query.trim().toLowerCase(); const t = target.toLowerCase(); if (q.length === 0) return 0; let score = 0; let ti = 0; let prevMatch = -2; for (let qi = 0; qi < q.length; qi += 1) { const ch = q[qi]; let found = -1; while (ti < t.length) { if (t[ti] === ch) { found = ti; break; } ti += 1; } if (found < 0) return -1; // query char missing → not a subsequence score += 1; if (found === prevMatch + 1) score += BONUS_CONTIGUOUS; if (found === 0 || t[found - 1] === " ") score += BONUS_WORD_START; if (found > prevMatch + 1) score -= (found - prevMatch - 1) * 0.1; prevMatch = found; ti = found + 1; } return score; } /** * Filter + rank items against a query. Deterministic: sorted by score * descending, ties by original index ascending. Items scoring < 0 are dropped. */ export function filterCommands(items: readonly CommandItem[], query: string): ScoredCommand[] { const scored: Array = []; for (let i = 0; i < items.length; i += 1) { const item = items[i]; if (item === undefined) continue; const score = scoreCommand(query, item.title); if (score >= 0) scored.push({ item, score, index: i }); } // Deterministic: score descending, ties by ORIGINAL INDEX ascending. scored.sort((a, b) => b.score - a.score || a.index - b.index); return scored.map(({ index: _index, ...rest }) => rest); } export interface CommandNavState { /** Currently highlighted index; -1 = nothing highlighted. */ active: number; /** Number of visible results (clamped by the caller). */ count: number; open: boolean; } export type CommandNavKey = "ArrowUp" | "ArrowDown" | "Enter" | "Escape" | "Home" | "End"; export type CommandNavAction = | { type: "key"; key: CommandNavKey } | { type: "open" } | { type: "close" } | { type: "results"; count: number }; /** * Pure navigation reducer for the command palette. * - ArrowDown/ArrowUp move the highlight with wrap-around (Home/End jump); * with `count === 0` the highlight stays -1. * - Enter is a NO-OP on state: selecting `state.active` is a side effect the * wiring performs (dispatch "close" afterwards when appropriate). * - Escape closes (resets highlight to -1). * - "results" re-bounds `active` into [0, count) when the list shrinks. * - Keys are ignored while closed or when `count === 0`. */ export function commandNav(state: CommandNavState, action: CommandNavAction): CommandNavState { switch (action.type) { case "open": return { ...state, open: true, active: state.active >= 0 ? state.active : 0 }; case "close": return { ...state, open: false, active: -1 }; case "results": { const count = Math.max(0, action.count); const active = state.active >= 0 ? Math.min(state.active, Math.max(0, count - 1)) : -1; return { ...state, count, active }; } case "key": { if (!state.open) return state; if (state.count === 0) return state; const n = state.count; switch (action.key) { case "ArrowDown": { const next = state.active < 0 ? 0 : (state.active + 1) % n; return { ...state, active: next }; } case "ArrowUp": { const next = state.active < 0 ? n - 1 : (state.active - 1 + n) % n; return { ...state, active: next }; } case "Home": return { ...state, active: 0 }; case "End": return { ...state, active: n - 1 }; case "Enter": return state; // selection is a side effect handled by the wiring case "Escape": return { ...state, open: false, active: -1 }; default: return state; } } default: return state; } }