Skip to content

Commit 9ff8ea7

Browse files
authored
Basic keyboard features (#2169)
- Depends on #2168 ## Checklist - [x] I have added [tests](https://www.cursorless.org/docs/contributing/test-case-recorder/) - [-] 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
1 parent 6f01c31 commit 9ff8ea7

File tree

16 files changed

+407
-160
lines changed

16 files changed

+407
-160
lines changed

docs/user/experimental/keyboard/modal.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ Paste the following into your [VSCode `keybindings.json`](https://code.visualstu
2020
"command": "cursorless.keyboard.modal.modeOn",
2121
"when": "editorTextFocus"
2222
},
23+
{
24+
"key": "ctrl+c",
25+
"command": "cursorless.keyboard.targeted.targetSelection",
26+
"when": "cursorless.keyboard.modal.mode && editorTextFocus"
27+
},
2328
{
2429
"key": "escape",
2530
"command": "cursorless.keyboard.escape",
@@ -37,6 +42,8 @@ Any keybindings that use modifier keys should go in `keybindings.json` as well,
3742

3843
The above allows you to press `ctrl-c` to switch to Cursorless mode, `escape` to exit Cursorless mode, and `backspace` to issue the delete action while in Cursorless mode.
3944

45+
If you're already in Cursorless mode, pressing `ctrl-c` again will target the current selection, which is useful if you have moved the cursor using your mouse while in Cursorless mode, and want to target your new cursor position.
46+
4047
### `settings.json`
4148

4249
To bind keys that do not have modifiers (eg just pressing `a`), add entries like the following to your [VSCode `settings.json`](https://code.visualstudio.com/docs/getstarted/settings#_settingsjson) (or edit these settings in the VSCode settings gui by saying `"cursorless settings"`):

packages/cursorless-engine/src/generateSpokenForm/defaultSpokenForms/modifiers.ts

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,28 +2,7 @@ import { CompositeKeyMap } from "@cursorless/common";
22
import { SpeakableSurroundingPairName } from "../../spokenForms/SpokenFormType";
33
import { SpokenFormComponentMap } from "../getSpokenFormComponentMap";
44
import { CustomizableSpokenFormComponentForType } from "../SpokenFormComponent";
5-
6-
const surroundingPairsDelimiters: Record<
7-
SpeakableSurroundingPairName,
8-
[string, string] | null
9-
> = {
10-
curlyBrackets: ["{", "}"],
11-
angleBrackets: ["<", ">"],
12-
escapedDoubleQuotes: ['\\"', '\\"'],
13-
escapedSingleQuotes: ["\\'", "\\'"],
14-
escapedParentheses: ["\\(", "\\)"],
15-
escapedSquareBrackets: ["\\[", "\\]"],
16-
doubleQuotes: ['"', '"'],
17-
parentheses: ["(", ")"],
18-
backtickQuotes: ["`", "`"],
19-
squareBrackets: ["[", "]"],
20-
singleQuotes: ["'", "'"],
21-
whitespace: [" ", " "],
22-
23-
any: null,
24-
string: null,
25-
collectionBoundary: null,
26-
};
5+
import { surroundingPairsDelimiters } from "./surroundingPairsDelimiters";
276

287
const surroundingPairDelimiterToName = new CompositeKeyMap<
298
[string, string],
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { SpeakableSurroundingPairName } from "../../spokenForms/SpokenFormType";
2+
3+
export const surroundingPairsDelimiters: Record<
4+
SpeakableSurroundingPairName,
5+
[string, string] | null
6+
> = {
7+
curlyBrackets: ["{", "}"],
8+
angleBrackets: ["<", ">"],
9+
escapedDoubleQuotes: ['\\"', '\\"'],
10+
escapedSingleQuotes: ["\\'", "\\'"],
11+
escapedParentheses: ["\\(", "\\)"],
12+
escapedSquareBrackets: ["\\[", "\\]"],
13+
doubleQuotes: ['"', '"'],
14+
parentheses: ["(", ")"],
15+
backtickQuotes: ["`", "`"],
16+
squareBrackets: ["[", "]"],
17+
singleQuotes: ["'", "'"],
18+
whitespace: [" ", " "],
19+
20+
any: null,
21+
string: null,
22+
collectionBoundary: null,
23+
};

packages/cursorless-engine/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export * from "./testCaseRecorder/TestCaseRecorder";
55
export * from "./core/StoredTargets";
66
export * from "./typings/TreeSitter";
77
export * from "./cursorlessEngine";
8+
export * from "./generateSpokenForm/defaultSpokenForms/surroundingPairsDelimiters";
89
export * from "./api/CursorlessEngineApi";
910
export * from "./CommandRunner";
1011
export * from "./CommandHistory";

packages/cursorless-vscode-e2e/src/suite/keyboard/basic.vscode.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,70 @@ import path from "path";
77
import { getCursorlessRepoRoot } from "@cursorless/common";
88
import { readFile } from "node:fs/promises";
99

10+
interface TestCase {
11+
name: string;
12+
initialContent: string;
13+
/**
14+
* The sequence of keypresses that will be sent. The list of strings will simply
15+
* be concatenated before sending. We could just represent this as a single string
16+
* but it is more readable if each "token" is a separate string.
17+
*/
18+
keySequence: string[];
19+
finalContent: string;
20+
}
21+
22+
const testCases: TestCase[] = [
23+
{
24+
name: "and",
25+
initialContent: "x T y\n",
26+
// change plex and yank
27+
keySequence: ["dx", "fa", "dy", "c"],
28+
finalContent: " T \n",
29+
},
30+
{
31+
name: "every",
32+
initialContent: "a a\nb b\n",
33+
// change every token air
34+
keySequence: ["da", "x", "st", "c"],
35+
finalContent: " \nb b\n",
36+
},
37+
{
38+
name: "three",
39+
initialContent: "a b c d e\n",
40+
// change three tokens bat
41+
keySequence: ["db", "3", "st", "c"],
42+
finalContent: "a e\n",
43+
},
44+
{
45+
name: "three backwards",
46+
initialContent: "a b c d e\n",
47+
// change three tokens backwards drum
48+
keySequence: ["dd", "-3", "st", "c"],
49+
finalContent: "a e\n",
50+
},
51+
{
52+
name: "pair parens",
53+
initialContent: "a + (b + c) + d",
54+
// change parens bat
55+
keySequence: ["db", "wp", "c"],
56+
finalContent: "a + + d",
57+
},
58+
{
59+
name: "pair string",
60+
initialContent: 'a + "w" + b',
61+
// change parens bat
62+
keySequence: ["dw", "wj", "c"],
63+
finalContent: "a + + b",
64+
},
65+
{
66+
name: "wrap",
67+
initialContent: "a",
68+
// round wrap air
69+
keySequence: ["da", "aw", "wp"],
70+
finalContent: "(a)",
71+
},
72+
];
73+
1074
suite("Basic keyboard test", async function () {
1175
endToEndTestSetup(this);
1276

@@ -22,6 +86,9 @@ suite("Basic keyboard test", async function () {
2286
test("Basic keyboard test", () => basic());
2387
test("No automatic token expansion", () => noAutomaticTokenExpansion());
2488
test("Run vscode command", () => vscodeCommand());
89+
for (const t of testCases) {
90+
test("Sequence " + t.name, () => sequence(t));
91+
}
2592
test("Check that entering and leaving mode is no-op", () =>
2693
enterAndLeaveIsNoOp());
2794
});
@@ -82,6 +149,22 @@ async function noAutomaticTokenExpansion() {
82149
assert.isTrue(editor.selection.isEqual(new vscode.Selection(1, 0, 1, 0)));
83150
}
84151

152+
/**
153+
* sequence runs a test keyboard sequences.
154+
*/
155+
async function sequence(t: TestCase) {
156+
const { hatTokenMap } = (await getCursorlessApi()).testHelpers!;
157+
158+
const editor = await openNewEditor(t.initialContent, {
159+
languageId: "typescript",
160+
});
161+
await hatTokenMap.allocateHats();
162+
editor.selection = new vscode.Selection(1, 0, 1, 0);
163+
await vscode.commands.executeCommand("cursorless.keyboard.modal.modeOn");
164+
await typeText(t.keySequence.join(""));
165+
assert.equal(editor.document.getText(), t.finalContent);
166+
}
167+
85168
async function vscodeCommand() {
86169
const { hatTokenMap } = (await getCursorlessApi()).testHelpers!;
87170

packages/cursorless-vscode/src/keyboard/KeyboardCommandHandler.ts

Lines changed: 15 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import { ScopeType } from "@cursorless/common";
1+
import { Modifier, SurroundingPairName } from "@cursorless/common";
22
import * as vscode from "vscode";
33
import { HatColor, HatShape } from "../ide/vscode/hatStyles.types";
44
import { SimpleKeyboardActionType } from "./KeyboardActionType";
55
import KeyboardCommandsTargeted from "./KeyboardCommandsTargeted";
66
import { ModalVscodeCommandDescriptor } from "./TokenTypes";
7+
import { surroundingPairsDelimiters } from "@cursorless/cursorless-engine";
78

89
/**
910
* This class defines the keyboard commands available to our modal keyboard
@@ -27,15 +28,8 @@ import { ModalVscodeCommandDescriptor } from "./TokenTypes";
2728
export class KeyboardCommandHandler {
2829
constructor(private targeted: KeyboardCommandsTargeted) {}
2930

30-
targetDecoratedMarkReplace({ decoratedMark }: DecoratedMarkArg) {
31-
this.targeted.targetDecoratedMark(decoratedMark);
32-
}
33-
34-
targetDecoratedMarkExtend({ decoratedMark }: DecoratedMarkArg) {
35-
this.targeted.targetDecoratedMark({
36-
...decoratedMark,
37-
mode: "extend",
38-
});
31+
targetDecoratedMark({ decoratedMark, mode }: DecoratedMarkArg) {
32+
this.targeted.targetDecoratedMark({ ...decoratedMark, mode });
3933
}
4034

4135
async vscodeCommand({
@@ -78,22 +72,18 @@ export class KeyboardCommandHandler {
7872
this.targeted.performSimpleActionOnTarget(actionName);
7973
}
8074

81-
modifyTargetContainingScope(arg: { scopeType: ScopeType }) {
82-
this.targeted.modifyTargetContainingScope(arg);
75+
performWrapActionOnTarget({ delimiter }: { delimiter: SurroundingPairName }) {
76+
const [left, right] = surroundingPairsDelimiters[delimiter]!;
77+
this.targeted.performActionOnTarget((target) => ({
78+
name: "wrapWithPairedDelimiter",
79+
target,
80+
left,
81+
right,
82+
}));
8383
}
8484

85-
targetRelativeExclusiveScope({
86-
offset,
87-
length,
88-
scopeType,
89-
}: TargetRelativeExclusiveScopeArg) {
90-
this.targeted.targetModifier({
91-
type: "relativeScope",
92-
offset: offset?.number ?? 1,
93-
direction: offset?.direction ?? "forward",
94-
length: length ?? 1,
95-
scopeType,
96-
});
85+
modifyTarget({ modifier }: { modifier: Modifier }) {
86+
this.targeted.targetModifier(modifier);
9787
}
9888
}
9989

@@ -102,16 +92,7 @@ interface DecoratedMarkArg {
10292
color?: HatColor;
10393
shape?: HatShape;
10494
};
105-
}
106-
interface TargetRelativeExclusiveScopeArg {
107-
offset: Offset;
108-
length: number | null;
109-
scopeType: ScopeType;
110-
}
111-
112-
interface Offset {
113-
direction: "forward" | "backward" | null;
114-
number: number | null;
95+
mode: "replace" | "extend" | "append";
11596
}
11697

11798
function isString(input: any): input is string {

packages/cursorless-vscode/src/keyboard/KeyboardCommandsModal.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { pick, toPairs } from "lodash";
1+
import { pick, sortedUniq, toPairs } from "lodash";
22
import { Grammar, Parser } from "nearley";
33
import * as vscode from "vscode";
44
import { KeyboardCommandsModalLayer } from "./KeyboardCommandsModalLayer";
@@ -90,9 +90,9 @@ export default class KeyboardCommandsModal {
9090
private computeLayer() {
9191
const acceptableTokenTypeInfos = getAcceptableTokenTypes(this.parser);
9292
// FIXME: Here's where we'd update sidebar
93-
const acceptableTokenTypes = acceptableTokenTypeInfos
94-
.map(({ type }) => type)
95-
.sort();
93+
const acceptableTokenTypes = sortedUniq(
94+
acceptableTokenTypeInfos.map(({ type }) => type).sort(),
95+
);
9696
let layer = this.layerCache.get(acceptableTokenTypes);
9797
if (layer == null) {
9898
layer = new KeyboardCommandsModalLayer(

packages/cursorless-vscode/src/keyboard/TokenTypes.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { SimpleScopeTypeType } from "@cursorless/common";
1+
import { SimpleScopeTypeType, SurroundingPairName } from "@cursorless/common";
22
import { HatColor, HatShape } from "../ide/vscode/hatStyles.types";
33
import {
44
KeyboardActionType,
@@ -14,11 +14,12 @@ export interface SectionTypes {
1414
color: HatColor;
1515
misc: MiscValue;
1616
scope: SimpleScopeTypeType;
17+
pairedDelimiter: SurroundingPairName;
1718
shape: HatShape;
1819
vscodeCommand: ModalVscodeCommandDescriptor;
1920
modifier: ModifierType;
2021
}
21-
type ModifierType = "nextPrev";
22+
type ModifierType = "nextPrev" | "every";
2223
type MiscValue =
2324
| "combineColorAndShape"
2425
| "makeRange"
@@ -48,17 +49,21 @@ export interface TokenTypeValueMap {
4849
color: HatColor;
4950
shape: HatShape;
5051
vscodeCommand: ModalVscodeCommandDescriptor;
52+
pairedDelimiter: SurroundingPairName;
5153

5254
// action config section
5355
simpleAction: SimpleKeyboardActionType;
56+
wrap: "wrap";
5457

5558
// misc config section
5659
makeRange: "makeRange";
60+
makeList: "makeList";
5761
combineColorAndShape: "combineColorAndShape";
5862
direction: "forward" | "backward";
5963

6064
// modifier config section
6165
nextPrev: "nextPrev";
66+
every: "every";
6267

6368
digit: number;
6469
}

packages/cursorless-vscode/src/keyboard/getTokenTypeKeyMaps.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,16 +36,19 @@ export function getTokenTypeKeyMaps(
3636
color: config.getTokenKeyMap("color"),
3737
shape: config.getTokenKeyMap("shape"),
3838
vscodeCommand: config.getTokenKeyMap("vscodeCommand"),
39+
pairedDelimiter: config.getTokenKeyMap("pairedDelimiter"),
3940

4041
// action config section
4142
simpleAction: config.getTokenKeyMap(
4243
"simpleAction",
4344
"action",
4445
simpleKeyboardActionNames,
4546
),
47+
wrap: config.getTokenKeyMap("wrap", "action", ["wrap"]),
4648

4749
// misc config section
4850
makeRange: config.getTokenKeyMap("makeRange", "misc", ["makeRange"]),
51+
makeList: config.getTokenKeyMap("makeList", "misc", ["makeList"]),
4952
combineColorAndShape: config.getTokenKeyMap(
5053
"combineColorAndShape",
5154
"misc",
@@ -57,6 +60,7 @@ export function getTokenTypeKeyMaps(
5760
]),
5861

5962
// modifier config section
63+
every: config.getTokenKeyMap("every", "modifier", ["every"]),
6064
nextPrev: config.getTokenKeyMap("nextPrev", "modifier", ["nextPrev"]),
6165

6266
digit: Object.fromEntries(

packages/cursorless-vscode/src/keyboard/grammar/CommandRulePostProcessor.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import {
22
KeyboardCommand,
33
KeyboardCommandArgTypes,
44
} from "../KeyboardCommandTypeHelpers";
5-
import { Unused } from "./grammarHelpers";
65

76
/**
87
* Represents a post-processing function for a top-level rule of our grammar.
@@ -19,7 +18,5 @@ export interface CommandRulePostProcessor<
1918
metadata: {
2019
/** The command type */
2120
type: T;
22-
/** The names of the arguments to the command's argument payload */
23-
argNames: (keyof KeyboardCommandArgTypes[T] | Unused)[];
2421
};
2522
}

0 commit comments

Comments
 (0)