/** * Svelte 5 rune-based keybinding store. * Manages global keyboard shortcuts with user-customizable chords. * Persists overrides via settings RPC (only non-default bindings saved). * * Usage: * import { keybindingStore } from './keybinding-store.svelte.ts'; * await keybindingStore.init(rpc); * * // Register a command handler * keybindingStore.on('palette', () => paletteOpen = true); * * // Install the global keydown listener (call once) * keybindingStore.installListener(); */ // ── Minimal RPC interface ──────────────────────────────────────────────────── interface SettingsRpc { request: { "keybindings.getAll"(p: Record): Promise<{ keybindings: Record }>; "keybindings.set"(p: { id: string; chord: string }): Promise<{ ok: boolean }>; "keybindings.reset"(p: { id: string }): Promise<{ ok: boolean }>; }; } // ── Types ──────────────────────────────────────────────────────────────────── export interface Keybinding { id: string; label: string; category: "Global" | "Navigation" | "Terminal" | "Settings"; chord: string; defaultChord: string; } // ── Default bindings ───────────────────────────────────────────────────────── const DEFAULTS: Keybinding[] = [ { id: "palette", label: "Command Palette", category: "Global", chord: "Ctrl+K", defaultChord: "Ctrl+K" }, { id: "settings", label: "Open Settings", category: "Global", chord: "Ctrl+,", defaultChord: "Ctrl+," }, { id: "group1", label: "Switch to Group 1", category: "Navigation", chord: "Ctrl+1", defaultChord: "Ctrl+1" }, { id: "group2", label: "Switch to Group 2", category: "Navigation", chord: "Ctrl+2", defaultChord: "Ctrl+2" }, { id: "group3", label: "Switch to Group 3", category: "Navigation", chord: "Ctrl+3", defaultChord: "Ctrl+3" }, { id: "group4", label: "Switch to Group 4", category: "Navigation", chord: "Ctrl+4", defaultChord: "Ctrl+4" }, { id: "newTerminal", label: "New Terminal Tab", category: "Terminal", chord: "Ctrl+Shift+T", defaultChord: "Ctrl+Shift+T" }, { id: "closeTab", label: "Close Terminal Tab", category: "Terminal", chord: "Ctrl+Shift+W", defaultChord: "Ctrl+Shift+W" }, { id: "nextTab", label: "Next Terminal Tab", category: "Terminal", chord: "Ctrl+]", defaultChord: "Ctrl+]" }, { id: "prevTab", label: "Previous Terminal Tab", category: "Terminal", chord: "Ctrl+[", defaultChord: "Ctrl+[" }, { id: "search", label: "Global Search", category: "Global", chord: "Ctrl+Shift+F", defaultChord: "Ctrl+Shift+F" }, { id: "notifications",label: "Notification Center", category: "Global", chord: "Ctrl+Shift+N", defaultChord: "Ctrl+Shift+N" }, { id: "minimize", label: "Minimize Window", category: "Global", chord: "Ctrl+M", defaultChord: "Ctrl+M" }, { id: "toggleFiles", label: "Toggle Files Tab", category: "Navigation", chord: "Ctrl+Shift+E", defaultChord: "Ctrl+Shift+E" }, { id: "toggleMemory", label: "Toggle Memory Tab", category: "Navigation", chord: "Ctrl+Shift+M", defaultChord: "Ctrl+Shift+M" }, { id: "reload", label: "Reload App", category: "Settings", chord: "Ctrl+R", defaultChord: "Ctrl+R" }, ]; // ── Chord serialisation helpers ─────────────────────────────────────────────── export function chordFromEvent(e: KeyboardEvent): string { const parts: string[] = []; if (e.ctrlKey || e.metaKey) parts.push("Ctrl"); if (e.shiftKey) parts.push("Shift"); if (e.altKey) parts.push("Alt"); const key = e.key === " " ? "Space" : e.key; // Exclude pure modifier keys if (!["Control", "Shift", "Alt", "Meta"].includes(key)) { parts.push(key.length === 1 ? key.toUpperCase() : key); } return parts.join("+"); } function matchesChord(e: KeyboardEvent, chord: string): boolean { return chordFromEvent(e) === chord; } // ── Store ──────────────────────────────────────────────────────────────────── function createKeybindingStore() { let bindings = $state(DEFAULTS.map((b) => ({ ...b }))); let rpc: SettingsRpc | null = null; const handlers = new Map void>(); let listenerInstalled = false; /** Load persisted overrides and merge with defaults. */ async function init(rpcInstance: SettingsRpc): Promise { rpc = rpcInstance; try { const { keybindings: overrides } = await rpc.request["keybindings.getAll"]({}); bindings = DEFAULTS.map((b) => ({ ...b, chord: overrides[b.id] ?? b.defaultChord, })); } catch (err) { console.error("[keybinding-store] Failed to load keybindings:", err); } } /** Set a custom chord for a binding id. Persists to SQLite. */ function setChord(id: string, chord: string): void { bindings = bindings.map((b) => b.id === id ? { ...b, chord } : b); rpc?.request["keybindings.set"]({ id, chord }).catch(console.error); } /** Reset a binding to its default chord. Removes SQLite override. */ function resetChord(id: string): void { const def = DEFAULTS.find((b) => b.id === id); if (!def) return; bindings = bindings.map((b) => b.id === id ? { ...b, chord: def.defaultChord } : b); rpc?.request["keybindings.reset"]({ id }).catch(console.error); } /** Reset all bindings to defaults. */ function resetAll(): void { for (const b of bindings) { if (b.chord !== b.defaultChord) resetChord(b.id); } } /** Register a handler for a binding id. */ function on(id: string, handler: () => void): void { handlers.set(id, handler); } /** Install a global capture-phase keydown listener. Idempotent. */ function installListener(): () => void { if (listenerInstalled) return () => {}; listenerInstalled = true; function handleKeydown(e: KeyboardEvent): void { // Never intercept keys when focus is inside a terminal canvas const target = e.target as Element | null; if (target?.closest(".terminal-container, .xterm")) return; const chord = chordFromEvent(e); if (!chord) return; for (const b of bindings) { if (b.chord === chord) { const handler = handlers.get(b.id); if (handler) { e.preventDefault(); handler(); return; } } } } document.addEventListener("keydown", handleKeydown, { capture: true }); return () => { document.removeEventListener("keydown", handleKeydown, { capture: true }); listenerInstalled = false; }; } return { get bindings() { return bindings; }, init, setChord, resetChord, resetAll, on, installListener, }; } export const keybindingStore = createKeybindingStore();