diff --git a/CHANGELOG.md b/CHANGELOG.md index 96aa1a999..a1edb6a53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased +### The New chat shortcut works on a Russian or Greek keyboard layout + +Settings lists New chat as Shift+N, and the app matched the character the keystroke wrote. A layout +that writes another script has no key that writes an N: Shift and the N key write "Т" on Russian and +"Ν" on Greek, so the shortcut never fired there. When the character is not ASCII, the app now reads +the physical key instead. A layout that writes Latin letters, such as Dvorak, still goes by the letter. + ### Enter that confirms a typed character no longer saves a name, a rule or a wizard step Japanese, Chinese and Korean are typed through an input method, where Enter confirms the character diff --git a/app/src/lib/hotkeys/hotkeys.ts b/app/src/lib/hotkeys/hotkeys.ts index 7aa303618..7c2c3b7a3 100644 --- a/app/src/lib/hotkeys/hotkeys.ts +++ b/app/src/lib/hotkeys/hotkeys.ts @@ -52,6 +52,22 @@ const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform); +/** + * The key a keystroke names, spelled the way a combo spells it. + * + * `key` first, because it follows the layout: on Dvorak the letter N is not where QWERTY puts it, and + * the key a person presses when told "N" is the one that writes an N. A layout that writes another + * script has no such key. Shift and the N key write "Т" on Russian and "Ν" (Greek capital nu, not a + * Latin N) on Greek, so a shortcut read from `key` alone never fired there. When `key` is a single + * character outside ASCII, the physical key in `code` is the only N there is. + */ +function keyOf(event: KeyboardEvent): string { + const written = event.key.toLowerCase(); + if (written.length !== 1 || written.charCodeAt(0) < 0x80) return written; + const physical = /^(?:Key|Digit)([A-Z0-9])$/.exec(event.code); + return physical?.[1]?.toLowerCase() ?? written; +} + /** * Whether this keystroke is this combo — exactly, not at-least. * @@ -66,7 +82,7 @@ export function matchesHotkey( const mod = isMac ? event.metaKey : event.ctrlKey; const otherMod = isMac ? event.ctrlKey : event.metaKey; return ( - event.key.toLowerCase() === combo.key && + keyOf(event) === combo.key && event.shiftKey === Boolean(combo.shift) && mod === Boolean(combo.mod) && !otherMod && diff --git a/app/tests/hotkey-layouts.test.ts b/app/tests/hotkey-layouts.test.ts new file mode 100644 index 000000000..8815d3289 --- /dev/null +++ b/app/tests/hotkey-layouts.test.ts @@ -0,0 +1,78 @@ +import { expect, test } from "bun:test"; +import { getHotkey, matchesHotkey } from "@/lib/hotkeys/hotkeys"; + +/** + * The New chat shortcut on a keyboard layout that does not write Latin letters. + * + * Settings shows it as Shift+N, and `matchesHotkey` compared `KeyboardEvent.key`. That is right for + * a layout that moves the letter, such as Dvorak: the key a person presses when told "N" is the one + * that writes an N. A layout that writes another script has no key that writes an N at + * all. Shift and the N key write "Т" on Russian and "Ν" (Greek capital nu, not a Latin N) on Greek, + * so the shortcut Settings lists never fired for anybody with one of those layouts selected. + */ + +const combo = getHotkey("new-chat").combo; + +/** A keydown as a browser reports it: `key` from the layout, `code` from the physical key. */ +function keydown( + key: string, + code: string, + modifiers: Partial< + Pick + > = {}, +): KeyboardEvent { + return { + key, + code, + shiftKey: false, + ctrlKey: false, + metaKey: false, + altKey: false, + ...modifiers, + } as KeyboardEvent; +} + +test("Shift and the N key start a new chat on a layout that writes another script", () => { + const pressed = [ + ["Russian", keydown("Т", "KeyN", { shiftKey: true })], + ["Greek", keydown("Ν", "KeyN", { shiftKey: true })], + ] as const; + + expect( + pressed.map(([layout, event]) => [layout, matchesHotkey(event, combo)]), + ).toEqual([ + ["Russian", true], + ["Greek", true], + ]); +}); + +test("a layout that writes Latin letters still goes by the letter, wherever its key is", () => { + // Dvorak writes N on the key QWERTY calls L, and B on the key QWERTY calls N. + expect(matchesHotkey(keydown("N", "KeyL", { shiftKey: true }), combo)).toBe( + true, + ); + expect(matchesHotkey(keydown("B", "KeyN", { shiftKey: true }), combo)).toBe( + false, + ); +}); + +test("the modifiers are still exact on the physical key", () => { + expect(matchesHotkey(keydown("т", "KeyN"), combo)).toBe(false); + expect( + matchesHotkey( + keydown("Т", "KeyN", { shiftKey: true, ctrlKey: true, metaKey: true }), + combo, + ), + ).toBe(false); + // A character from another script on some other key is not N, and neither is one on a key that + // has no letter of its own, nor the Shift key itself. + expect(matchesHotkey(keydown("Ь", "KeyM", { shiftKey: true }), combo)).toBe( + false, + ); + expect( + matchesHotkey(keydown("Ё", "Backquote", { shiftKey: true }), combo), + ).toBe(false); + expect( + matchesHotkey(keydown("Shift", "ShiftLeft", { shiftKey: true }), combo), + ).toBe(false); +});