diff --git a/packages/app/src/keyboard/keyboard-shortcuts.test.ts b/packages/app/src/keyboard/keyboard-shortcuts.test.ts index 2a7960c9b9..571474643b 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.test.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.test.ts @@ -1027,6 +1027,71 @@ describe("unassigned shortcuts", () => { }); describe("matching", () => { + it("dispatches a captured Russian letter shortcut on the same key", () => { + const event = keyboardInput({ key: "и", code: "KeyB", ctrlKey: true }); + const combo = keyboardEventToComboString(event as KeyboardEvent); + expect(combo).toBe("Ctrl+B"); + const bindings = buildEffectiveBindings({ + "sidebar-toggle-left-ctrl-period-non-mac": combo!, + }); + + expect(resolveShortcut({ event, context: desktopNonMac, bindings }).match?.action).toBe( + "sidebar.toggle.left", + ); + }); + + it("dispatches a recorded dotted-uppercase letter with Shift held", () => { + const event = keyboardInput({ key: "İ", code: "KeyS", ctrlKey: true, shiftKey: true }); + const combo = keyboardEventToComboString(event as KeyboardEvent); + expect(combo).toBe("Ctrl+Shift+S"); + const bindings = buildEffectiveBindings({ + "sidebar-toggle-left-ctrl-period-non-mac": combo!, + }); + expect(resolveShortcut({ event, context: desktopNonMac, bindings }).match?.action).toBe( + "sidebar.toggle.left", + ); + }); + + it.each(["и", "И", "β", "ش", "ב", "é"])( + "matches default and recorded letter bindings for unsupported letter %s", + (key) => { + const event = keyboardInput({ key, code: "KeyB", ctrlKey: true }); + expectShortcutResolution({ + event, + context: desktopNonMac, + action: "sidebar.toggle.left", + }); + const combo = keyboardEventToComboString(event as KeyboardEvent); + expect(combo).toBe("Ctrl+B"); + expect( + resolveShortcut({ + event, + context: desktopNonMac, + bindings: buildEffectiveBindings({ + "sidebar-toggle-left-ctrl-period-non-mac": combo!, + }), + }).match?.action, + ).toBe("sidebar.toggle.left"); + }, + ); + + it.each(["v", "†", "Dead", "Unidentified", ""])( + "does not match an unrelated character or named key %s by physical letter", + (key) => { + expectNoShortcutResolution({ + event: { key, code: "KeyB", ctrlKey: true }, + context: desktopNonMac, + }); + }, + ); + + it("does not match non-letter bindings by physical key for unsupported letters", () => { + expectNoShortcutResolution({ + event: { key: "ю", code: "Period", ctrlKey: true }, + context: desktopNonMac, + }); + }); + it("dispatches a captured Dvorak binding on the same logical key", () => { const event = keyboardInput({ key: "b", code: "KeyN", metaKey: true }); const combo = keyboardEventToComboString(event as KeyboardEvent); diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index 4a843124f2..54d2b59cdb 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -1285,6 +1285,12 @@ function matchesKeyOrCode(combo: KeyCombo, event: KeyboardShortcutInput): boolea if (combo.shift === true && combo.shiftedKey !== undefined && eventKey === combo.shiftedKey) { return true; } + // Recording an unsupported letter (for example Cyrillic и on KeyB) + // stores its physical A-Z key. Dispatch must accept that same key without + // giving Latin layouts such as Dvorak a second physical-key binding. + if (/^[a-z]$/.test(combo.key) && /^\p{Letter}$/u.test(event.key) && !/^[a-z]$/.test(eventKey)) { + return event.code === combo.code; + } // 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