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:
@@ -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 }) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user