import { createSignal, onMount } from "solid-js"; import { createSimpleContext } from "./helper"; import { copyKeybindsIfNeeded, loadKeybindsFromFile, saveKeybindsToFile, } from "../utils/keybinds-persistence"; import { createStore } from "solid-js/store"; // ── Keybind model ─────────────────────────────────────────────────────────── // Yazi-style: every binding is one or more "strokes". A stroke is a single // key press (key + optional ctrl/shift/meta). Multi-stroke bindings form a // sequence (e.g. ["g","g"] = gg, ["space","n"] = n). The matcher // buffers keystrokes, prefers the longest matching sequence, and exposes the // pending buffer reactively so the status bar can show it (very yazi). /** A single key press. `key` is the lowercase logical key name * ("j", "return", "space", "up", "f1", ...). */ export interface Stroke { key: string; ctrl?: boolean; shift?: boolean; meta?: boolean; } /** Raw config spec for one action: a list of alternative sequences. Each * alternative is itself a list of stroke-notation strings. So * "j" -> [[ {key:"j"} ]] * ["j","down"] -> [[ {key:"j"} ], [ {key:"down"} ]] * [["g","g"],"G"] -> [[ {key:"g"},{key:"g"} ], [ {key:"g",shift:true} ]] */ export type KeybindSpec = string | (string | string[])[]; /** Canonical action names. Must match keys in keybinds.jsonc. */ export type KeybindActionName = | "move-down" | "move-up" | "page-down" | "page-up" | "full-down" | "full-up" | "jump-down" | "jump-up" | "goto-top" | "goto-bottom" | "swipe-prev" | "swipe-next" | "open" | "open-interactive" | "toggle-select" | "visual-mode" | "toggle-all" | "invert-all" | "escape" | "tab-prev" | "tab-next" | "tab-goto-1" | "tab-goto-2" | "tab-goto-3" | "tab-goto-4" | "tab-goto-5" | "tab-goto-6" | "command" | "quit" | "help" | "search" | "filter" | "sort" | "toggle-hidden" | "refresh" | "unsubscribe" | "audio-toggle" | "audio-next" | "audio-prev" | "audio-seek-forward" | "audio-seek-backward" // legacy compat (kept so older callers don't crash) | "select" | "leader" | "inverseModifier" | "cycle" | "dive" | "out" | "up" | "down" | "left" | "right" | "audio-pause" | "audio-play"; /** Resolved config: action -> list of alternative stroke-sequences. */ export type KeybindsResolved = Partial>; const SEQ_TIMEOUT_MS = 600; // ── Stroke parsing ─────────────────────────────────────────────────────────── // Notation: "ctrl-d", "shift-j", "meta-x", "C-d", "M-x", "S-j". // uppercase letter "G" => {key:"g", shift:true} // special: return/enter/escape/tab/space/backspace/up/down/left/right export function parseStroke(notation: string): Stroke { const raw = notation.trim(); let ctrl = false, shift = false, meta = false; let key = raw; const parts = raw.split(/[-+]/); if (parts.length > 1) { key = parts[parts.length - 1]; for (const mod of parts.slice(0, -1)) { const m = mod.toLowerCase(); if (m === "ctrl" || m === "c") ctrl = true; else if (m === "shift" || m === "s") shift = true; else if (m === "meta" || m === "alt" || m === "m") meta = true; } } // Uppercase single letter w/o modifier => shift+letter (vim convention) if ( parts.length === 1 && key.length === 1 && key >= "A" && key <= "Z" && !ctrl && !shift && !meta ) { shift = true; key = key.toLowerCase(); } return { key: key.toLowerCase(), ctrl, shift, meta }; } /** Turn one raw spec into a list of alternative stroke-sequences. */ export function parseBindingSpec(spec: KeybindSpec | undefined): Stroke[][] { if (spec == null) return []; const alts: Stroke[][] = []; const push = (item: string | string[]) => { const seq = Array.isArray(item) ? item : [item]; alts.push(seq.map(parseStroke)); }; if (Array.isArray(spec)) { for (const item of spec) push(item); } else { push(spec); } return alts; } /** Build a Stroke from a keyboard event (opentui shape: name + ctrl/shift/meta). */ export function strokeFromEvent(evt: { name: string; ctrl?: boolean; meta?: boolean; shift?: boolean; }): Stroke { // Uppercase letter events from opentui arrive as name="q" + shift; normalize. return { key: (evt.name ?? "").toLowerCase(), ctrl: !!evt.ctrl, shift: !!evt.shift, meta: !!evt.meta, }; } function strokeEq(a: Stroke, b: Stroke): boolean { return ( a.key === b.key && !!a.ctrl === !!b.ctrl && !!a.shift === !!b.shift && !!a.meta === !!b.meta ); } /** A human label for a stroke, for the status bar / help. */ export function strokeLabel(s: Stroke): string { let out = ""; if (s.ctrl) out += "C-"; if (s.meta) out += "M-"; if (s.shift) out += "S-"; out += s.key; return out; } export function sequenceLabel(seq: Stroke[]): string { return seq.map(strokeLabel).join(" "); } export const { use: useKeybinds, provider: KeybindProvider } = createSimpleContext({ name: "Keybinds", init: () => { const [store, setStore] = createStore({}); // Resolved sequences per action, recomputed when store changes. const [resolved, setResolved] = createSignal>( {}, ); const [ready, setReady] = createSignal(false); const [pending, setPending] = createSignal([]); let pendingTimer: ReturnType | undefined; function recompute() { const out: Record = {}; for (const name of Object.keys(store) as string[]) { out[name] = parseBindingSpec((store as any)[name]); } setResolved(out); } async function load() { await copyKeybindsIfNeeded(); const keybinds = await loadKeybindsFromFile(); setStore(keybinds); recompute(); setReady(true); } async function save() { saveKeybindsToFile(store as KeybindsResolved); } function print(input: KeybindActionName): string { const alts = resolved()[input] ?? []; return alts.map(sequenceLabel).join(" / ") || "—"; } function clearPending() { if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = undefined; } if (pending().length > 0) setPending([]); } function armTimer() { if (pendingTimer) clearTimeout(pendingTimer); pendingTimer = setTimeout(() => clearPending(), SEQ_TIMEOUT_MS); } /** Look up every action whose sequence-list the candidate is a prefix of * (i.e. a longer match is still possible) and every action that the * candidate exactly equals. */ function classify(candidate: Stroke[]) { const exact: KeybindActionName[] = []; const prefix: KeybindActionName[] = []; const map = resolved(); for (const name of Object.keys(map) as KeybindActionName[]) { for (const seq of map[name] ?? []) { if (seq.length < candidate.length) continue; let isPrefix = true; for (let i = 0; i < candidate.length; i++) { if (!strokeEq(seq[i], candidate[i])) { isPrefix = false; break; } } if (!isPrefix) continue; if (seq.length === candidate.length) exact.push(name); else prefix.push(name); } } return { exact, prefix }; } /** Legacy single-key matcher (used by command-palette registrations and * older call sites). Returns true iff `name`'s sequence list contains a * single-stroke alternative equal to the event. */ function match( name: KeybindActionName, evt: { name: string; ctrl?: boolean; meta?: boolean; shift?: boolean }, ): boolean { const alts = resolved()[name] ?? []; const s = strokeFromEvent(evt); // skip in command/input mode unless explicitly handled by caller for (const seq of alts) { if (seq.length === 1 && strokeEq(seq[0], s)) return true; } return false; } /** New sequence-aware matcher. Returns the resolved action or null. * Callers should invoke this once per keypress in a single router. */ function tryMatch(evt: { name: string; ctrl?: boolean; meta?: boolean; shift?: boolean; }): KeybindActionName | null { const stroke = strokeFromEvent(evt); const candidate = [...pending(), stroke]; const { exact, prefix } = classify(candidate); // Still mid-sequence: wait for more keys (unless this stroke also // exactly matches something AND nothing depends on a longer prefix). if (prefix.length > 0) { setPending(candidate); armTimer(); // If there's also an exact match, we *could* fire now — but yazi // prefers to wait for the longer sequence within the timeout, then // falls through. We honor that: only fire exact if no prefix. if (exact.length > 0) { // ambiguous prefix+exact: keep waiting (e.g. `g` could be gg) } return null; } // No longer-match possible: decide on exact. clearPending(); if (exact.length === 0) { // The new stroke might itself begin a fresh sequence. const fresh = classify([stroke]); if (fresh.prefix.length > 0) { setPending([stroke]); armTimer(); return null; } if (fresh.exact.length > 0) { // prefer longest-sequence match among fresh.exact return pickLongest(fresh.exact); } return null; } return pickLongest(exact); } function pickLongest(names: KeybindActionName[]): KeybindActionName { const map = resolved(); let best = names[0]; let bestLen = 0; for (const n of names) { for (const seq of map[n] ?? []) { if (seq.length > bestLen) { bestLen = seq.length; best = n; } } } return best; } // `isInverting` kept for legacy callers; yazi model has no inverse mod, // so it always reports false. Migrated callers should use tryMatch(). function isInverting(_evt: { name: string; ctrl?: boolean; meta?: boolean; shift?: boolean; }): boolean { return false; } onMount(() => { load().catch(() => {}); }); return { get ready() { return ready(); }, get keybinds() { return store; }, get resolved() { return resolved(); }, pending, match, tryMatch, isInverting, print, save, load, clearPending, }; }, });