fix(keymap): match remaining letter shortcuts by typed character (#292)

#291 named letter keys by the typed character (e.key) when Alt is not held, so AZERTY Ctrl+Z is no longer Ctrl+W. Hard-coded handlers still compared the physical KeyboardEvent.code. A shared letterOf(e) in platform/keymap.ts now backs strokeOf and those handlers:

- terminal Ctrl+Shift+C / V / T (copy, paste, new tab) and Ctrl+letter control codes
- Run/Debug Configurations Ctrl/Cmd+N
- browser shortcut guards (blocked reload/print etc.)

Alt combos keep the physical key (Option / AltGr type symbols).
This commit is contained in:
l0ng-ai
2026-10-29 23:05:26 +08:00
committed by GitHub
parent d1a597898d
commit aeec0e6759
8 changed files with 64 additions and 18 deletions
+3 -1
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState, type DragEvent, type KeyboardEven
import clsx from "clsx";
import { executeAction } from "@/platform/actions";
import { call, commands, isTauri, type RunConfig } from "@/platform/ipc";
import { letterOf } from "@/platform/keymap";
import { useWorkflowSettings } from "@/platform/workflowSettings";
import { Button, Dialog, Icon, IconButton, Input, T } from "@/ui";
import {
@@ -312,7 +313,8 @@ function EditConfigsDialog() {
// IDEA: ⌘N / Alt+Insert adds a configuration, ⌥M opens "Modify options".
const onKeyDown = (e: KeyboardEvent) => {
const add =
(e.code === "KeyN" && (isMac() ? e.metaKey || e.ctrlKey : e.ctrlKey)) || (e.altKey && e.key === "Insert");
(letterOf(e) === "N" && !e.altKey && (isMac() ? e.metaKey || e.ctrlKey : e.ctrlKey)) ||
(e.altKey && e.key === "Insert");
if (add && mode === "configs") {
e.preventDefault();
openAdd();
+11
View File
@@ -318,6 +318,17 @@ describe("Run/Debug Configurations tree", () => {
fireEvent.click(await screen.findByText("Redirect input"));
expect(screen.getByLabelText("Redirect input from")).toBeTruthy();
});
it("opens Add New Configuration on Ctrl+N by typed letter", () => {
useRun.setState({ configs: [], loaded: true, editorOpen: true });
render(<EditConfigsOverlay />);
const tree = screen.getByRole("tree", { name: "Run configurations" });
// Dvorak: physical KeyN types "b", physical KeyL types "n".
fireEvent.keyDown(tree, { key: "b", code: "KeyN", ctrlKey: true });
expect(screen.queryByLabelText("Search configuration types")).toBeNull();
fireEvent.keyDown(tree, { key: "n", code: "KeyL", ctrlKey: true });
expect(screen.getByLabelText("Search configuration types")).toBeTruthy();
});
});
describe("debug-only types (Remote JVM Debug)", () => {
+6 -6
View File
@@ -15,7 +15,7 @@
* there is no terminal, so there is nothing to handle.
*/
import { executeAction } from "@/platform/actions";
import { effectiveBindings, strokeOf } from "@/platform/keymap";
import { effectiveBindings, letterOf, strokeOf } from "@/platform/keymap";
import { workflowSettings } from "@/platform/workflowSettings";
import { terminalRuntime } from "./loader";
import { isImeKey } from "@/platform/ime";
@@ -53,16 +53,16 @@ export function terminalKeyAction(e: KeyLike, hasSelection: boolean): TerminalKe
if (e.key === "Backspace") return { kind: "send", data: "\x17" };
return { kind: "pass" };
}
const letter = letterOf(e);
if (ctrl && shift && !meta && !alt) {
if (e.code === "KeyC") return hasSelection ? { kind: "copy" } : { kind: "pass" };
if (e.code === "KeyV") return { kind: "paste" };
if (letter === "C") return hasSelection ? { kind: "copy" } : { kind: "pass" };
if (letter === "V") return { kind: "paste" };
// New Tab on Windows / Linux (IDEA keymap); plain Ctrl+T still reaches the shell.
if (e.code === "KeyT") return { kind: "pass" };
if (letter === "T") return { kind: "pass" };
}
if (ctrl && !meta && !alt) {
// The letter the key types, so Shift doesn't change the control code and AZERTY's Ctrl+Z is ^Z, not ^W.
const letter = /^[a-zA-Z]$/.test(e.key) ? e.key : /^Key([A-Z])$/.exec(e.code)?.[1];
if (letter) return { kind: "send", data: String.fromCharCode(letter.toUpperCase().charCodeAt(0) - 64) };
if (letter) return { kind: "send", data: String.fromCharCode(letter.charCodeAt(0) - 64) };
if (e.code === "BracketLeft") return { kind: "send", data: "\x1b" };
if (e.code === "Backslash") return { kind: "send", data: "\x1c" };
if (e.code === "BracketRight") return { kind: "send", data: "\x1d" };
+14
View File
@@ -133,6 +133,20 @@ describe("terminal keys", () => {
kind: "send",
data: "\x1a",
});
// Non-QWERTY Ctrl+Shift+C / V / T match the typed letter, not the physical key.
expect(terminalKeyAction(key({ key: "C", code: "KeyI", ctrlKey: true, shiftKey: true }), true)).toEqual({
kind: "copy",
});
expect(terminalKeyAction(key({ key: "V", code: "KeyPeriod", ctrlKey: true, shiftKey: true }), false)).toEqual({
kind: "paste",
});
expect(terminalKeyAction(key({ key: "T", code: "KeyK", ctrlKey: true, shiftKey: true }), false)).toEqual({
kind: "pass",
});
expect(terminalKeyAction(key({ key: "J", code: "KeyC", ctrlKey: true, shiftKey: true }), true)).toEqual({
kind: "send",
data: "\x0a",
});
});
it("implements macOS conveniences", () => {
+4
View File
@@ -4,6 +4,7 @@ import {
installKeyboardDispatcher,
isAltGrTyping,
isValidBinding,
letterOf,
normalizeBinding,
parseDoubleModifier,
strokeOf,
@@ -31,6 +32,9 @@ describe("keymap", () => {
it("names letters by the character typed, so AZERTY's Ctrl+Z is not Ctrl+W", () => {
// AZERTY: the key typing "z" is physically KeyW.
expect(letterOf({ code: "KeyW", key: "z", altKey: false })).toBe("Z");
expect(letterOf({ code: "KeyF", key: "ƒ", altKey: true })).toBe("F");
expect(letterOf({ code: "Digit1", key: "1", altKey: false })).toBeNull();
expect(strokeOf({ code: "KeyW", key: "z", metaKey: false, ctrlKey: true, altKey: false, shiftKey: false })).toBe(
"Ctrl+Z",
);
+14 -4
View File
@@ -253,15 +253,25 @@ const CODE_NAMES: Record<string, string> = {
NumpadDivide: "NumpadDivide",
};
/**
* The uppercase ASCII letter of a key event: the character it types when Alt
* is not held (on AZERTY the Z key sits on the physical KeyW, so Ctrl+Z must
* not read as Ctrl+W), or the physical `KeyX` when Alt is held (⌥ on macOS and
* AltGr may type a symbol) or `e.key` is not an ASCII letter.
*/
export function letterOf(e: Pick<KeyboardEvent, "code" | "key"> & { altKey?: boolean }): string | null {
if (/^[a-zA-Z]$/.test(e.key) && !e.altKey) return e.key.toUpperCase();
if (/^Key[A-Z]$/.test(e.code)) return e.code.slice(3);
return null;
}
/** Converts a keydown event into a canonical stroke, or null for bare modifiers. */
export function strokeOf(
e: Pick<KeyboardEvent, "code" | "key" | "metaKey" | "ctrlKey" | "altKey" | "shiftKey">,
): string | null {
let key: string | undefined;
// A letter is named by the character it types: on AZERTY the Z key sits on the physical KeyW,
// so Ctrl+Z must not read as Ctrl+W. Alt combos may type a symbol (⌥ on macOS, AltGr), so they keep the physical key.
if (/^[a-zA-Z]$/.test(e.key) && !e.altKey) key = e.key.toUpperCase();
else if (/^Key[A-Z]$/.test(e.code)) key = e.code.slice(3);
const letter = letterOf(e);
if (letter) key = letter;
else if (/^Digit\d$/.test(e.code)) key = e.code.slice(5);
else if (/^Numpad\d$/.test(e.code)) key = e.code.slice(6);
else if (/^F\d{1,2}$/.test(e.code)) key = e.code;
+10
View File
@@ -56,4 +56,14 @@ describe("browser guards: context menu", () => {
rightClick(p);
expect(useContextMenuStore.getState().menu).toBeNull();
});
it("blocks browser shortcuts by typed letter", () => {
const press = (init: KeyboardEventInit) => {
const e = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ...init });
window.dispatchEvent(e);
return e.defaultPrevented;
};
expect(press({ key: "p", code: "KeyR", ctrlKey: true })).toBe(true);
expect(press({ key: "l", code: "KeyP", ctrlKey: true })).toBe(false);
});
});
+2 -7
View File
@@ -5,6 +5,7 @@
* shortcuts stay untouched. A right-click no component handled on selected or
* selectable read-only text gets IDEA's Copy menu instead of nothing.
*/
import { letterOf } from "@/platform/keymap";
import { copyMenuFor, showContextMenu, useContextMenuStore } from "@/ui";
const BLOCKED = new Set([
@@ -23,13 +24,7 @@ const BLOCKED = new Set([
const RELOAD = new Set(["Meta+R", "Meta+Shift+R", "Ctrl+R", "Ctrl+Shift+R", "F5", "Shift+F5"]);
function keyId(e: KeyboardEvent) {
const key = e.code.startsWith("Key")
? e.code.slice(3)
: e.code === "BracketLeft"
? "["
: e.code === "BracketRight"
? "]"
: e.key;
const key = letterOf(e) ?? (e.code === "BracketLeft" ? "[" : e.code === "BracketRight" ? "]" : e.key);
return [e.ctrlKey && "Ctrl", e.altKey && "Alt", e.shiftKey && "Shift", e.metaKey && "Meta", key]
.filter(Boolean)
.join("+");