дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
feat(editor): make caret mouse gestures configurable (#2143)
The gesture that adds a caret was hardcoded to Alt+Shift+Click, so it could not be changed in the Keymap settings. Adds the "Add or Remove Caret" action to Editor Actions, with Alt+Shift+Click as the default in every preset. `multiCaret.ts` now matches the event against that action's effective bindings, so rebinding and removal apply at once. A stroke bound to it takes precedence over Alt-drag column selection and over mouse-shortcut dispatch in the editor; column selection is unchanged otherwise. Closes #2127 Refs #2126
Цей коміт міститься в:
1 джерело
7f62011a08
коміт
6394f5904a
10 files changed
+149
-11
No files matched your search
@@ -778,6 +778,14 @@ export function registerEditorActions(): () => void {
|
||||
text("code.autoIndentLines", "Auto-Indent Lines", (v) => !v.state.readOnly && indentSelection(v), {
|
||||
keywords: ["reindent", "fix indentation"],
|
||||
}),
|
||||
{
|
||||
id: "editor.addOrRemoveCaret",
|
||||
title: "Add or Remove Caret",
|
||||
category: "Editor Actions",
|
||||
when: "textEditorActive",
|
||||
// The mouse selection style handles the pointer position and dragging.
|
||||
run: () => {},
|
||||
},
|
||||
text("editor.cloneCaretAbove", "Clone Caret Above", (v) => cloneCaretAbove(v), { category: "Editor Actions" }),
|
||||
text("editor.cloneCaretBelow", "Clone Caret Below", (v) => cloneCaretBelow(v), { category: "Editor Actions" }),
|
||||
text("editor.matchBrace", "Move Caret to Matching Brace", (v) => cursorMatchBrace(v), {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EditorSelection, EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { PRESETS, useKeymap } from "@/platform/keymap";
|
||||
import { usePlatform } from "@/platform/platformInfo";
|
||||
import { multiCaretMouse } from "./multiCaret";
|
||||
|
||||
let view: EditorView;
|
||||
const originalKeymap = useKeymap.getState();
|
||||
const originalPlatform = usePlatform.getState();
|
||||
const event = (init: MouseEventInit = {}) => new MouseEvent("mousedown", { button: 0, altKey: true, ...init });
|
||||
const styles = (e: MouseEvent) => view.state.facet(EditorView.mouseSelectionStyle).map((style) => style(view, e));
|
||||
|
||||
beforeEach(() => {
|
||||
useKeymap.setState({ preset: "intellij-macos", overrides: {} });
|
||||
usePlatform.setState({ os: "macos" });
|
||||
view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc: "first\nsecond\nthird",
|
||||
extensions: [EditorState.allowMultipleSelections.of(true), multiCaretMouse],
|
||||
}),
|
||||
parent: document.body,
|
||||
});
|
||||
vi.spyOn(view, "posAtCoords").mockImplementation((coords) => coords.x);
|
||||
vi.spyOn(view, "coordsAtPos").mockReturnValue(null);
|
||||
});
|
||||
afterEach(() => {
|
||||
view.destroy();
|
||||
useKeymap.setState(originalKeymap);
|
||||
usePlatform.setState(originalPlatform);
|
||||
});
|
||||
|
||||
describe("caret mouse bindings", () => {
|
||||
it.each(Object.keys(PRESETS))("%s keeps the default gesture", (preset) => {
|
||||
useKeymap.setState({ preset });
|
||||
expect(PRESETS[preset]!["editor.addOrRemoveCaret"]).toEqual(["Alt+Shift+Button1"]);
|
||||
expect(styles(event({ shiftKey: true }))[0]).not.toBeNull();
|
||||
});
|
||||
|
||||
it("matches the complete stroke and only the left button", () => {
|
||||
expect(styles(event({ shiftKey: true }))[0]).not.toBeNull();
|
||||
for (const init of [{}, { shiftKey: true, ctrlKey: true }, { shiftKey: true, metaKey: true }, { button: 1 }]) {
|
||||
expect(styles(event(init))[0]).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses rebinding immediately and yields column selection to the caret gesture", () => {
|
||||
expect(styles(event())[1]).not.toBeNull();
|
||||
useKeymap.setState({ overrides: { "editor.addOrRemoveCaret": ["Alt+Button1"] } });
|
||||
expect(styles(event({ shiftKey: true }))[0]).toBeNull();
|
||||
const [caret, column] = styles(event({ clientX: 2 }));
|
||||
expect(column).toBeNull();
|
||||
expect(caret!.get(event({ clientX: 2 }), false, false).ranges.map((r) => r.head)).toEqual([0, 2]);
|
||||
const drag = caret!.get(event({ clientX: 8 }), false, false);
|
||||
expect(drag.ranges.map((r) => [r.anchor, r.head])).toEqual([
|
||||
[0, 0],
|
||||
[2, 8],
|
||||
]);
|
||||
view.dispatch({ selection: EditorSelection.create([EditorSelection.cursor(0), EditorSelection.cursor(2)]) });
|
||||
const remove = styles(event({ clientX: 2 }))[0]!;
|
||||
expect(remove.get(event({ clientX: 2 }), false, false).ranges.map((r) => r.head)).toEqual([0]);
|
||||
});
|
||||
|
||||
it("honours removal and restores Alt-drag column selection", () => {
|
||||
useKeymap.setState({ overrides: { "editor.addOrRemoveCaret": [] } });
|
||||
expect(styles(event({ shiftKey: true }))[0]).toBeNull();
|
||||
expect(styles(event())[0]).toBeNull();
|
||||
expect(styles(event())[1]).not.toBeNull();
|
||||
useKeymap.setState({ overrides: {} });
|
||||
expect(styles(event({ shiftKey: true }))[0]).not.toBeNull();
|
||||
});
|
||||
|
||||
it("matches normalized Ctrl+Alt strokes as reported for right Alt on Windows", () => {
|
||||
useKeymap.setState({ overrides: { "editor.addOrRemoveCaret": ["alt+ctrl+button1"] } });
|
||||
expect(styles(event({ ctrlKey: true }))[0]).not.toBeNull();
|
||||
expect(styles(event())[0]).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves middle-button column selection except on Linux", () => {
|
||||
expect(styles(event({ button: 1, altKey: false }))[1]).not.toBeNull();
|
||||
usePlatform.setState({ os: "linux" });
|
||||
expect(styles(event({ button: 1, altKey: false }))[1]).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,11 @@
|
||||
/**
|
||||
* IDEA mouse multi-caret gestures (macOS keymap): ⌥⇧click adds a caret (or
|
||||
* removes the one under the pointer), ⌥⇧drag adds a selection, ⌥drag and
|
||||
* middle-button drag make a column (rectangular) selection. Middle-button
|
||||
* drag is left alone on Linux, where a middle click pastes the primary selection.
|
||||
*/
|
||||
/** Mouse caret gestures follow the keymap; Alt and middle-button drags select columns. */
|
||||
import { EditorSelection, type Extension } from "@codemirror/state";
|
||||
import { EditorView, type MouseSelectionStyle } from "@codemirror/view";
|
||||
import { bindingsFor, mouseStrokeOf, normalizeBinding } from "@/platform/keymap";
|
||||
import { usePlatform } from "@/platform/platformInfo";
|
||||
import { blockMouseSelection } from "./columnMode";
|
||||
|
||||
/** The selection after ⌥⇧click / drag from `anchor` to `head` on top of `start`. */
|
||||
/** The selection after a caret click / drag from `anchor` to `head` on top of `start`. */
|
||||
export function toggleCaret(start: EditorSelection, anchor: number, head: number): EditorSelection {
|
||||
if (anchor === head) {
|
||||
const hit = start.ranges.findIndex((r) => r.empty && r.head === head);
|
||||
@@ -21,8 +17,16 @@ export function toggleCaret(start: EditorSelection, anchor: number, head: number
|
||||
return start.addRange(EditorSelection.range(anchor, head));
|
||||
}
|
||||
|
||||
export function isAddCaretGesture(event: MouseEvent): boolean {
|
||||
if (event.button !== 0) return false;
|
||||
const stroke = mouseStrokeOf(event);
|
||||
return (
|
||||
stroke !== null && bindingsFor("editor.addOrRemoveCaret").some((binding) => normalizeBinding(binding) === stroke)
|
||||
);
|
||||
}
|
||||
|
||||
const addCaretStyle = EditorView.mouseSelectionStyle.of((view, event): MouseSelectionStyle | null => {
|
||||
if (event.button !== 0 || !event.altKey || !event.shiftKey || event.metaKey || event.ctrlKey) return null;
|
||||
if (!isAddCaretGesture(event)) return null;
|
||||
let start = view.state.selection;
|
||||
const anchor = view.posAtCoords({ x: event.clientX, y: event.clientY }, false);
|
||||
return {
|
||||
@@ -41,7 +45,7 @@ export const multiCaretMouse: Extension = [
|
||||
// CodeMirror's default (⌘click) would clash with ⌘click navigation.
|
||||
EditorView.clickAddsSelectionRange.of(() => false),
|
||||
blockMouseSelection((e) => {
|
||||
if (e.shiftKey || e.metaKey || e.ctrlKey) return false;
|
||||
if (isAddCaretGesture(e) || e.shiftKey || e.metaKey || e.ctrlKey) return false;
|
||||
if (e.button === 0) return e.altKey;
|
||||
return e.button === 1 && !e.altKey && usePlatform.getState().os !== "linux";
|
||||
}),
|
||||
|
||||
@@ -74,6 +74,7 @@ import { requestHover, showPhpConstructDocumentation } from "./hover";
|
||||
import { declarationSummary, renderCode } from "./markdown";
|
||||
import { editorPrefs } from "@/platform/editorSettings";
|
||||
import { isVirtualUri, virtualContainer } from "@/platform/virtualDocs";
|
||||
import { isAddCaretGesture } from "@/editor/core/cm/multiCaret";
|
||||
import { dispatchMouseShortcut, shortcutLabel } from "@/platform/keymap";
|
||||
import { isNavigationGesture, onNavigationModifier, navigationModifierHeld } from "@/platform/navigationModifier";
|
||||
import { create } from "zustand";
|
||||
@@ -710,6 +711,7 @@ const linkPlugin = ViewPlugin.fromClass(
|
||||
},
|
||||
mousedown(e, view) {
|
||||
if (e.button !== 0 && e.button !== 1) return false;
|
||||
if (isAddCaretGesture(e)) return false;
|
||||
if (
|
||||
dispatchMouseShortcut(e, () => {
|
||||
const pos = view.posAtCoords({ x: e.clientX, y: e.clientY }, false);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { setContext } from "@/platform/context";
|
||||
import { installKeyboardDispatcher, useKeymap } from "@/platform/keymap";
|
||||
import { usePlatform } from "@/platform/platformInfo";
|
||||
import { menuGroupItems } from "@/workbench/contributions";
|
||||
import { multiCaretMouse } from "@/editor/core/cm/multiCaret";
|
||||
import { navigationExtension } from "@/editor/lsp/navigation";
|
||||
import { activate } from "../index";
|
||||
import { adoptSession, publishContext, useDebug } from "../state";
|
||||
@@ -45,7 +46,7 @@ function session(adapter = "debugpy", status: "paused" | "running" | "terminated
|
||||
publishContext();
|
||||
}
|
||||
function click() {
|
||||
const e = new MouseEvent("mousedown", { button: 0, altKey: true, bubbles: true, cancelable: true });
|
||||
const e = new MouseEvent("mousedown", { button: 0, altKey: true, detail: 1, bubbles: true, cancelable: true });
|
||||
view.contentDOM.dispatchEvent(e);
|
||||
return e;
|
||||
}
|
||||
@@ -58,7 +59,15 @@ beforeEach(() => {
|
||||
dispose = activate();
|
||||
uninstall = installKeyboardDispatcher(window);
|
||||
view = new EditorView({
|
||||
state: EditorState.create({ doc, extensions: [navigationExtension(), quickEvaluateMouse()] }),
|
||||
state: EditorState.create({
|
||||
doc,
|
||||
extensions: [
|
||||
EditorState.allowMultipleSelections.of(true),
|
||||
navigationExtension(),
|
||||
quickEvaluateMouse(),
|
||||
multiCaretMouse,
|
||||
],
|
||||
}),
|
||||
parent: document.body,
|
||||
});
|
||||
editor.current = { view, ctx: { path: "/p/app.py" } };
|
||||
@@ -133,6 +142,32 @@ describe("paused editor evaluation", () => {
|
||||
expect(ipc.debugEvaluateIn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"adds a caret without dispatching an action after removing the evaluation conflict (paused: %s)",
|
||||
(paused) => {
|
||||
if (paused) session();
|
||||
const run = vi.fn();
|
||||
const remove = registerAction({ id: "editor.addOrRemoveCaret", title: "Add or Remove Caret", run });
|
||||
useKeymap.setState({ overrides: { "editor.addOrRemoveCaret": ["Alt+Button1"], "debug.quickEvaluate": [] } });
|
||||
try {
|
||||
click();
|
||||
expect(view.state.selection.ranges.map((r) => r.head)).toEqual([0, 16]);
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
expect(ipc.debugEvaluateIn).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
remove();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("does not also add a caret when an enabled evaluation handles a shared stroke", async () => {
|
||||
session();
|
||||
useKeymap.setState({ overrides: { "editor.addOrRemoveCaret": ["Alt+Button1"] } });
|
||||
click();
|
||||
await vi.waitFor(() => expect(ipc.debugEvaluateIn).toHaveBeenCalledOnce());
|
||||
expect(view.state.selection.ranges.map((r) => r.head)).toEqual([16]);
|
||||
});
|
||||
|
||||
it("honours removal of the mouse binding", () => {
|
||||
session();
|
||||
useKeymap.setState({ overrides: { "debug.quickEvaluate": ["Meta+Alt+F8"] } });
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* contract between features (which register actions) and keymaps.
|
||||
*/
|
||||
export const intellijMacos: Record<string, string[]> = {
|
||||
"editor.addOrRemoveCaret": ["Alt+Shift+Button1"],
|
||||
// Navigate
|
||||
"navigate.searchEverywhere": ["Shift Shift"],
|
||||
"navigate.gotoClass": ["Meta+O"],
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* there stay unbound here.
|
||||
*/
|
||||
export const intellijWindows: Record<string, string[]> = {
|
||||
"editor.addOrRemoveCaret": ["Alt+Shift+Button1"],
|
||||
// Navigate
|
||||
"navigate.searchEverywhere": ["Shift Shift"],
|
||||
"navigate.gotoClass": ["Ctrl+N"],
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* no binding so switching presets doesn't leak IntelliJ shortcuts.
|
||||
*/
|
||||
export const vscodeMacos: Record<string, string[]> = {
|
||||
"editor.addOrRemoveCaret": ["Alt+Shift+Button1"],
|
||||
// Navigate
|
||||
"navigate.searchEverywhere": ["Shift Shift"],
|
||||
"navigate.gotoClass": [],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"Add or Remove Caret": "Add or Remove Caret",
|
||||
"Select Python Environment Folder": "Select Python Environment Folder",
|
||||
"Checking interpreter": "Checking interpreter",
|
||||
"Cannot run this Python interpreter. Select a working Python executable or virtualenv folder.": "Cannot run this Python interpreter. Select a working Python executable or virtualenv folder.",
|
||||
|
||||
@@ -193,6 +193,7 @@
|
||||
"Clone Caret Below": "在下方添加光标",
|
||||
"Join Lines": "合并行",
|
||||
"Toggle Case": "切换大小写",
|
||||
"Add or Remove Caret": "添加或移除插入点",
|
||||
"Add Carets to Ends of Selected Lines": "在所选行末尾添加光标",
|
||||
"Unselect Occurrence": "取消选择匹配项",
|
||||
"Paste from History…": "从历史记录粘贴…",
|
||||
|
||||
Reference in new issue
Заблокувати користувача