From b2bfab48373dc18e1311bd3261bae2e4b5c37c53 Mon Sep 17 00:00:00 2001 From: LeonSGP43 Date: Mon, 15 Jun 2026 09:11:15 +0800 Subject: [PATCH 1/2] fix(desktop): honor layout-aware letter keybinds --- apps/desktop/src/lib/keybinds/combo.test.ts | 13 +++++++++++++ apps/desktop/src/lib/keybinds/combo.ts | 11 ++++++++--- 2 files changed, 21 insertions(+), 3 deletions(-) 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 From 83cc5831fd79dca103d6e737bfcf8848f4f981d0 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 19:57:51 -0500 Subject: [PATCH 2/2] fix(desktop): resolve punctuation keybinds through the active layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Letter chords now follow `event.key`, but punctuation was still anchored to the physical QWERTY position, so the shipped punctuation defaults stayed unreachable on a remapped layout. On Dvorak `mod+.` (command center) reads the physical V key, and `mod+,` / `mod+/` land on `w` / `[`. Take `event.key` for unshifted punctuation too. Shift stays excluded because a shifted `event.key` is the shifted glyph ("?" for "/") and combos are anchored to the unshifted token. Digits stay physical: AZERTY types "&" on the unshifted "1" key, so `event.code` is what keeps `mod+1` bound. Glyphs we do not ship as tokens — Option output, dead keys, non-Latin scripts — fail both checks and fall back to the physical code. The punctuation set derives from CODE_TO_KEY so the two cannot drift. --- apps/desktop/src/lib/keybinds/combo.test.ts | 31 ++++++++++++++++++ apps/desktop/src/lib/keybinds/combo.ts | 35 +++++++++++++++++---- 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/lib/keybinds/combo.test.ts b/apps/desktop/src/lib/keybinds/combo.test.ts index f5f1203999a..676a3852c61 100644 --- a/apps/desktop/src/lib/keybinds/combo.test.ts +++ b/apps/desktop/src/lib/keybinds/combo.test.ts @@ -45,6 +45,37 @@ describe('comboFromEvent — ctrl as a distinct modifier on macOS', () => { 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 dfc2e7a4ae4..78f91e96e22 100644 --- a/apps/desktop/src/lib/keybinds/combo.ts +++ b/apps/desktop/src/lib/keybinds/combo.ts @@ -2,9 +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 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+?"). +// 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 @@ -71,8 +72,30 @@ 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 +// 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 @@ -82,7 +105,7 @@ export function comboFromEvent(event: KeyboardEvent): string | null { return null } - const base = baseKeyFromEventKey(event.key) ?? baseKeyFromCode(event.code) + const base = baseKeyFromEventKey(event.key, event.shiftKey) ?? baseKeyFromCode(event.code) if (!base) { return null