fix(app): restore Alt+letter shortcuts on macOS

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).
This commit is contained in:
Mohamed Boudra
2026-05-22 17:55:48 +07:00
parent 5f11f602fb
commit de7bf2fb01
2 changed files with 70 additions and 12 deletions
@@ -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 }) => {
+19 -12
View File
@@ -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 {