"use client"; import { useI18n } from "@/components/i18n"; /** * The Keyboard section (thin settings wave): view / rebind / reset over * core/keyboard.ts's registry. The overrides persist in localStorage * (lh:keymap, overrides-only, merged over DEFAULT_BINDINGS), so rebinding * is INSTANT — the dispatcher reads the map per keydown, no reload. * * Rebind flow: click Rebind → the row listens for the next keydown * (capture phase, preventDefault — nothing types anywhere), eventBinding * normalizes it, saveOverrides persists. Escape cancels. displayOnly * actions (a surface owns those keys — the question dock's row machine) * are listed for reading but never rebindable. * * Thin: no conflict detection — if a new binding collides with another * action's, the first match in registry order wins; the cheat sheet shows * both. Detection belongs to the designed wave. */ import { useEffect, useState } from "react"; import { KEYBOARD_ACTIONS, eventBinding, formatBinding, loadOverrides, saveOverrides, type KeyAction, type KeyScope, } from "@/src/core"; import { SettingsRow } from "./controls.tsx"; const SCOPES: { scope: KeyScope; label: string }[] = [ { scope: "global", label: "Global" }, { scope: "chat", label: "Chat" }, { scope: "graph", label: "Graph" }, ]; /** The overrides map after ONE change (pure — exported for tests): * binding null = reset the action to its default (key removed). */ export function nextOverrides( current: Partial>, id: KeyAction, binding: string | null, ): Partial> { const next = { ...current }; if (binding === null) delete next[id]; else next[id] = binding; return next; } export function KeyboardSection() { const { t } = useI18n(); const [overrides, setOverrides] = useState>>({}); const [rebinding, setRebinding] = useState(null); const [nonce, setNonce] = useState(0); // localStorage is read post-mount (SSR-safe); `nonce` re-reads after writes. useEffect(() => { setOverrides(loadOverrides()); }, [nonce]); // The capture listener — only while a rebind is armed. useEffect(() => { if (!rebinding) return; const onKey = (e: KeyboardEvent) => { e.preventDefault(); e.stopPropagation(); if (e.key === "Escape") { setRebinding(null); return; } // Modifier keydowns arrive one by one (⌘ down, then ⇧, then the // key) — each is a keydown this capture listener sees. They are // not bindings; keep listening for the real key. if (e.key === "Control" || e.key === "Meta" || e.key === "Alt" || e.key === "Shift") return; const binding = eventBinding(e); if (!binding) return; const next = nextOverrides(loadOverrides(), rebinding, binding); saveOverrides(next); setRebinding(null); setNonce((n) => n + 1); }; window.addEventListener("keydown", onKey, true); return () => window.removeEventListener("keydown", onKey, true); }, [rebinding]); function resetOne(id: KeyAction) { saveOverrides(nextOverrides(loadOverrides(), id, null)); setNonce((n) => n + 1); } function resetAll() { saveOverrides({}); setNonce((n) => n + 1); } const overridden = (id: KeyAction) => overrides[id] !== undefined; return ( <> {SCOPES.map(({ scope, label }) => { const actions = KEYBOARD_ACTIONS.filter((a) => a.scope === scope); if (!actions.length) return null; return (

{t(label)}

{actions.map((def) => { const effective = overrides[def.id] ?? def.binding; return ( {rebinding === def.id ? t("Press keys…") : t(formatBinding(effective))} {def.displayOnly ? null : rebinding === def.id ? ( ) : ( <> {overridden(def.id) && ( )} )} ); })}
); })}
{t("Stored on this device (lh:keymap) — overrides only.")}
); }