From 6394f5904a083c4f95615f1ca1e9b91cda96f9b7 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 8 Nov 2026 20:33:37 +0800 Subject: [PATCH] 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 --- src/editor/core/actions.ts | 8 ++ src/editor/core/cm/multiCaret.test.ts | 84 +++++++++++++++++++ src/editor/core/cm/multiCaret.ts | 22 +++-- src/editor/lsp/navigation.ts | 2 + .../debugger/__tests__/quickEvaluate.test.ts | 39 ++++++++- src/platform/keymaps/intellij-macos.ts | 1 + src/platform/keymaps/intellij-windows.ts | 1 + src/platform/keymaps/vscode-macos.ts | 1 + src/platform/locales/text/en.json | 1 + src/platform/locales/text/zh.json | 1 + 10 files changed, 149 insertions(+), 11 deletions(-) create mode 100644 src/editor/core/cm/multiCaret.test.ts diff --git a/src/editor/core/actions.ts b/src/editor/core/actions.ts index d8c35480c..81358f537 100644 --- a/src/editor/core/actions.ts +++ b/src/editor/core/actions.ts @@ -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), { diff --git a/src/editor/core/cm/multiCaret.test.ts b/src/editor/core/cm/multiCaret.test.ts new file mode 100644 index 000000000..7bb584533 --- /dev/null +++ b/src/editor/core/cm/multiCaret.test.ts @@ -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(); + }); +}); diff --git a/src/editor/core/cm/multiCaret.ts b/src/editor/core/cm/multiCaret.ts index 620f6ce95..a776c39e5 100644 --- a/src/editor/core/cm/multiCaret.ts +++ b/src/editor/core/cm/multiCaret.ts @@ -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"; }), diff --git a/src/editor/lsp/navigation.ts b/src/editor/lsp/navigation.ts index f35a59110..68183a169 100644 --- a/src/editor/lsp/navigation.ts +++ b/src/editor/lsp/navigation.ts @@ -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); diff --git a/src/features/debugger/__tests__/quickEvaluate.test.ts b/src/features/debugger/__tests__/quickEvaluate.test.ts index 4de18562d..c040b6d41 100644 --- a/src/features/debugger/__tests__/quickEvaluate.test.ts +++ b/src/features/debugger/__tests__/quickEvaluate.test.ts @@ -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"] } }); diff --git a/src/platform/keymaps/intellij-macos.ts b/src/platform/keymaps/intellij-macos.ts index baf1977ed..cbb3f9b06 100644 --- a/src/platform/keymaps/intellij-macos.ts +++ b/src/platform/keymaps/intellij-macos.ts @@ -3,6 +3,7 @@ * contract between features (which register actions) and keymaps. */ export const intellijMacos: Record = { + "editor.addOrRemoveCaret": ["Alt+Shift+Button1"], // Navigate "navigate.searchEverywhere": ["Shift Shift"], "navigate.gotoClass": ["Meta+O"], diff --git a/src/platform/keymaps/intellij-windows.ts b/src/platform/keymaps/intellij-windows.ts index a10656c73..a049427c4 100644 --- a/src/platform/keymaps/intellij-windows.ts +++ b/src/platform/keymaps/intellij-windows.ts @@ -4,6 +4,7 @@ * there stay unbound here. */ export const intellijWindows: Record = { + "editor.addOrRemoveCaret": ["Alt+Shift+Button1"], // Navigate "navigate.searchEverywhere": ["Shift Shift"], "navigate.gotoClass": ["Ctrl+N"], diff --git a/src/platform/keymaps/vscode-macos.ts b/src/platform/keymaps/vscode-macos.ts index acb8fb42b..5b6dd9b35 100644 --- a/src/platform/keymaps/vscode-macos.ts +++ b/src/platform/keymaps/vscode-macos.ts @@ -4,6 +4,7 @@ * no binding so switching presets doesn't leak IntelliJ shortcuts. */ export const vscodeMacos: Record = { + "editor.addOrRemoveCaret": ["Alt+Shift+Button1"], // Navigate "navigate.searchEverywhere": ["Shift Shift"], "navigate.gotoClass": [], diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index e56ac68b0..00bbd701f 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -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.", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 967fca9b6..29e4477bd 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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…": "从历史记录粘贴…",