diff --git a/apps/desktop/src/lib/keybinds/combo.test.ts b/apps/desktop/src/lib/keybinds/combo.test.ts index b7452fd6c46..676a3852c61 100644 --- a/apps/desktop/src/lib/keybinds/combo.test.ts +++ b/apps/desktop/src/lib/keybinds/combo.test.ts @@ -32,6 +32,50 @@ 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('uses layout-aware punctuation for Cmd shortcuts on non-QWERTY layouts', async () => { + const { comboFromEvent } = await loadCombo('MacIntel') + + // Dvorak puts "." on the physical QWERTY V key — ⌘. must still reach the + // command center rather than resolving to the physical token. + expect(comboFromEvent(keydown({ code: 'KeyV', key: '.', metaKey: true }))).toBe('mod+.') + expect(comboFromEvent(keydown({ code: 'KeyW', key: ',', metaKey: true }))).toBe('mod+,') + expect(comboFromEvent(keydown({ code: 'BracketLeft', key: '/', metaKey: true }))).toBe('mod+/') + // AZERTY reaches "," from the physical QWERTY M key. + expect(comboFromEvent(keydown({ code: 'KeyM', key: ',', metaKey: true }))).toBe('mod+,') + }) + + it('keeps digits physical so AZERTY\'s shifted number row still binds', async () => { + const { comboFromEvent } = await loadCombo('MacIntel') + + // On AZERTY the unshifted "1" key types "&", and "1" only with Shift held. + // Both must resolve to the same `mod+1` the QWERTY user gets. + expect(comboFromEvent(keydown({ code: 'Digit1', key: '&', metaKey: true }))).toBe('mod+1') + expect(comboFromEvent(keydown({ code: 'Digit1', key: '1', metaKey: true }))).toBe('mod+1') + }) + + it('falls back to the physical key for glyphs we do not ship as tokens', async () => { + const { comboFromEvent } = await loadCombo('MacIntel') + + // Option-modified glyphs, dead keys, and non-Latin scripts are not combo + // tokens, so the physical code keeps the binding reachable. + expect(comboFromEvent(keydown({ code: 'KeyK', key: '˚', metaKey: true, altKey: true }))).toBe('mod+alt+k') + expect(comboFromEvent(keydown({ code: 'KeyN', key: 'Dead', metaKey: true, altKey: true }))).toBe('mod+alt+n') + expect(comboFromEvent(keydown({ code: 'KeyK', key: 'л', metaKey: true }))).toBe('mod+k') + }) + 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..78f91e96e22 100644 --- a/apps/desktop/src/lib/keybinds/combo.ts +++ b/apps/desktop/src/lib/keybinds/combo.ts @@ -2,8 +2,10 @@ // // 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 derive the base key from `event.key` where the layout matters +// (letters, unshifted punctuation) and from `event.code` otherwise, so a +// binding follows the character the user's layout actually types while Shift +// never mutates it ("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 +72,32 @@ function baseKeyFromCode(code: string): string | null { return CODE_TO_KEY[code] ?? null } +// Punctuation we ship as combo tokens, derived from CODE_TO_KEY so the two +// can't drift. Named tokens (space, tab, …) are excluded by the length check. +const PUNCTUATION_KEYS = new Set(Object.values(CODE_TO_KEY).filter(token => token.length === 1)) + +// The layout-aware half of the base key. `event.key` carries the character the +// user's layout actually produces, which is what a binding should match: +// +// - Letters always win, shifted or not — `toLowerCase` normalizes the case. +// - Punctuation only when Shift is UP, because a shifted `event.key` is the +// shifted glyph ("?" for "/"), and combos stay anchored to the unshifted +// token. Shifted punctuation falls through to `event.code` below. +// +// Digits deliberately stay physical: on AZERTY the number row is shifted, so +// `event.key` for the "1" key is "&" and only yields "1" with Shift held — +// `event.code` is what keeps `mod+1` reachable there. +// +// Anything else (Option glyphs like "˚", dead keys, non-Latin scripts) isn't a +// token we ship, so it fails both checks and falls back to the physical code. +function baseKeyFromEventKey(key: string, shiftKey: boolean): string | null { + if (/^[a-z]$/i.test(key)) { + return key.toLowerCase() + } + + return !shiftKey && PUNCTUATION_KEYS.has(key) ? key : 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 +105,7 @@ export function comboFromEvent(event: KeyboardEvent): string | null { return null } - const base = baseKeyFromCode(event.code) + const base = baseKeyFromEventKey(event.key, event.shiftKey) ?? baseKeyFromCode(event.code) if (!base) { return null