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
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-08 20:33:37 +08:00
1 джерело 7f62011a08
коміт 6394f5904a
10 files changed
+149 -11

No files matched your search

+8
Переглянути файл
@@ -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), {
+84
Переглянути файл
@@ -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();
});
});
+13 -9
Переглянути файл
@@ -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";
}),
+2
Переглянути файл
@@ -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);
+37 -2
Переглянути файл
@@ -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"] } });
+1
Переглянути файл
@@ -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"],
+1
Переглянути файл
@@ -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"],
+1
Переглянути файл
@@ -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
Переглянути файл
@@ -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.",
+1
Переглянути файл
@@ -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…": "从历史记录粘贴…",