From de7bf2fb01d56268f8d76ec0a016231f71f2313d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 22 May 2026 17:55:48 +0700 Subject: [PATCH] fix(app): restore Alt+letter shortcuts on macOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The key matcher only consulted event.code when a binding explicitly opted in via codeFallback. macOS rewrites event.key whenever Option is held (Option+T -> "†", Option+[ -> "“", Option+Shift+W -> "„", etc.), so the comparison against combo.key always failed and every Alt-bound letter / bracket shortcut silently stopped firing on Mac: Cmd+Alt+T (cycle theme), Alt+Shift+[/], Alt+[/], Alt+Shift+W. Fall back to event.code when combo.alt is set. Keep the existing key-first behaviour for non-Alt bindings so Dvorak users still get logical-character matching (Cmd+V on physical Period keeps pasting instead of triggering Cmd+. for sidebar toggle). --- .../src/keyboard/keyboard-shortcuts.test.ts | 51 +++++++++++++++++++ .../app/src/keyboard/keyboard-shortcuts.ts | 31 ++++++----- 2 files changed, 70 insertions(+), 12 deletions(-) diff --git a/packages/app/src/keyboard/keyboard-shortcuts.test.ts b/packages/app/src/keyboard/keyboard-shortcuts.test.ts index 01cc7e2d5..8508fbda5 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.test.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.test.ts @@ -300,6 +300,49 @@ describe("keyboard-shortcuts", () => { preventDefault: false, stopPropagation: false, }, + // macOS rewrites event.key when Option is held (Option+T -> "†", + // Option+[ -> "“", Option+Shift+W -> "„", etc.). Every Alt-bound + // letter / bracket shortcut must still resolve. + { + name: "matches Cmd+Alt+T to cycle theme on macOS when Option substitutes event.key", + event: { key: "\u2020", code: "KeyT", metaKey: true, altKey: true }, + context: { isMac: true }, + action: "theme.cycle", + }, + { + name: "matches Alt+Shift+[ to previous tab on macOS when Option substitutes event.key", + event: { key: "\u201D", code: "BracketLeft", altKey: true, shiftKey: true }, + context: { isMac: true }, + action: "workspace.tab.navigate.relative", + payload: { delta: -1 }, + }, + { + name: "matches Alt+Shift+] to next tab on macOS when Option substitutes event.key", + event: { key: "\u2019", code: "BracketRight", altKey: true, shiftKey: true }, + context: { isMac: true }, + action: "workspace.tab.navigate.relative", + payload: { delta: 1 }, + }, + { + name: "matches Alt+[ to previous workspace on macOS web when Option substitutes event.key", + event: { key: "\u201C", code: "BracketLeft", altKey: true }, + context: { isMac: true, isDesktop: false }, + action: "workspace.navigate.relative", + payload: { delta: -1 }, + }, + { + name: "matches Alt+] to next workspace on macOS web when Option substitutes event.key", + event: { key: "\u2018", code: "BracketRight", altKey: true }, + context: { isMac: true, isDesktop: false }, + action: "workspace.navigate.relative", + payload: { delta: 1 }, + }, + { + name: "matches Alt+Shift+W to close current tab on macOS web when Option substitutes event.key", + event: { key: "\u201E", code: "KeyW", altKey: true, shiftKey: true }, + context: { isMac: true, isDesktop: false }, + action: "workspace.tab.close.current", + }, ]; it.each(matchingCases)( @@ -401,6 +444,14 @@ describe("keyboard-shortcuts", () => { event: { key: "v", code: "Period", metaKey: true }, context: { isMac: true, isDesktop: true, focusScope: "message-input" }, }, + // Sanity: the macOS Option-substitution fallback must still respect + // modifier checks — pressing Option+T alone (no Cmd) must not trigger + // the Cmd+Alt+T theme-cycle binding. + { + name: "does not cycle theme on macOS when Cmd is missing (Alt+T alone)", + event: { key: "\u2020", code: "KeyT", altKey: true }, + context: { isMac: true }, + }, ]; it.each(nonMatchingCases)("$name", ({ event, context }) => { diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index 26a86a272..a67945aae 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -1020,6 +1020,24 @@ function parseDigit(event: KeyboardEvent): number | null { return null; } +function matchesKeyOrCode(combo: KeyCombo, event: KeyboardEvent): boolean { + if (combo.key === undefined) { + return event.code === combo.code; + } + const eventKey = event.key.toLowerCase(); + if (eventKey === combo.key) return true; + if (combo.shift === true && combo.shiftedKey !== undefined && eventKey === combo.shiftedKey) { + return true; + } + // macOS rewrites event.key when Option is held (Option+T -> "†", + // Option+[ -> "“"), so Alt-bound letter / bracket bindings can only + // match by event.code. Stay key-first for non-Alt bindings so Dvorak + // keeps its logical-character matching (e.g. Cmd+V on physical Period + // must paste, not trigger Cmd+.). + if (combo.alt === true && event.code === combo.code) return true; + return combo.codeFallback === true && event.code === combo.code; +} + function matchesCombo(combo: KeyCombo, event: KeyboardEvent, isMac: boolean): boolean { if (combo.mod) { if (isMac) { @@ -1040,18 +1058,7 @@ function matchesCombo(combo: KeyCombo, event: KeyboardEvent, isMac: boolean): bo if (combo.code === "Digit") { return parseDigit(event) !== null; } - - if (combo.key !== undefined) { - const eventKey = event.key.toLowerCase(); - if (eventKey === combo.key) return true; - if (combo.shift === true && combo.shiftedKey !== undefined && eventKey === combo.shiftedKey) { - return true; - } - return combo.codeFallback === true && event.code === combo.code; - } - - const codeMatch = event.code === combo.code; - return codeMatch; + return matchesKeyOrCode(combo, event); } function matchesWhen(when: ShortcutWhen | undefined, context: KeyboardShortcutContext): boolean {