From 08cd7a1ff21ecad691237801d625eb17d55c8e3f Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Fri, 24 Nov 2023 12:46:02 +0000 Subject: [PATCH] Support mapping from key to VSCode command in Cursorless keyboard mode (#2026) - Fixes #1963 ## Checklist - [x] I have added [tests](https://www.cursorless.org/docs/contributing/test-case-recorder/) - [x] I have updated the [docs](https://github.com/cursorless-dev/cursorless/tree/main/docs) and [cheatsheet](https://github.com/cursorless-dev/cursorless/tree/main/cursorless-talon/src/cheatsheet) - [-] I have not broken the cheatsheet --- .../2023-11-modalKeyboardVscodeCommands.md | 6 ++ docs/user/experimental/keyboard/modal.md | 18 ++++++ .../src/suite/keyboard/basic.vscode.test.ts | 32 ++++++++++ packages/cursorless-vscode/package.json | 35 +++++++++++ .../src/keyboard/KeyboardCommandsModal.ts | 56 +++++++++++++++--- .../src/keyboard/KeyboardCommandsTargeted.ts | 59 +++++++++++++++++++ .../src/keyboard/defaultKeymaps.ts | 27 +++++++++ 7 files changed, 225 insertions(+), 8 deletions(-) create mode 100644 changelog/2023-11-modalKeyboardVscodeCommands.md diff --git a/changelog/2023-11-modalKeyboardVscodeCommands.md b/changelog/2023-11-modalKeyboardVscodeCommands.md new file mode 100644 index 0000000000..befdee416b --- /dev/null +++ b/changelog/2023-11-modalKeyboardVscodeCommands.md @@ -0,0 +1,6 @@ +--- +tags: [enhancement, keyboard] +pullRequest: 2026 +--- + +- Add support for running VSCode commands from the experimental modal keyboard interface. See the [keyboard modal docs](https://www.cursorless.org/docs/user/experimental/keyboard/modal/) for more info. diff --git a/docs/user/experimental/keyboard/modal.md b/docs/user/experimental/keyboard/modal.md index d46d2c9d36..1cc58dbc94 100644 --- a/docs/user/experimental/keyboard/modal.md +++ b/docs/user/experimental/keyboard/modal.md @@ -97,6 +97,24 @@ To bind keys that do not have modifiers (eg just pressing `a`), add entries like "z": "bolt", "w": "crosshairs" }, + "cursorless.experimental.keyboard.modal.keybindings.vscodeCommands": { + // For simple commands, just use the command name + // "aa": "workbench.action.editor.changeLanguageMode", + + // For commands with args, use the following format + // "am": { + // "commandId": "some.command.id", + // "args": ["foo", 0] + // } + + // If you'd like to run the command on the active target, use the following format + "am": { + "commandId": "editor.action.joinLines", + "executeAtTarget": true, + // "keepChangedSelection": true, + // "exitCursorlessMode": true, + } + } ``` Any supported scopes, actions, or colors can be added to these sections, using the same identifiers that appear in the second column of your customisation csvs. Feel free to add / tweak / remove the keyboard shortcuts above as you see fit. diff --git a/packages/cursorless-vscode-e2e/src/suite/keyboard/basic.vscode.test.ts b/packages/cursorless-vscode-e2e/src/suite/keyboard/basic.vscode.test.ts index 4409c2b618..0c5726daf3 100644 --- a/packages/cursorless-vscode-e2e/src/suite/keyboard/basic.vscode.test.ts +++ b/packages/cursorless-vscode-e2e/src/suite/keyboard/basic.vscode.test.ts @@ -12,6 +12,7 @@ suite("Basic keyboard test", async function () { test("Don't take keyboard control on startup", () => checkKeyboardStartup()); test("Basic keyboard test", () => basic()); + test("Run vscode command", () => vscodeCommand()); test("Check that entering and leaving mode is no-op", () => enterAndLeaveIsNoOp()); }); @@ -56,6 +57,37 @@ async function basic() { assert.equal(editor.document.getText().trim(), "a"); } +async function vscodeCommand() { + const { hatTokenMap } = (await getCursorlessApi()).testHelpers!; + + const editor = await openNewEditor("aaa;\nbbb;\nccc;\n", { + languageId: "typescript", + }); + await hatTokenMap.allocateHats(); + + editor.selection = new vscode.Selection(0, 0, 0, 0); + + await vscode.commands.executeCommand("cursorless.keyboard.modal.modeOn"); + + // Target default b + await typeText("db"); + + // Comment line containing *selection* + await typeText("c"); + assert.equal(editor.document.getText(), "// aaa;\nbbb;\nccc;\n"); + + // Comment line containing *target* + await typeText("mc"); + assert.equal(editor.document.getText(), "// aaa;\n// bbb;\nccc;\n"); + + // Comment line containing *target*, keeping changed selection and exiting + // cursorless mode + await typeText("dcmma"); + assert.equal(editor.document.getText(), "// aaa;\n// bbb;\n// a;\n"); + + await vscode.commands.executeCommand("cursorless.keyboard.modal.modeOff"); +} + async function enterAndLeaveIsNoOp() { const editor = await openNewEditor("hello"); diff --git a/packages/cursorless-vscode/package.json b/packages/cursorless-vscode/package.json index 06515dcd12..431ac45e97 100644 --- a/packages/cursorless-vscode/package.json +++ b/packages/cursorless-vscode/package.json @@ -909,6 +909,41 @@ ] } }, + "cursorless.experimental.keyboard.modal.keybindings.vscodeCommands": { + "description": "Define modal keybindings for running vscode commands", + "type": "object", + "additionalProperties": { + "type": [ + "string", + "object" + ], + "properties": { + "commandId": { + "type": "string", + "description": "The vscode command to run" + }, + "args": { + "type": "array", + "description": "The arguments to pass to the command" + }, + "executeAtTarget": { + "type": "boolean", + "description": "If `true`, indicates that the command should be executed at the target by moving the cursor there first, running the command, and then moving the cursor back to the original position" + }, + "keepChangedSelection": { + "type": "boolean", + "description": "If `true`, the selection will be retained after the command is run, rather than being restored to its original position. This setting only applies when `executeAtTarget` is `true`." + }, + "exitCursorlessMode": { + "type": "boolean", + "description": "If `true`, indicates that the command should exit cursorless mode after it is run." + } + }, + "required": [ + "commandId" + ] + } + }, "cursorless.experimental.keyboard.modal.keybindings.colors": { "description": "Define modal keybindings for colors", "type": "object", diff --git a/packages/cursorless-vscode/src/keyboard/KeyboardCommandsModal.ts b/packages/cursorless-vscode/src/keyboard/KeyboardCommandsModal.ts index e08f12f716..e5c03df801 100644 --- a/packages/cursorless-vscode/src/keyboard/KeyboardCommandsModal.ts +++ b/packages/cursorless-vscode/src/keyboard/KeyboardCommandsModal.ts @@ -1,16 +1,24 @@ +import { isTesting } from "@cursorless/common"; import { keys, merge, toPairs } from "lodash"; import * as vscode from "vscode"; +import KeyboardCommandsTargeted from "./KeyboardCommandsTargeted"; +import KeyboardHandler from "./KeyboardHandler"; import { DEFAULT_ACTION_KEYMAP, DEFAULT_COLOR_KEYMAP, - Keymap, DEFAULT_SCOPE_KEYMAP, DEFAULT_SHAPE_KEYMAP, + DEFAULT_VSCODE_COMMAND_KEYMAP, + Keymap, + ModalVscodeCommandDescriptor, } from "./defaultKeymaps"; -import KeyboardCommandsTargeted from "./KeyboardCommandsTargeted"; -import KeyboardHandler from "./KeyboardHandler"; -type SectionName = "actions" | "scopes" | "colors" | "shapes"; +type SectionName = + | "actions" + | "scopes" + | "colors" + | "shapes" + | "vscodeCommands"; interface KeyHandler { sectionName: SectionName; @@ -61,6 +69,30 @@ export default class KeyboardCommandsModal { ); } + private async handleVscodeCommand(commandInfo: ModalVscodeCommandDescriptor) { + const { + commandId, + args, + executeAtTarget, + keepChangedSelection, + exitCursorlessMode, + } = + typeof commandInfo === "string" || commandInfo instanceof String + ? ({ commandId: commandInfo } as Exclude< + ModalVscodeCommandDescriptor, + string + >) + : commandInfo; + if (executeAtTarget) { + return await this.targeted.performVscodeCommandOnTarget(commandId, { + args, + keepChangedSelection, + exitCursorlessMode, + }); + } + return await vscode.commands.executeCommand(commandId, ...(args ?? [])); + } + private constructMergedKeymap() { this.mergedKeymap = {}; @@ -82,6 +114,11 @@ export default class KeyboardCommandsModal { shape: value, }), ); + this.handleSection( + "vscodeCommands", + DEFAULT_VSCODE_COMMAND_KEYMAP, + (value) => this.handleVscodeCommand(value), + ); } /** @@ -96,10 +133,13 @@ export default class KeyboardCommandsModal { defaultKeyMap: Keymap, handleValue: (value: T) => Promise, ) { - const userOverrides: Keymap = - vscode.workspace - .getConfiguration("cursorless.experimental.keyboard.modal.keybindings") - .get>(sectionName) ?? {}; + const userOverrides: Keymap = isTesting() + ? {} + : vscode.workspace + .getConfiguration( + "cursorless.experimental.keyboard.modal.keybindings", + ) + .get>(sectionName) ?? {}; const keyMap = merge({}, defaultKeyMap, userOverrides); for (const [key, value] of toPairs(keyMap)) { diff --git a/packages/cursorless-vscode/src/keyboard/KeyboardCommandsTargeted.ts b/packages/cursorless-vscode/src/keyboard/KeyboardCommandsTargeted.ts index 80d44036c2..b38b589986 100644 --- a/packages/cursorless-vscode/src/keyboard/KeyboardCommandsTargeted.ts +++ b/packages/cursorless-vscode/src/keyboard/KeyboardCommandsTargeted.ts @@ -42,6 +42,8 @@ export default class KeyboardCommandsTargeted { constructor(private keyboardHandler: KeyboardHandler) { this.targetDecoratedMark = this.targetDecoratedMark.bind(this); this.performActionOnTarget = this.performActionOnTarget.bind(this); + this.performVscodeCommandOnTarget = + this.performVscodeCommandOnTarget.bind(this); this.targetScopeType = this.targetScopeType.bind(this); this.targetSelection = this.targetSelection.bind(this); this.clearTarget = this.clearTarget.bind(this); @@ -244,6 +246,52 @@ export default class KeyboardCommandsTargeted { return returnValue; }; + /** + * Performs the given VSCode command on the current target. If + * {@link keepChangedSelection} is true, then the selection will not be + * restored after the command is run. + * + * @param commandId The command to run + * @param options Additional options + * @returns A promise that resolves to the result of the VSCode command + */ + performVscodeCommandOnTarget = async ( + commandId: string, + { + args, + keepChangedSelection, + exitCursorlessMode, + }: VscodeCommandOnTargetOptions = {}, + ) => { + const target: PartialPrimitiveTargetDescriptor = { + type: "primitive", + mark: { + type: "that", + }, + }; + + const returnValue = await executeCursorlessCommand({ + name: "executeCommand", + target, + commandId, + options: { + restoreSelection: !keepChangedSelection, + showDecorations: true, + commandArgs: args, + }, + }); + + await this.highlightTarget(); + + if (exitCursorlessMode) { + // For some Cursorless actions, it is more convenient if we automatically + // exit modal mode + await this.modal.modeOff(); + } + + return returnValue; + }; + /** * Sets the current target to the current selection * @returns A promise that resolves to the result of the cursorless command @@ -276,6 +324,17 @@ export default class KeyboardCommandsTargeted { }); } +interface VscodeCommandOnTargetOptions { + /** The arguments to pass to the command */ + args?: unknown[]; + + /** If `true`, the selection will not be restored after the command is run */ + keepChangedSelection?: boolean; + + /** If `true`, exit Cursorless mode after running command */ + exitCursorlessMode?: boolean; +} + function executeCursorlessCommand(action: ActionDescriptor) { return runCursorlessCommand({ action, diff --git a/packages/cursorless-vscode/src/keyboard/defaultKeymaps.ts b/packages/cursorless-vscode/src/keyboard/defaultKeymaps.ts index a35daad0e7..72b04fec10 100644 --- a/packages/cursorless-vscode/src/keyboard/defaultKeymaps.ts +++ b/packages/cursorless-vscode/src/keyboard/defaultKeymaps.ts @@ -5,6 +5,16 @@ import { isTesting } from "@cursorless/common"; export type Keymap = Record; +export type ModalVscodeCommandDescriptor = + | string + | { + commandId: string; + args?: unknown[]; + executeAtTarget?: boolean; + keepChangedSelection?: boolean; + exitCursorlessMode?: boolean; + }; + // FIXME: Switch to a better mocking setup. We don't use our built in // configuration set up because that is probably going to live server side, and // the keyboard setup will probably live client side @@ -21,4 +31,21 @@ export const DEFAULT_COLOR_KEYMAP: Keymap = isTesting() ? { d: "default" } : {}; +export const DEFAULT_VSCODE_COMMAND_KEYMAP: Keymap = + isTesting() + ? { + c: "editor.action.addCommentLine", + mc: { + commandId: "editor.action.addCommentLine", + executeAtTarget: true, + }, + mm: { + commandId: "editor.action.addCommentLine", + executeAtTarget: true, + keepChangedSelection: true, + exitCursorlessMode: true, + }, + } + : {}; + export const DEFAULT_SHAPE_KEYMAP: Keymap = isTesting() ? {} : {};