diff --git a/apps/desktop/src/lib/keybinds/combo.test.ts b/apps/desktop/src/lib/keybinds/combo.test.ts index b7452fd6c46..f5f1203999a 100644 --- a/apps/desktop/src/lib/keybinds/combo.test.ts +++ b/apps/desktop/src/lib/keybinds/combo.test.ts @@ -32,6 +32,19 @@ describe('comboFromEvent — ctrl as a distinct modifier on macOS', () => { expect(comboFromEvent(keydown({ code: 'KeyK', ctrlKey: true }))).toBe('ctrl+k') }) + it('uses layout-aware letters for Cmd shortcuts on non-QWERTY layouts', async () => { + const { comboFromEvent } = await loadCombo('MacIntel') + + expect(comboFromEvent(keydown({ code: 'KeyI', key: 'c', metaKey: true }))).toBe('mod+c') + expect(comboFromEvent(keydown({ code: 'KeyI', key: 'C', metaKey: true, shiftKey: true }))).toBe('mod+shift+c') + }) + + it('keeps shifted punctuation anchored to the physical key token', async () => { + const { comboFromEvent } = await loadCombo('MacIntel') + + expect(comboFromEvent(keydown({ code: 'Slash', key: '?', metaKey: true, shiftKey: true }))).toBe('mod+shift+/') + }) + it('treats Control as the "mod" accelerator off macOS', async () => { const { comboFromEvent } = await loadCombo('Win32') diff --git a/apps/desktop/src/lib/keybinds/combo.ts b/apps/desktop/src/lib/keybinds/combo.ts index cfaaa840b32..dfc2e7a4ae4 100644 --- a/apps/desktop/src/lib/keybinds/combo.ts +++ b/apps/desktop/src/lib/keybinds/combo.ts @@ -2,8 +2,9 @@ // // A combo is a canonical lowercase string like "mod+k", "mod+shift+]", "shift+x", // or "r". `mod` is Cmd on macOS / Ctrl elsewhere, so a single binding works on -// both. We derive the base key from `event.code` (not `event.key`) so Shift never -// mutates it ("shift+/" stays "shift+/" instead of becoming "shift+?"). +// both. We prefer layout-aware `event.key` for letters, then fall back to +// `event.code` so shifted punctuation still normalizes to its unshifted token +// ("shift+/" stays "shift+/" instead of becoming "shift+?"). // // `ctrl` is physical Control, distinct from `mod`. It only matters on macOS, // where `mod` is Cmd and Cmd+Tab is OS-reserved — so `ctrl+tab` is literally @@ -70,6 +71,10 @@ function baseKeyFromCode(code: string): string | null { return CODE_TO_KEY[code] ?? null } +function baseKeyFromEventKey(key: string): string | null { + return /^[a-z]$/i.test(key) ? key.toLowerCase() : null +} + // Returns the canonical combo for a keydown, or null while only modifiers are // held (so capture mode keeps waiting for a real key). export function comboFromEvent(event: KeyboardEvent): string | null { @@ -77,7 +82,7 @@ export function comboFromEvent(event: KeyboardEvent): string | null { return null } - const base = baseKeyFromCode(event.code) + const base = baseKeyFromEventKey(event.key) ?? baseKeyFromCode(event.code) if (!base) { return null