diff --git a/apps/desktop/plans/20260505-hotkey-adaptive-toggle-smoke-test.md b/apps/desktop/plans/20260505-hotkey-adaptive-toggle-smoke-test.md new file mode 100644 index 00000000000..250dcb3aec5 --- /dev/null +++ b/apps/desktop/plans/20260505-hotkey-adaptive-toggle-smoke-test.md @@ -0,0 +1,130 @@ +# Hotkey adaptive-layout toggle — manual smoke test + +Verifies the full adaptive-layout pipeline after this branch: + +- **A.** Toggle gates *every* dispatch consumer (registration, resolver, + display, recorder conflict detector, imperative `getDispatchChord`). +- **B.** Shipped defaults are authored as `mode: "logical"`, so the toggle + actually moves them on non-US layouts (previously it was a no-op for + defaults because they were physical strings). +- **C.** `adaptiveLayoutEnabled` defaults to **true** for new installs. + Matches macOS / VS Code / Chrome convention: `⌘Z` fires on the key + labeled "Z" regardless of layout. + +The original PR (#4078) wired the toggle through the resolver and display +hooks but missed the registration hook and the recorder; even with that +fixed, defaults were stored as physical strings so the toggle had no +effect on shipped bindings. This branch closes both gaps. + +## Setup + +1. Build & launch the desktop app: `cd apps/desktop && bun dev`. +2. Open DevTools (View → Toggle Developer Tools). +3. Run `localStorage.removeItem("keyboard-preferences")` and reload — + guarantees you start on the **new default (toggle ON)**. + +You'll need a non-US layout to exercise the bug. macOS: +System Settings → Keyboard → Input Sources → add **German – Standard +(QWERTZ)**. Switch via the menu-bar flag or Ctrl+Space. + +QWERTZ swap to keep in mind: physical KeyZ prints **Y**, physical KeyY +prints **Z**. + +## A. Toggle ON (new default) — labels rule + +> Expected: every shortcut fires on the key whose **printed label** +> matches the binding, regardless of physical position. + +1. Switch input source to **German (QWERTZ)**. +2. In the app: + - Press `⌘Z` (the key labeled "Z" — physical KeyY) → bound `UNDO`-style + hotkey fires. Pressing the key labeled "Y" (physical KeyZ) does + **not** fire it. + - Press `⌘P` (Open Command Palette) — opens. The labeled-P key works. + - Settings → Keyboard: glyphs reflect QWERTZ (the printed character on + the current layout). +3. Open the recorder for any shortcut, press `⌘Z` (labeled-Z key). The + captured chord shows `⌘Z`. Saving doesn't flag a phantom conflict. +4. In DevTools console, dispatch synthesized events: + ```js + // physical KeyY (labeled Z on QWERTZ) — should fire + document.activeElement.dispatchEvent( + new KeyboardEvent("keydown", { code: "KeyY", metaKey: true, bubbles: true }) + ); + // physical KeyZ (labeled Y on QWERTZ) — should NOT fire + document.activeElement.dispatchEvent( + new KeyboardEvent("keydown", { code: "KeyZ", metaKey: true, bubbles: true }) + ); + ``` + +## B. Toggle OFF — positions rule + +1. Settings → Keyboard → flip **Adaptive layout mapping** off. +2. Still on QWERTZ: + - Press the key labeled "Y" (physical KeyZ) with ⌘ → `UNDO` fires. + - Press the key labeled "Z" (physical KeyY) with ⌘ → does NOT fire. + - Settings → Keyboard glyphs render exactly as authored ("⌘Z" stays + "⌘Z"; no QWERTZ remap). +3. Recorder: press the key labeled "Y" (physical KeyZ) + ⌘ → captured + chord shows `⌘Z`, saved as physical KeyZ internally. + +## C. Live toggle flip + +1. Toggle OFF → confirm physical KeyZ (labeled Y) fires Undo. +2. Flip toggle ON without reloading → physical KeyZ stops firing it, + physical KeyY (labeled Z) starts firing it. +3. Flip back OFF → original positional behavior returns. No reload needed. + +The resolver index, registration (`useHotkey`), display, and recorder +all subscribe to the preferences store, so a toggle flip propagates +through every consumer immediately. + +## D. Default-direction migration + +For a brand-new install (no persisted `keyboard-preferences` value), the +toggle initializes to **true**. Verify: + +1. `localStorage.removeItem("keyboard-preferences")` + reload. +2. Open Settings → Keyboard. The Adaptive layout mapping switch is **on**. +3. On QWERTZ, Undo fires on the labeled-Z key out of the box. + +Existing users who explicitly toggled OFF before this change keep their +saved value (persist middleware writes only on explicit `set()`). Users +who never opened the keyboard settings page get the new default ON on +upgrade — this is intentional (matches OS convention) and the only +behavior change they'll notice is on non-US layouts. + +## E. Terminal reservation parity + +With the toggle in either state, in a v2 terminal pane: + +1. Press `Ctrl+C` — sent to the PTY (interrupts the running process). +2. Press the app's bound chord (e.g. `⌘P`) — opens the palette, doesn't + leak into the terminal buffer. +3. On QWERTZ + toggle ON: press `⌘Z` on the labeled-Z key (physical + KeyY). Undo should fire; the keystroke should not also leak to the PTY. + +## Expected file touchpoints + +If any of the above fails, the bug is almost certainly in one of: + +- `apps/desktop/src/renderer/hotkeys/hooks/useHotkey/useHotkey.ts` + (registration — main consumer; gated via `useActiveLayoutMap`) +- `apps/desktop/src/renderer/hotkeys/utils/resolveHotkeyFromEvent.ts` + (reverse index used by terminal reservation) +- `apps/desktop/src/renderer/hotkeys/hooks/useHotkeyDisplay/useHotkeyDisplay.ts` + (display — `useHotkeyDisplay` + `useFormatBinding`) +- `apps/desktop/src/renderer/hotkeys/hooks/useBinding/useBinding.ts` + (`getDispatchChord`) +- `apps/desktop/src/renderer/hotkeys/hooks/useRecordHotkeys/useRecordHotkeys.ts` + (`getHotkeyConflict`) +- `apps/desktop/src/renderer/hotkeys/registry.ts` (defaults must use + `L()` for printable chords, bare strings only for named keys) +- `apps/desktop/src/renderer/hotkeys/stores/keyboardPreferencesStore.ts` + (default `adaptiveLayoutEnabled: true`) + +All five hook/util consumers must read the layout map through the +`adaptiveLayoutEnabled` gate. If a future consumer reads +`useKeyboardLayoutStore` directly it must do the same — consider a +`useEffectiveLayoutMap()` helper as a follow-up to make this +unmissable. diff --git a/apps/desktop/src/renderer/hotkeys/hooks/useBinding/useBinding.ts b/apps/desktop/src/renderer/hotkeys/hooks/useBinding/useBinding.ts index bac3bbb1da0..f1d2ab17c16 100644 --- a/apps/desktop/src/renderer/hotkeys/hooks/useBinding/useBinding.ts +++ b/apps/desktop/src/renderer/hotkeys/hooks/useBinding/useBinding.ts @@ -1,7 +1,6 @@ import { HOTKEYS, type HotkeyId } from "../../registry"; import { useHotkeyOverridesStore } from "../../stores/hotkeyOverridesStore"; -import { useKeyboardLayoutStore } from "../../stores/keyboardLayoutStore"; -import { useKeyboardPreferencesStore } from "../../stores/keyboardPreferencesStore"; +import { getEffectiveLayoutMap } from "../../stores/keyboardPreferencesStore"; import type { ShortcutBinding } from "../../types"; import { bindingToDispatchChord } from "../../utils/binding"; @@ -34,7 +33,5 @@ export function getBinding(id: HotkeyId): ShortcutBinding | null { * the bound handler on non-US layouts. */ export function getDispatchChord(id: HotkeyId): string | null { - const adaptive = useKeyboardPreferencesStore.getState().adaptiveLayoutEnabled; - const layoutMap = adaptive ? useKeyboardLayoutStore.getState().map : null; - return bindingToDispatchChord(getBinding(id), layoutMap); + return bindingToDispatchChord(getBinding(id), getEffectiveLayoutMap()); } diff --git a/apps/desktop/src/renderer/hotkeys/hooks/useHotkey/useHotkey.ts b/apps/desktop/src/renderer/hotkeys/hooks/useHotkey/useHotkey.ts index a36cbd8657d..c6c8e1ce393 100644 --- a/apps/desktop/src/renderer/hotkeys/hooks/useHotkey/useHotkey.ts +++ b/apps/desktop/src/renderer/hotkeys/hooks/useHotkey/useHotkey.ts @@ -3,7 +3,7 @@ import { type Options, useHotkeys } from "react-hotkeys-hook"; import { formatHotkeyDisplay } from "../../display"; import type { HotkeyId } from "../../registry"; import { PLATFORM } from "../../registry"; -import { useKeyboardLayoutStore } from "../../stores/keyboardLayoutStore"; +import { useEffectiveLayoutMap } from "../../stores/keyboardPreferencesStore"; import type { HotkeyDisplay } from "../../types"; import { bindingToDispatchChord } from "../../utils/binding"; import { useBinding } from "../useBinding"; @@ -24,7 +24,7 @@ export function useHotkey( options?: Options, ): HotkeyDisplay { const binding = useBinding(id); - const layoutMap = useKeyboardLayoutStore((s) => s.map); + const layoutMap = useEffectiveLayoutMap(); const chord = bindingToDispatchChord(binding, layoutMap); const callbackRef = useRef(callback); callbackRef.current = callback; diff --git a/apps/desktop/src/renderer/hotkeys/hooks/useHotkeyDisplay/useHotkeyDisplay.ts b/apps/desktop/src/renderer/hotkeys/hooks/useHotkeyDisplay/useHotkeyDisplay.ts index 22add732053..46aee2715a2 100644 --- a/apps/desktop/src/renderer/hotkeys/hooks/useHotkeyDisplay/useHotkeyDisplay.ts +++ b/apps/desktop/src/renderer/hotkeys/hooks/useHotkeyDisplay/useHotkeyDisplay.ts @@ -1,23 +1,14 @@ import { useMemo } from "react"; import { formatHotkeyDisplay } from "../../display"; import { PLATFORM } from "../../registry"; -import { useKeyboardLayoutStore } from "../../stores/keyboardLayoutStore"; -import { useKeyboardPreferencesStore } from "../../stores/keyboardPreferencesStore"; +import { useEffectiveLayoutMap } from "../../stores/keyboardPreferencesStore"; import type { HotkeyDisplay, ShortcutBinding } from "../../types"; import { bindingToDispatchChord } from "../../utils/binding"; import { useBinding } from "../useBinding"; -/** Effective layout map for display: null when the user has disabled - * adaptive layout mapping, so glyphs match the authored chord. */ -function useActiveLayoutMap(): ReadonlyMap | null { - const layoutMap = useKeyboardLayoutStore((s) => s.map); - const adaptive = useKeyboardPreferencesStore((s) => s.adaptiveLayoutEnabled); - return adaptive ? layoutMap : null; -} - export function useHotkeyDisplay(id: string): HotkeyDisplay { const binding = useBinding(id as Parameters[0]); - const layoutMap = useActiveLayoutMap(); + const layoutMap = useEffectiveLayoutMap(); const chord = bindingToDispatchChord(binding, layoutMap); return useMemo( () => formatHotkeyDisplay(chord, PLATFORM, layoutMap), @@ -34,7 +25,7 @@ export function useHotkeyDisplay(id: string): HotkeyDisplay { export function useFormatBinding( binding: ShortcutBinding | null, ): HotkeyDisplay { - const layoutMap = useActiveLayoutMap(); + const layoutMap = useEffectiveLayoutMap(); const chord = bindingToDispatchChord(binding, layoutMap); return useMemo( () => formatHotkeyDisplay(chord, PLATFORM, layoutMap), diff --git a/apps/desktop/src/renderer/hotkeys/hooks/useRecordHotkeys/useRecordHotkeys.ts b/apps/desktop/src/renderer/hotkeys/hooks/useRecordHotkeys/useRecordHotkeys.ts index cc8ac66e0f1..559c7635627 100644 --- a/apps/desktop/src/renderer/hotkeys/hooks/useRecordHotkeys/useRecordHotkeys.ts +++ b/apps/desktop/src/renderer/hotkeys/hooks/useRecordHotkeys/useRecordHotkeys.ts @@ -1,7 +1,7 @@ import { useEffect, useRef } from "react"; import { HOTKEYS, type HotkeyId, PLATFORM } from "../../registry"; import { useHotkeyOverridesStore } from "../../stores/hotkeyOverridesStore"; -import { useKeyboardLayoutStore } from "../../stores/keyboardLayoutStore"; +import { getEffectiveLayoutMap } from "../../stores/keyboardPreferencesStore"; import type { BindingMode, ParsedBinding, @@ -141,7 +141,7 @@ function getHotkeyConflict( excludeId: HotkeyId, ): HotkeyId | null { const { overrides } = useHotkeyOverridesStore.getState(); - const layoutMap = useKeyboardLayoutStore.getState().map; + const layoutMap = getEffectiveLayoutMap(); const candidateDispatch = bindingToDispatchChord(candidate, layoutMap); if (!candidateDispatch) return null; const target = canonicalizeChord(candidateDispatch); diff --git a/apps/desktop/src/renderer/hotkeys/registry.test.ts b/apps/desktop/src/renderer/hotkeys/registry.test.ts new file mode 100644 index 00000000000..8d38386169a --- /dev/null +++ b/apps/desktop/src/renderer/hotkeys/registry.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "bun:test"; +import { HOTKEYS_REGISTRY } from "./registry"; + +// Locks in the shape of shipped defaults so the toggle keeps doing +// something. The original "Adaptive layout mapping" toggle was decorative +// for shipped defaults because every entry was a bare-string (physical) +// chord, and bindingToDispatchChord short-circuits physical mode. Keeping +// printable defaults as `mode: "logical"` is what makes the toggle move +// them on non-US layouts. + +const NAMED_TERMINAL_TOKENS = new Set([ + "enter", + "escape", + "backspace", + "delete", + "tab", + "space", + "up", + "down", + "left", + "right", + "arrowup", + "arrowdown", + "arrowleft", + "arrowright", + "home", + "end", + "pageup", + "pagedown", + "insert", +]); + +function isFunctionKey(token: string): boolean { + return /^f([1-9]|1[0-2])$/.test(token); +} + +function terminalToken(chord: string): string { + const parts = chord.split("+"); + return parts[parts.length - 1] ?? ""; +} + +function* allBindings(): Generator<{ + id: string; + platform: "mac" | "windows" | "linux"; + binding: unknown; +}> { + for (const [id, def] of Object.entries(HOTKEYS_REGISTRY)) { + for (const platform of ["mac", "windows", "linux"] as const) { + yield { id, platform, binding: def.key[platform] }; + } + } +} + +describe("HOTKEYS_REGISTRY shape", () => { + it("authors printable defaults as mode: 'logical'", () => { + const offenders: string[] = []; + for (const { id, platform, binding } of allBindings()) { + if (binding === null) continue; + if (typeof binding !== "string") continue; // v2 objects checked below + + const token = terminalToken(binding); + const isLayoutStable = + NAMED_TERMINAL_TOKENS.has(token) || isFunctionKey(token); + if (!isLayoutStable) { + offenders.push(`${id}.${platform}=${binding}`); + } + } + // If this fires: a printable chord ('meta+t', 'ctrl+shift+0', 'meta+slash') + // was authored as a bare string, which parses as physical mode and bypasses + // the adaptive-layout toggle. Wrap it with the L() helper in registry.ts. + expect(offenders).toEqual([]); + }); + + it("keeps every logical entry pinned to v2 / logical mode", () => { + for (const { id, platform, binding } of allBindings()) { + if (binding === null) continue; + if (typeof binding === "string") continue; + expect({ id, platform, binding }).toMatchObject({ + binding: { version: 2, mode: "logical" }, + }); + } + }); + + it("keeps named-key chords as bare strings (layout-stable, no L() needed)", () => { + // Chords whose terminal is a named key gain nothing from logical mode — + // translateLogicalChord short-circuits named keys. Authoring them as + // bare strings keeps the registry terse. + for (const { id, platform, binding } of allBindings()) { + if (typeof binding !== "string") continue; + const token = terminalToken(binding); + const isLayoutStable = + NAMED_TERMINAL_TOKENS.has(token) || isFunctionKey(token); + if (!isLayoutStable) { + throw new Error( + `${id}.${platform}=${binding} is a bare string but its terminal token is not a named key — wrap with L() in registry.ts`, + ); + } + } + }); + + it("includes a canary letter, digit, and punctuation default in logical mode", () => { + // Sample three different terminal-token shapes so a partial regression + // (e.g. only digits revert to physical) gets caught here too. + expect(HOTKEYS_REGISTRY.QUICK_OPEN.key.mac).toMatchObject({ + mode: "logical", + chord: "meta+p", + }); + expect(HOTKEYS_REGISTRY.JUMP_TO_WORKSPACE_1.key.mac).toMatchObject({ + mode: "logical", + chord: "meta+1", + }); + expect(HOTKEYS_REGISTRY.OPEN_SETTINGS.key.mac).toMatchObject({ + mode: "logical", + chord: "meta+comma", + }); + }); +}); diff --git a/apps/desktop/src/renderer/hotkeys/registry.ts b/apps/desktop/src/renderer/hotkeys/registry.ts index 67053c0881d..2352c3615dc 100644 --- a/apps/desktop/src/renderer/hotkeys/registry.ts +++ b/apps/desktop/src/renderer/hotkeys/registry.ts @@ -3,6 +3,7 @@ import type { HotkeyDefinition, Platform, PlatformKey, + ShortcutBinding, } from "./types"; interface HotkeyRegistryDefinition { @@ -22,6 +23,21 @@ function detectPlatform(): Platform { export const PLATFORM: Platform = detectPlatform(); +/** + * Mark a printable chord as logical so it follows the labeled key on + * non-US layouts (e.g. on QWERTZ ⌘Z fires on the key printed "Z" — physical + * KeyY — instead of physical KeyZ). Honored when `adaptiveLayoutEnabled` + * is on; falls through to the original chord otherwise (matching physical + * dispatch). Use bare strings only for chords whose terminal token is a + * named key (arrows, Enter, Escape, F1–F12, …) — those are layout-stable + * and `defaultModeForChord` classifies them as "named" automatically. + */ +const L = (chord: string): ShortcutBinding => ({ + version: 2, + mode: "logical", + chord, +}); + // --------------------------------------------------------------------------- // Hotkey definitions // --------------------------------------------------------------------------- @@ -30,9 +46,9 @@ export const HOTKEYS_REGISTRY = { // Navigation NAVIGATE_BACK: { key: { - mac: "meta+bracketleft", - windows: "ctrl+shift+bracketleft", - linux: "ctrl+shift+bracketleft", + mac: L("meta+bracketleft"), + windows: L("ctrl+shift+bracketleft"), + linux: L("ctrl+shift+bracketleft"), }, label: "Navigate Back", category: "Navigation", @@ -40,16 +56,20 @@ export const HOTKEYS_REGISTRY = { }, NAVIGATE_FORWARD: { key: { - mac: "meta+bracketright", - windows: "ctrl+shift+bracketright", - linux: "ctrl+shift+bracketright", + mac: L("meta+bracketright"), + windows: L("ctrl+shift+bracketright"), + linux: L("ctrl+shift+bracketright"), }, label: "Navigate Forward", category: "Navigation", description: "Go forward to the next page in history", }, QUICK_OPEN: { - key: { mac: "meta+p", windows: "ctrl+shift+p", linux: "ctrl+shift+p" }, + key: { + mac: L("meta+p"), + windows: L("ctrl+shift+p"), + linux: L("ctrl+shift+p"), + }, label: "Quick Open File", category: "Navigation", description: "Search and open files in the current workspace", @@ -57,47 +77,83 @@ export const HOTKEYS_REGISTRY = { // Workspace switching JUMP_TO_WORKSPACE_1: { - key: { mac: "meta+1", windows: "ctrl+shift+1", linux: "ctrl+shift+1" }, + key: { + mac: L("meta+1"), + windows: L("ctrl+shift+1"), + linux: L("ctrl+shift+1"), + }, label: "Switch to Workspace 1", category: "Workspace", }, JUMP_TO_WORKSPACE_2: { - key: { mac: "meta+2", windows: "ctrl+shift+2", linux: "ctrl+shift+2" }, + key: { + mac: L("meta+2"), + windows: L("ctrl+shift+2"), + linux: L("ctrl+shift+2"), + }, label: "Switch to Workspace 2", category: "Workspace", }, JUMP_TO_WORKSPACE_3: { - key: { mac: "meta+3", windows: "ctrl+shift+3", linux: "ctrl+shift+3" }, + key: { + mac: L("meta+3"), + windows: L("ctrl+shift+3"), + linux: L("ctrl+shift+3"), + }, label: "Switch to Workspace 3", category: "Workspace", }, JUMP_TO_WORKSPACE_4: { - key: { mac: "meta+4", windows: "ctrl+shift+4", linux: "ctrl+shift+4" }, + key: { + mac: L("meta+4"), + windows: L("ctrl+shift+4"), + linux: L("ctrl+shift+4"), + }, label: "Switch to Workspace 4", category: "Workspace", }, JUMP_TO_WORKSPACE_5: { - key: { mac: "meta+5", windows: "ctrl+shift+5", linux: "ctrl+shift+5" }, + key: { + mac: L("meta+5"), + windows: L("ctrl+shift+5"), + linux: L("ctrl+shift+5"), + }, label: "Switch to Workspace 5", category: "Workspace", }, JUMP_TO_WORKSPACE_6: { - key: { mac: "meta+6", windows: "ctrl+shift+6", linux: "ctrl+shift+6" }, + key: { + mac: L("meta+6"), + windows: L("ctrl+shift+6"), + linux: L("ctrl+shift+6"), + }, label: "Switch to Workspace 6", category: "Workspace", }, JUMP_TO_WORKSPACE_7: { - key: { mac: "meta+7", windows: "ctrl+shift+7", linux: "ctrl+shift+7" }, + key: { + mac: L("meta+7"), + windows: L("ctrl+shift+7"), + linux: L("ctrl+shift+7"), + }, label: "Switch to Workspace 7", category: "Workspace", }, JUMP_TO_WORKSPACE_8: { - key: { mac: "meta+8", windows: "ctrl+shift+8", linux: "ctrl+shift+8" }, + key: { + mac: L("meta+8"), + windows: L("ctrl+shift+8"), + linux: L("ctrl+shift+8"), + }, label: "Switch to Workspace 8", category: "Workspace", }, JUMP_TO_WORKSPACE_9: { - key: { mac: "meta+9", windows: "ctrl+shift+9", linux: "ctrl+shift+9" }, + key: { + mac: L("meta+9"), + windows: L("ctrl+shift+9"), + linux: L("ctrl+shift+9"), + }, label: "Switch to Workspace 9", category: "Workspace", }, @@ -132,38 +188,50 @@ export const HOTKEYS_REGISTRY = { description: "Close or delete the current workspace", }, NEW_WORKSPACE: { - key: { mac: "meta+n", windows: "ctrl+shift+n", linux: "ctrl+shift+n" }, + key: { + mac: L("meta+n"), + windows: L("ctrl+shift+n"), + linux: L("ctrl+shift+n"), + }, label: "New Workspace", category: "Workspace", description: "Open the new workspace modal", }, QUICK_CREATE_WORKSPACE: { key: { - mac: "meta+shift+n", - windows: "ctrl+shift+alt+n", - linux: "ctrl+shift+alt+n", + mac: L("meta+shift+n"), + windows: L("ctrl+shift+alt+n"), + linux: L("ctrl+shift+alt+n"), }, label: "Quick Create Workspace", category: "Workspace", description: "Quickly create a workspace in the current project", }, RUN_WORKSPACE_COMMAND: { - key: { mac: "meta+g", windows: "ctrl+shift+g", linux: "ctrl+shift+g" }, + key: { + mac: L("meta+g"), + windows: L("ctrl+shift+g"), + linux: L("ctrl+shift+g"), + }, label: "Run Workspace Command", category: "Workspace", description: "Start or stop the workspace run command", }, FOCUS_TASK_SEARCH: { - key: { mac: "meta+f", windows: "ctrl+shift+f", linux: "ctrl+shift+f" }, + key: { + mac: L("meta+f"), + windows: L("ctrl+shift+f"), + linux: L("ctrl+shift+f"), + }, label: "Focus Task Search", category: "Workspace", description: "Focus the search input in the tasks view", }, OPEN_PROJECT: { key: { - mac: "meta+shift+o", - windows: "ctrl+shift+alt+o", - linux: "ctrl+shift+alt+o", + mac: L("meta+shift+o"), + windows: L("ctrl+shift+alt+o"), + linux: L("ctrl+shift+alt+o"), }, label: "Open Project", category: "Workspace", @@ -171,9 +239,9 @@ export const HOTKEYS_REGISTRY = { }, OPEN_PR: { key: { - mac: "meta+shift+p", - windows: "ctrl+shift+alt+p", - linux: "ctrl+shift+alt+p", + mac: L("meta+shift+p"), + windows: L("ctrl+shift+alt+p"), + linux: L("ctrl+shift+alt+p"), }, label: "Open Pull Request", category: "Workspace", @@ -182,15 +250,19 @@ export const HOTKEYS_REGISTRY = { // Layout TOGGLE_SIDEBAR: { - key: { mac: "meta+l", windows: "ctrl+shift+l", linux: "ctrl+shift+l" }, + key: { + mac: L("meta+l"), + windows: L("ctrl+shift+l"), + linux: L("ctrl+shift+l"), + }, label: "Toggle Changes Tab", category: "Layout", }, OPEN_DIFF_VIEWER: { key: { - mac: "meta+shift+l", - windows: "ctrl+shift+alt+l", - linux: "ctrl+shift+alt+l", + mac: L("meta+shift+l"), + windows: L("ctrl+shift+alt+l"), + linux: L("ctrl+shift+alt+l"), }, label: "Open Diff Viewer", category: "Layout", @@ -198,43 +270,59 @@ export const HOTKEYS_REGISTRY = { "Open the diff viewer in a new tab, or focus the existing diff viewer", }, TOGGLE_WORKSPACE_SIDEBAR: { - key: { mac: "meta+b", windows: "ctrl+shift+b", linux: "ctrl+shift+b" }, + key: { + mac: L("meta+b"), + windows: L("ctrl+shift+b"), + linux: L("ctrl+shift+b"), + }, label: "Toggle Workspaces Sidebar", category: "Layout", }, SPLIT_RIGHT: { - key: { mac: "meta+d", windows: "ctrl+shift+d", linux: "ctrl+shift+d" }, + key: { + mac: L("meta+d"), + windows: L("ctrl+shift+d"), + linux: L("ctrl+shift+d"), + }, label: "Split Right", category: "Layout", description: "Split the current pane to the right", }, SPLIT_DOWN: { key: { - mac: "meta+shift+d", - windows: "ctrl+shift+alt+d", - linux: "ctrl+shift+alt+d", + mac: L("meta+shift+d"), + windows: L("ctrl+shift+alt+d"), + linux: L("ctrl+shift+alt+d"), }, label: "Split Down", category: "Layout", description: "Split the current pane downward", }, SPLIT_AUTO: { - key: { mac: "meta+e", windows: "ctrl+shift+e", linux: "ctrl+shift+e" }, + key: { + mac: L("meta+e"), + windows: L("ctrl+shift+e"), + linux: L("ctrl+shift+e"), + }, label: "Split Pane Auto", category: "Layout", description: "Split the current pane along its longer side", }, SPLIT_WITH_CHAT: { - key: { mac: "meta+shift+e", windows: "ctrl+alt+e", linux: "ctrl+alt+e" }, + key: { + mac: L("meta+shift+e"), + windows: L("ctrl+alt+e"), + linux: L("ctrl+alt+e"), + }, label: "Split with New Chat", category: "Layout", description: "Split the current pane and open a new chat pane", }, SPLIT_WITH_BROWSER: { key: { - mac: "meta+shift+s", - windows: "ctrl+shift+alt+s", - linux: "ctrl+shift+alt+s", + mac: L("meta+shift+s"), + windows: L("ctrl+shift+alt+s"), + linux: L("ctrl+shift+alt+s"), }, label: "Split with New Browser", category: "Layout", @@ -242,16 +330,20 @@ export const HOTKEYS_REGISTRY = { }, EQUALIZE_PANE_SPLITS: { key: { - mac: "meta+shift+0", - windows: "ctrl+shift+0", - linux: "ctrl+shift+0", + mac: L("meta+shift+0"), + windows: L("ctrl+shift+0"), + linux: L("ctrl+shift+0"), }, label: "Equalize Pane Splits", category: "Layout", description: "Make all panes equal size", }, CLOSE_PANE: { - key: { mac: "meta+w", windows: "ctrl+shift+w", linux: "ctrl+shift+w" }, + key: { + mac: L("meta+w"), + windows: L("ctrl+shift+w"), + linux: L("ctrl+shift+w"), + }, label: "Close Pane", category: "Layout", description: "Close the current pane", @@ -259,72 +351,96 @@ export const HOTKEYS_REGISTRY = { // Terminal FIND_IN_TERMINAL: { - key: { mac: "meta+f", windows: "ctrl+shift+f", linux: "ctrl+shift+f" }, + key: { + mac: L("meta+f"), + windows: L("ctrl+shift+f"), + linux: L("ctrl+shift+f"), + }, label: "Find in Terminal", category: "Terminal", description: "Search text in the active terminal", }, FIND_IN_FILE_VIEWER: { - key: { mac: "meta+f", windows: "ctrl+shift+f", linux: "ctrl+shift+f" }, + key: { + mac: L("meta+f"), + windows: L("ctrl+shift+f"), + linux: L("ctrl+shift+f"), + }, label: "Find in File Viewer", category: "Terminal", description: "Search text in the rendered file viewer", }, FIND_IN_CHAT: { - key: { mac: "meta+f", windows: "ctrl+shift+f", linux: "ctrl+shift+f" }, + key: { + mac: L("meta+f"), + windows: L("ctrl+shift+f"), + linux: L("ctrl+shift+f"), + }, label: "Find in Chat", category: "Terminal", description: "Search text in the active chat", }, NEW_GROUP: { - key: { mac: "meta+t", windows: "ctrl+shift+t", linux: "ctrl+shift+t" }, + key: { + mac: L("meta+t"), + windows: L("ctrl+shift+t"), + linux: L("ctrl+shift+t"), + }, label: "New Terminal", category: "Terminal", }, NEW_CHAT: { key: { - mac: "meta+shift+t", - windows: "ctrl+shift+alt+t", - linux: "ctrl+shift+alt+t", + mac: L("meta+shift+t"), + windows: L("ctrl+shift+alt+t"), + linux: L("ctrl+shift+alt+t"), }, label: "New Chat", category: "Terminal", }, REOPEN_TAB: { key: { - mac: "meta+shift+r", - windows: "ctrl+shift+alt+r", - linux: "ctrl+shift+alt+r", + mac: L("meta+shift+r"), + windows: L("ctrl+shift+alt+r"), + linux: L("ctrl+shift+alt+r"), }, label: "Reopen Closed Tab", category: "Terminal", }, NEW_BROWSER: { key: { - mac: "meta+shift+b", - windows: "ctrl+shift+alt+b", - linux: "ctrl+shift+alt+b", + mac: L("meta+shift+b"), + windows: L("ctrl+shift+alt+b"), + linux: L("ctrl+shift+alt+b"), }, label: "New Browser", category: "Terminal", }, CLOSE_TERMINAL: { - key: { mac: "meta+w", windows: "ctrl+shift+w", linux: "ctrl+shift+w" }, + key: { + mac: L("meta+w"), + windows: L("ctrl+shift+w"), + linux: L("ctrl+shift+w"), + }, label: "Close Terminal", category: "Terminal", }, CLOSE_TAB: { key: { - mac: "meta+shift+w", - windows: "ctrl+shift+alt+w", - linux: "ctrl+shift+alt+w", + mac: L("meta+shift+w"), + windows: L("ctrl+shift+alt+w"), + linux: L("ctrl+shift+alt+w"), }, label: "Close Tab", category: "Terminal", description: "Close the current tab", }, CLEAR_TERMINAL: { - key: { mac: "meta+k", windows: "ctrl+shift+k", linux: "ctrl+shift+k" }, + key: { + mac: L("meta+k"), + windows: L("ctrl+shift+k"), + linux: L("ctrl+shift+k"), + }, label: "Clear Terminal", category: "Terminal", }, @@ -398,155 +514,167 @@ export const HOTKEYS_REGISTRY = { }, JUMP_TO_TAB_1: { key: { - mac: "meta+alt+1", - windows: "ctrl+shift+alt+1", - linux: "ctrl+shift+alt+1", + mac: L("meta+alt+1"), + windows: L("ctrl+shift+alt+1"), + linux: L("ctrl+shift+alt+1"), }, label: "Switch to Tab 1", category: "Terminal", }, JUMP_TO_TAB_2: { key: { - mac: "meta+alt+2", - windows: "ctrl+shift+alt+2", - linux: "ctrl+shift+alt+2", + mac: L("meta+alt+2"), + windows: L("ctrl+shift+alt+2"), + linux: L("ctrl+shift+alt+2"), }, label: "Switch to Tab 2", category: "Terminal", }, JUMP_TO_TAB_3: { key: { - mac: "meta+alt+3", - windows: "ctrl+shift+alt+3", - linux: "ctrl+shift+alt+3", + mac: L("meta+alt+3"), + windows: L("ctrl+shift+alt+3"), + linux: L("ctrl+shift+alt+3"), }, label: "Switch to Tab 3", category: "Terminal", }, JUMP_TO_TAB_4: { key: { - mac: "meta+alt+4", - windows: "ctrl+shift+alt+4", - linux: "ctrl+shift+alt+4", + mac: L("meta+alt+4"), + windows: L("ctrl+shift+alt+4"), + linux: L("ctrl+shift+alt+4"), }, label: "Switch to Tab 4", category: "Terminal", }, JUMP_TO_TAB_5: { key: { - mac: "meta+alt+5", - windows: "ctrl+shift+alt+5", - linux: "ctrl+shift+alt+5", + mac: L("meta+alt+5"), + windows: L("ctrl+shift+alt+5"), + linux: L("ctrl+shift+alt+5"), }, label: "Switch to Tab 5", category: "Terminal", }, JUMP_TO_TAB_6: { key: { - mac: "meta+alt+6", - windows: "ctrl+shift+alt+6", - linux: "ctrl+shift+alt+6", + mac: L("meta+alt+6"), + windows: L("ctrl+shift+alt+6"), + linux: L("ctrl+shift+alt+6"), }, label: "Switch to Tab 6", category: "Terminal", }, JUMP_TO_TAB_7: { key: { - mac: "meta+alt+7", - windows: "ctrl+shift+alt+7", - linux: "ctrl+shift+alt+7", + mac: L("meta+alt+7"), + windows: L("ctrl+shift+alt+7"), + linux: L("ctrl+shift+alt+7"), }, label: "Switch to Tab 7", category: "Terminal", }, JUMP_TO_TAB_8: { key: { - mac: "meta+alt+8", - windows: "ctrl+shift+alt+8", - linux: "ctrl+shift+alt+8", + mac: L("meta+alt+8"), + windows: L("ctrl+shift+alt+8"), + linux: L("ctrl+shift+alt+8"), }, label: "Switch to Tab 8", category: "Terminal", }, JUMP_TO_TAB_9: { key: { - mac: "meta+alt+9", - windows: "ctrl+shift+alt+9", - linux: "ctrl+shift+alt+9", + mac: L("meta+alt+9"), + windows: L("ctrl+shift+alt+9"), + linux: L("ctrl+shift+alt+9"), }, label: "Switch to Tab 9", category: "Terminal", }, OPEN_PRESET_1: { - key: { mac: "ctrl+1", windows: "ctrl+1", linux: "ctrl+1" }, + key: { mac: L("ctrl+1"), windows: L("ctrl+1"), linux: L("ctrl+1") }, label: "Open Preset 1", category: "Terminal", }, OPEN_PRESET_2: { - key: { mac: "ctrl+2", windows: "ctrl+2", linux: "ctrl+2" }, + key: { mac: L("ctrl+2"), windows: L("ctrl+2"), linux: L("ctrl+2") }, label: "Open Preset 2", category: "Terminal", }, OPEN_PRESET_3: { - key: { mac: "ctrl+3", windows: "ctrl+3", linux: "ctrl+3" }, + key: { mac: L("ctrl+3"), windows: L("ctrl+3"), linux: L("ctrl+3") }, label: "Open Preset 3", category: "Terminal", }, OPEN_PRESET_4: { - key: { mac: "ctrl+4", windows: "ctrl+4", linux: "ctrl+4" }, + key: { mac: L("ctrl+4"), windows: L("ctrl+4"), linux: L("ctrl+4") }, label: "Open Preset 4", category: "Terminal", }, OPEN_PRESET_5: { - key: { mac: "ctrl+5", windows: "ctrl+5", linux: "ctrl+5" }, + key: { mac: L("ctrl+5"), windows: L("ctrl+5"), linux: L("ctrl+5") }, label: "Open Preset 5", category: "Terminal", }, OPEN_PRESET_6: { - key: { mac: "ctrl+6", windows: "ctrl+6", linux: "ctrl+6" }, + key: { mac: L("ctrl+6"), windows: L("ctrl+6"), linux: L("ctrl+6") }, label: "Open Preset 6", category: "Terminal", }, OPEN_PRESET_7: { - key: { mac: "ctrl+7", windows: "ctrl+7", linux: "ctrl+7" }, + key: { mac: L("ctrl+7"), windows: L("ctrl+7"), linux: L("ctrl+7") }, label: "Open Preset 7", category: "Terminal", }, OPEN_PRESET_8: { - key: { mac: "ctrl+8", windows: "ctrl+8", linux: "ctrl+8" }, + key: { mac: L("ctrl+8"), windows: L("ctrl+8"), linux: L("ctrl+8") }, label: "Open Preset 8", category: "Terminal", }, OPEN_PRESET_9: { - key: { mac: "ctrl+9", windows: "ctrl+9", linux: "ctrl+9" }, + key: { mac: L("ctrl+9"), windows: L("ctrl+9"), linux: L("ctrl+9") }, label: "Open Preset 9", category: "Terminal", }, // Chat FOCUS_CHAT_INPUT: { - key: { mac: "meta+j", windows: "ctrl+shift+j", linux: "ctrl+shift+j" }, + key: { + mac: L("meta+j"), + windows: L("ctrl+shift+j"), + linux: L("ctrl+shift+j"), + }, label: "Focus Chat Input", category: "Terminal", }, CHAT_ADD_ATTACHMENT: { - key: { mac: "meta+u", windows: "ctrl+shift+u", linux: "ctrl+shift+u" }, + key: { + mac: L("meta+u"), + windows: L("ctrl+shift+u"), + linux: L("ctrl+shift+u"), + }, label: "Add Attachment", category: "Terminal", }, // Window OPEN_IN_APP: { - key: { mac: "meta+o", windows: "ctrl+shift+o", linux: "ctrl+shift+o" }, + key: { + mac: L("meta+o"), + windows: L("ctrl+shift+o"), + linux: L("ctrl+shift+o"), + }, label: "Open in App", category: "Window", description: "Open workspace in external app (Cursor, VS Code, etc.)", }, COPY_PATH: { key: { - mac: "meta+shift+c", - windows: "ctrl+shift+alt+c", - linux: "ctrl+shift+alt+c", + mac: L("meta+shift+c"), + windows: L("ctrl+shift+alt+c"), + linux: L("ctrl+shift+alt+c"), }, label: "Copy Path", category: "Window", @@ -555,15 +683,19 @@ export const HOTKEYS_REGISTRY = { // Help OPEN_SETTINGS: { - key: { mac: "meta+comma", windows: "ctrl+comma", linux: "ctrl+comma" }, + key: { + mac: L("meta+comma"), + windows: L("ctrl+comma"), + linux: L("ctrl+comma"), + }, label: "Open Settings", category: "Help", }, SHOW_HOTKEYS: { key: { - mac: "meta+shift+slash", - windows: "ctrl+shift+slash", - linux: "ctrl+shift+slash", + mac: L("meta+shift+slash"), + windows: L("ctrl+shift+slash"), + linux: L("ctrl+shift+slash"), }, label: "Show Keyboard Shortcuts", category: "Help", diff --git a/apps/desktop/src/renderer/hotkeys/stores/index.ts b/apps/desktop/src/renderer/hotkeys/stores/index.ts index 478d066be0e..5616fa9fd65 100644 --- a/apps/desktop/src/renderer/hotkeys/stores/index.ts +++ b/apps/desktop/src/renderer/hotkeys/stores/index.ts @@ -1,2 +1,6 @@ export { useHotkeyOverridesStore } from "./hotkeyOverridesStore"; -export { useKeyboardPreferencesStore } from "./keyboardPreferencesStore"; +export { + getEffectiveLayoutMap, + useEffectiveLayoutMap, + useKeyboardPreferencesStore, +} from "./keyboardPreferencesStore"; diff --git a/apps/desktop/src/renderer/hotkeys/stores/keyboardLayoutStore.ts b/apps/desktop/src/renderer/hotkeys/stores/keyboardLayoutStore.ts index 9b602cceca4..7929a828303 100644 --- a/apps/desktop/src/renderer/hotkeys/stores/keyboardLayoutStore.ts +++ b/apps/desktop/src/renderer/hotkeys/stores/keyboardLayoutStore.ts @@ -8,6 +8,13 @@ import { create } from "zustand"; // native-keymap hooks the OS-level // kTISNotifySelectedKeyboardInputSourceChanged distributed notification, // which fires for every input-source change. +// +// Do not import this store directly from dispatch / display / recorder +// code. Use `useEffectiveLayoutMap` / `getEffectiveLayoutMap` from +// `./keyboardPreferencesStore` instead — that's the single chokepoint +// that gates by `adaptiveLayoutEnabled`. Reading the map raw silently +// bypasses the user's preference (this was the root cause of #4078's +// "toggle does nothing" bug). interface State { /** Map. Null until the first tRPC payload diff --git a/apps/desktop/src/renderer/hotkeys/stores/keyboardPreferencesStore.ts b/apps/desktop/src/renderer/hotkeys/stores/keyboardPreferencesStore.ts index abe0aecf6d8..ef418e728e2 100644 --- a/apps/desktop/src/renderer/hotkeys/stores/keyboardPreferencesStore.ts +++ b/apps/desktop/src/renderer/hotkeys/stores/keyboardPreferencesStore.ts @@ -1,11 +1,13 @@ import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; +import { useKeyboardLayoutStore } from "./keyboardLayoutStore"; interface KeyboardPreferencesState { - /** Opt-in: when true, logical bindings are translated through the OS - * keyboard layout — e.g. `⌘Z` dispatches to physical KeyY on QWERTZ. - * Defaults to false so bindings dispatch and display as if on US-ANSI - * regardless of the current input source. */ + /** When true (default), logical bindings are translated through the OS + * keyboard layout — e.g. `⌘Z` fires on the key labeled "Z" regardless + * of layout (physical KeyY on QWERTZ). Matches macOS / VS Code / Chrome + * convention. Flip off to anchor bindings to physical key positions + * (`⌘Z` always on physical KeyZ, regardless of label). */ adaptiveLayoutEnabled: boolean; setAdaptiveLayoutEnabled: (enabled: boolean) => void; } @@ -13,7 +15,7 @@ interface KeyboardPreferencesState { export const useKeyboardPreferencesStore = create()( persist( (set) => ({ - adaptiveLayoutEnabled: false, + adaptiveLayoutEnabled: true, setAdaptiveLayoutEnabled: (enabled) => set({ adaptiveLayoutEnabled: enabled }), }), @@ -26,3 +28,25 @@ export const useKeyboardPreferencesStore = create()( }, ), ); + +/** + * The layout map every dispatch consumer should use. Returns the OS layout + * map only when adaptive mapping is on; null otherwise (so logical bindings + * fall back to their authored chord). This is the single chokepoint — + * `useHotkey`, the resolver index, the display hooks, the recorder's + * conflict detector, and the imperative `getDispatchChord` all read through + * this so a future option that should affect dispatch doesn't have to be + * threaded through five callsites and miss one. Don't read + * `useKeyboardLayoutStore` directly outside this file. + */ +export function useEffectiveLayoutMap(): ReadonlyMap | null { + const layoutMap = useKeyboardLayoutStore((s) => s.map); + const adaptive = useKeyboardPreferencesStore((s) => s.adaptiveLayoutEnabled); + return adaptive ? layoutMap : null; +} + +/** Imperative form of {@link useEffectiveLayoutMap} for non-React contexts. */ +export function getEffectiveLayoutMap(): ReadonlyMap | null { + const adaptive = useKeyboardPreferencesStore.getState().adaptiveLayoutEnabled; + return adaptive ? useKeyboardLayoutStore.getState().map : null; +} diff --git a/apps/desktop/src/renderer/hotkeys/types.ts b/apps/desktop/src/renderer/hotkeys/types.ts index 96d6982c73e..5caf715372a 100644 --- a/apps/desktop/src/renderer/hotkeys/types.ts +++ b/apps/desktop/src/renderer/hotkeys/types.ts @@ -1,9 +1,9 @@ export type Platform = "mac" | "windows" | "linux"; export type PlatformKey = { - mac: string | null; - windows: string | null; - linux: string | null; + mac: ShortcutBinding | null; + windows: ShortcutBinding | null; + linux: ShortcutBinding | null; }; export type HotkeyCategory = @@ -22,7 +22,7 @@ export interface HotkeyDisplay { } export interface HotkeyDefinition { - key: string | null; + key: ShortcutBinding | null; label: string; category: HotkeyCategory; description?: string; @@ -30,20 +30,24 @@ export interface HotkeyDefinition { /** * How a binding identifies a key: - * - `physical`: matches `event.code` — same physical key on every layout. - * Default for shipped registry entries (preserves QWERTY muscle memory). - * - `logical`: matches the produced character (`event.key`) — same printed - * letter on every layout, even when it lives on different physical keys. - * Default for new user-recorded printable bindings. + * - `logical`: matches the produced character — same printed letter on + * every layout, even when it lives on different physical keys. Default + * for shipped registry entries (`⌘Z` always fires on the labeled-Z + * key) and for new user-recorded printable bindings, when adaptive + * layout mapping is enabled. + * - `physical`: matches `event.code` — same physical key on every + * layout regardless of what's printed on it. Used when adaptive + * layout mapping is off, or for explicit position-anchored bindings. * - `named`: stable named keys (Enter, ArrowUp, F1-F12, ...). Used * automatically for non-printable keys regardless of preference. */ export type BindingMode = "physical" | "logical" | "named"; /** - * Stored as a bare chord string for legacy / shipped defaults (implicitly - * physical) or a v2 object for explicit modes. The legacy string form is - * preserved indefinitely so default registry entries stay terse. + * Stored as a bare chord string for legacy storage (implicitly physical + * or named, decided by `defaultModeForChord`) or a v2 object for explicit + * modes. Shipped defaults use the v2 object form for printable chords — + * see the `L()` helper in `registry.ts`. */ export type ShortcutBinding = | string diff --git a/apps/desktop/src/renderer/hotkeys/utils/resolveHotkeyFromEvent.test.ts b/apps/desktop/src/renderer/hotkeys/utils/resolveHotkeyFromEvent.test.ts index d46ee4154ed..1a6094a7733 100644 --- a/apps/desktop/src/renderer/hotkeys/utils/resolveHotkeyFromEvent.test.ts +++ b/apps/desktop/src/renderer/hotkeys/utils/resolveHotkeyFromEvent.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { HOTKEYS, type HotkeyId } from "../registry"; import { useHotkeyOverridesStore } from "../stores/hotkeyOverridesStore"; import type { HotkeyDefinition, ShortcutBinding } from "../types"; +import { parseBinding } from "./binding"; import { canonicalizeChord, eventToChord, @@ -156,18 +157,19 @@ describe("resolveHotkeyFromEvent — live override index", () => { }); // Resolve once so registry reorders / removals surface as a test failure - // here instead of silently skipping the cases below. The type predicate - // narrows to a HotkeyDefinition whose .key is guaranteed non-null after - // the filter, so sampleDef.key can be passed to string-only helpers below. + // here instead of silently skipping the cases below. Defaults can be + // stored as bare strings (named/legacy) or v2 objects (logical) — extract + // the canonical chord via parseBinding so test helpers stay string-shaped. const sampleEntry = Object.entries(HOTKEYS).find( - (entry): entry is [HotkeyId, HotkeyDefinition & { key: string }] => + (entry): entry is [HotkeyId, HotkeyDefinition & { key: ShortcutBinding }] => entry[1].key !== null, ); if (!sampleEntry) throw new Error("HOTKEYS has no bound default"); const [sampleId, sampleDef] = sampleEntry; + const sampleChord = parseBinding(sampleDef.key).chord; it("resolves a default binding when no override is set", () => { - const event = buildEventFromChord(sampleDef.key); + const event = buildEventFromChord(sampleChord); expect(resolveHotkeyFromEvent(event)).toBe(sampleId); }); @@ -183,7 +185,7 @@ describe("resolveHotkeyFromEvent — live override index", () => { useHotkeyOverridesStore.setState({ overrides: { [sampleId]: "meta+shift+f10" }, }); - const event = buildEventFromChord(sampleDef.key); + const event = buildEventFromChord(sampleChord); expect(resolveHotkeyFromEvent(event)).toBeNull(); }); @@ -191,7 +193,7 @@ describe("resolveHotkeyFromEvent — live override index", () => { useHotkeyOverridesStore.setState({ overrides: { [sampleId]: null }, }); - const event = buildEventFromChord(sampleDef.key); + const event = buildEventFromChord(sampleChord); expect(resolveHotkeyFromEvent(event)).toBeNull(); }); }); diff --git a/apps/desktop/src/renderer/hotkeys/utils/resolveHotkeyFromEvent.ts b/apps/desktop/src/renderer/hotkeys/utils/resolveHotkeyFromEvent.ts index e5a6c44912d..f43626aeeae 100644 --- a/apps/desktop/src/renderer/hotkeys/utils/resolveHotkeyFromEvent.ts +++ b/apps/desktop/src/renderer/hotkeys/utils/resolveHotkeyFromEvent.ts @@ -1,19 +1,13 @@ import { HOTKEYS, type HotkeyId } from "../registry"; import { useHotkeyOverridesStore } from "../stores/hotkeyOverridesStore"; import { useKeyboardLayoutStore } from "../stores/keyboardLayoutStore"; -import { useKeyboardPreferencesStore } from "../stores/keyboardPreferencesStore"; +import { + getEffectiveLayoutMap, + useKeyboardPreferencesStore, +} from "../stores/keyboardPreferencesStore"; import type { ShortcutBinding } from "../types"; import { bindingToDispatchChord } from "./binding"; -/** Layout map for the resolver — null when the user has disabled adaptive - * layout mapping, so logical bindings keep their authored chord. */ -function activeLayoutMap(): ReadonlyMap | null { - if (!useKeyboardPreferencesStore.getState().adaptiveLayoutEnabled) { - return null; - } - return useKeyboardLayoutStore.getState().map; -} - /** * KeyboardEvent → registered {@link HotkeyId}, or `null` if unbound. Uses the * same `event.code` normalization as react-hotkeys-hook so the reverse index @@ -146,26 +140,18 @@ function buildRegisteredAppChords( // Reassigned on each override, layout, OR adaptive-layout-toggle change; // `let` is required so the subscribe callbacks can replace the reference -// the resolver reads. +// the resolver reads. Read the layout map through `getEffectiveLayoutMap` +// so the toggle state is honored on every rebuild. let registeredAppChords = buildRegisteredAppChords( useHotkeyOverridesStore.getState().overrides, - activeLayoutMap(), + getEffectiveLayoutMap(), ); -useHotkeyOverridesStore.subscribe((state) => { - registeredAppChords = buildRegisteredAppChords( - state.overrides, - activeLayoutMap(), - ); -}); -useKeyboardLayoutStore.subscribe(() => { +function rebuild() { registeredAppChords = buildRegisteredAppChords( useHotkeyOverridesStore.getState().overrides, - activeLayoutMap(), + getEffectiveLayoutMap(), ); -}); -useKeyboardPreferencesStore.subscribe(() => { - registeredAppChords = buildRegisteredAppChords( - useHotkeyOverridesStore.getState().overrides, - activeLayoutMap(), - ); -}); +} +useHotkeyOverridesStore.subscribe(rebuild); +useKeyboardLayoutStore.subscribe(rebuild); +useKeyboardPreferencesStore.subscribe(rebuild); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/keyboard/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/keyboard/page.tsx index 992d9a59b55..d658c52414f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/keyboard/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/keyboard/page.tsx @@ -232,9 +232,10 @@ function KeyboardShortcutsPage() { Adaptive layout mapping

- Remap shortcuts to your current keyboard layout (e.g. ⌘Z dispatches - to physical KeyY on QWERTZ). When off, shortcuts always match the - keys you bound. + Match shortcuts to the labels on your keyboard (e.g. ⌘Z always fires + on the key labeled "Z" — physical KeyY on QWERTZ). When off, + shortcuts are anchored to physical key positions and ignore the + current input source.