diff --git a/src/features/run/EditConfigsDialog.tsx b/src/features/run/EditConfigsDialog.tsx index 06018beb..a6c54e70 100644 --- a/src/features/run/EditConfigsDialog.tsx +++ b/src/features/run/EditConfigsDialog.tsx @@ -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(); diff --git a/src/features/run/runConfigTypes.test.tsx b/src/features/run/runConfigTypes.test.tsx index c928520f..0531328d 100644 --- a/src/features/run/runConfigTypes.test.tsx +++ b/src/features/run/runConfigTypes.test.tsx @@ -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(); + 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)", () => { diff --git a/src/features/terminal/keys.ts b/src/features/terminal/keys.ts index 98ce5a91..39ec66dc 100644 --- a/src/features/terminal/keys.ts +++ b/src/features/terminal/keys.ts @@ -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" }; diff --git a/src/features/terminal/terminal.test.ts b/src/features/terminal/terminal.test.ts index f5233225..580cf811 100644 --- a/src/features/terminal/terminal.test.ts +++ b/src/features/terminal/terminal.test.ts @@ -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", () => { diff --git a/src/platform/keymap.test.ts b/src/platform/keymap.test.ts index c5d3e694..62077e30 100644 --- a/src/platform/keymap.test.ts +++ b/src/platform/keymap.test.ts @@ -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", ); diff --git a/src/platform/keymap.ts b/src/platform/keymap.ts index 43941d48..8bac753b 100644 --- a/src/platform/keymap.ts +++ b/src/platform/keymap.ts @@ -253,15 +253,25 @@ const CODE_NAMES: Record = { 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 & { 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, ): 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; diff --git a/src/workbench/browserGuards.test.ts b/src/workbench/browserGuards.test.ts index d76c38d8..d44d6161 100644 --- a/src/workbench/browserGuards.test.ts +++ b/src/workbench/browserGuards.test.ts @@ -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); + }); }); diff --git a/src/workbench/browserGuards.ts b/src/workbench/browserGuards.ts index 167cb1c8..17a630b0 100644 --- a/src/workbench/browserGuards.ts +++ b/src/workbench/browserGuards.ts @@ -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("+");