Skip to content

Commit

Permalink
Support mapping from key to VSCode command in Cursorless keyboard mode (
Browse files Browse the repository at this point in the history
  • Loading branch information
pokey authored Nov 24, 2023
1 parent df8a74d commit 08cd7a1
Show file tree
Hide file tree
Showing 7 changed files with 225 additions and 8 deletions.
6 changes: 6 additions & 0 deletions changelog/2023-11-modalKeyboardVscodeCommands.md
Original file line number Diff line number Diff line change
@@ -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.
18 changes: 18 additions & 0 deletions docs/user/experimental/keyboard/modal.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
});
Expand Down Expand Up @@ -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");

Expand Down
35 changes: 35 additions & 0 deletions packages/cursorless-vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
56 changes: 48 additions & 8 deletions packages/cursorless-vscode/src/keyboard/KeyboardCommandsModal.ts
Original file line number Diff line number Diff line change
@@ -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<T> {
sectionName: SectionName;
Expand Down Expand Up @@ -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 = {};

Expand All @@ -82,6 +114,11 @@ export default class KeyboardCommandsModal {
shape: value,
}),
);
this.handleSection(
"vscodeCommands",
DEFAULT_VSCODE_COMMAND_KEYMAP,
(value) => this.handleVscodeCommand(value),
);
}

/**
Expand All @@ -96,10 +133,13 @@ export default class KeyboardCommandsModal {
defaultKeyMap: Keymap<T>,
handleValue: (value: T) => Promise<unknown>,
) {
const userOverrides: Keymap<T> =
vscode.workspace
.getConfiguration("cursorless.experimental.keyboard.modal.keybindings")
.get<Keymap<T>>(sectionName) ?? {};
const userOverrides: Keymap<T> = isTesting()
? {}
: vscode.workspace
.getConfiguration(
"cursorless.experimental.keyboard.modal.keybindings",
)
.get<Keymap<T>>(sectionName) ?? {};
const keyMap = merge({}, defaultKeyMap, userOverrides);

for (const [key, value] of toPairs(keyMap)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
27 changes: 27 additions & 0 deletions packages/cursorless-vscode/src/keyboard/defaultKeymaps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ import { isTesting } from "@cursorless/common";

export type Keymap<T> = Record<string, T>;

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
Expand All @@ -21,4 +31,21 @@ export const DEFAULT_COLOR_KEYMAP: Keymap<HatColor> = isTesting()
? { d: "default" }
: {};

export const DEFAULT_VSCODE_COMMAND_KEYMAP: Keymap<ModalVscodeCommandDescriptor> =
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<HatShape> = isTesting() ? {} : {};

0 comments on commit 08cd7a1

Please sign in to comment.