Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 17 additions & 1 deletion app/src/lib/hotkeys/hotkeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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 &&
Expand Down
78 changes: 78 additions & 0 deletions app/tests/hotkey-layouts.test.ts
Original file line number Diff line number Diff line change
@@ -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, "shiftKey" | "ctrlKey" | "metaKey" | "altKey">
> = {},
): 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);
});