mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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,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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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("+");
|
||||
|
||||
Reference in New Issue
Block a user