diff --git a/packages/cursorless-engine/src/generateSpokenForm/defaultSpokenForms/modifiers.ts b/packages/cursorless-engine/src/generateSpokenForm/defaultSpokenForms/modifiers.ts index 1652c9e02b4..d2ea3f9f316 100644 --- a/packages/cursorless-engine/src/generateSpokenForm/defaultSpokenForms/modifiers.ts +++ b/packages/cursorless-engine/src/generateSpokenForm/defaultSpokenForms/modifiers.ts @@ -2,28 +2,7 @@ import { CompositeKeyMap } from "@cursorless/common"; import { SpeakableSurroundingPairName } from "../../spokenForms/SpokenFormType"; import { SpokenFormComponentMap } from "../getSpokenFormComponentMap"; import { CustomizableSpokenFormComponentForType } from "../SpokenFormComponent"; - -const surroundingPairsDelimiters: Record< - SpeakableSurroundingPairName, - [string, string] | null -> = { - curlyBrackets: ["{", "}"], - angleBrackets: ["<", ">"], - escapedDoubleQuotes: ['\\"', '\\"'], - escapedSingleQuotes: ["\\'", "\\'"], - escapedParentheses: ["\\(", "\\)"], - escapedSquareBrackets: ["\\[", "\\]"], - doubleQuotes: ['"', '"'], - parentheses: ["(", ")"], - backtickQuotes: ["`", "`"], - squareBrackets: ["[", "]"], - singleQuotes: ["'", "'"], - whitespace: [" ", " "], - - any: null, - string: null, - collectionBoundary: null, -}; +import { surroundingPairsDelimiters } from "./surroundingPairsDelimiters"; const surroundingPairDelimiterToName = new CompositeKeyMap< [string, string], diff --git a/packages/cursorless-engine/src/generateSpokenForm/defaultSpokenForms/surroundingPairsDelimiters.ts b/packages/cursorless-engine/src/generateSpokenForm/defaultSpokenForms/surroundingPairsDelimiters.ts new file mode 100644 index 00000000000..ff3a19bbe11 --- /dev/null +++ b/packages/cursorless-engine/src/generateSpokenForm/defaultSpokenForms/surroundingPairsDelimiters.ts @@ -0,0 +1,23 @@ +import { SpeakableSurroundingPairName } from "../../spokenForms/SpokenFormType"; + +export const surroundingPairsDelimiters: Record< + SpeakableSurroundingPairName, + [string, string] | null +> = { + curlyBrackets: ["{", "}"], + angleBrackets: ["<", ">"], + escapedDoubleQuotes: ['\\"', '\\"'], + escapedSingleQuotes: ["\\'", "\\'"], + escapedParentheses: ["\\(", "\\)"], + escapedSquareBrackets: ["\\[", "\\]"], + doubleQuotes: ['"', '"'], + parentheses: ["(", ")"], + backtickQuotes: ["`", "`"], + squareBrackets: ["[", "]"], + singleQuotes: ["'", "'"], + whitespace: [" ", " "], + + any: null, + string: null, + collectionBoundary: null, +}; diff --git a/packages/cursorless-engine/src/index.ts b/packages/cursorless-engine/src/index.ts index 37648cf84cb..c68a23dc48d 100644 --- a/packages/cursorless-engine/src/index.ts +++ b/packages/cursorless-engine/src/index.ts @@ -5,6 +5,7 @@ export * from "./testCaseRecorder/TestCaseRecorder"; export * from "./core/StoredTargets"; export * from "./typings/TreeSitter"; export * from "./cursorlessEngine"; +export * from "./generateSpokenForm/defaultSpokenForms/surroundingPairsDelimiters"; export * from "./api/CursorlessEngineApi"; export * from "./CommandRunner"; export * from "./CommandHistory"; 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 c7378dab67c..3699e5ec934 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 @@ -7,6 +7,68 @@ import path from "path"; import { getCursorlessRepoRoot } from "@cursorless/common"; import { readFile } from "node:fs/promises"; +interface TestCase { + name: string; + initialContent: string; + // keySequence is the sequence of keypresses that will be sent. + // It can include phantom ";"s for readability. + // They will be not be sent. + keySequence: string; + finalContent: string; +} + +const testCases: TestCase[] = [ + { + name: "and", + initialContent: "x T y\n", + // change plex and yank + keySequence: "dx;fa;dy;c", + finalContent: "T", + }, + { + name: "every", + initialContent: "a a\nb b\n", + // change every token air + keySequence: "da;x;st;c", + finalContent: "b b", + }, + { + name: "three", + initialContent: "a b c d e\n", + // change three tokens bat + keySequence: "db;3;st;c", + finalContent: "a e", + }, + { + name: "three backwards", + initialContent: "a b c d e\n", + // change three tokens backwards drum + keySequence: "dd;-3;st;c", + finalContent: "a e", + }, + { + name: "pair parens", + initialContent: "a + (b + c) + d", + // change parens bat + keySequence: "db;wp;c", + finalContent: "a + + d", + }, + { + name: "pair string", + initialContent: 'a + "w" + b', + // change parens bat + keySequence: "dw;wj;c", + finalContent: "a + + b", + }, + { + name: "wrap", + initialContent: "a", + // round wrap air + keySequence: "da;aw;wp", + finalContent: "(a)", + }, +]; + suite("Basic keyboard test", async function () { endToEndTestSetup(this); @@ -22,6 +84,9 @@ suite("Basic keyboard test", async function () { test("Basic keyboard test", () => basic()); test("No automatic token expansion", () => noAutomaticTokenExpansion()); test("Run vscode command", () => vscodeCommand()); + for (const t of testCases) { + test("Sequence " + t.name, () => sequence(t)); + } test("Check that entering and leaving mode is no-op", () => enterAndLeaveIsNoOp()); }); @@ -82,6 +147,22 @@ async function noAutomaticTokenExpansion() { assert.isTrue(editor.selection.isEqual(new vscode.Selection(1, 0, 1, 0))); } +/** + * sequence runs a test keyboard sequences. + */ +async function sequence(t: TestCase) { + const { hatTokenMap } = (await getCursorlessApi()).testHelpers!; + + const editor = await openNewEditor(t.initialContent, { + languageId: "typescript", + }); + await hatTokenMap.allocateHats(); + editor.selection = new vscode.Selection(1, 0, 1, 0); + await vscode.commands.executeCommand("cursorless.keyboard.modal.modeOn"); + await typeText(t.keySequence.replaceAll(";", "")); + assert.equal(editor.document.getText().trim(), t.finalContent); +} + async function vscodeCommand() { const { hatTokenMap } = (await getCursorlessApi()).testHelpers!; diff --git a/packages/cursorless-vscode/src/keyboard/KeyboardCommandHandler.ts b/packages/cursorless-vscode/src/keyboard/KeyboardCommandHandler.ts index 423a19fb8f9..f8366b8c6b7 100644 --- a/packages/cursorless-vscode/src/keyboard/KeyboardCommandHandler.ts +++ b/packages/cursorless-vscode/src/keyboard/KeyboardCommandHandler.ts @@ -1,9 +1,10 @@ -import { ScopeType } from "@cursorless/common"; +import { ScopeType, SurroundingPairName } from "@cursorless/common"; import * as vscode from "vscode"; import { HatColor, HatShape } from "../ide/vscode/hatStyles.types"; import { SimpleKeyboardActionType } from "./KeyboardActionType"; import KeyboardCommandsTargeted from "./KeyboardCommandsTargeted"; import { ModalVscodeCommandDescriptor } from "./TokenTypes"; +import { surroundingPairsDelimiters } from "@cursorless/cursorless-engine"; /** * This class defines the keyboard commands available to our modal keyboard @@ -38,6 +39,13 @@ export class KeyboardCommandHandler { }); } + targetDecoratedMarkAppend({ decoratedMark }: DecoratedMarkArg) { + this.targeted.targetDecoratedMark({ + ...decoratedMark, + mode: "append", + }); + } + async vscodeCommand({ command: commandInfo, }: { @@ -81,6 +89,19 @@ export class KeyboardCommandHandler { modifyTargetContainingScope(arg: { scopeType: ScopeType }) { this.targeted.modifyTargetContainingScope(arg); } + performWrapActionOnTarget({ delimiter }: { delimiter: SurroundingPairName }) { + const [left, right] = surroundingPairsDelimiters[delimiter]!; + this.targeted.performActionOnTarget((target) => ({ + name: "wrapWithPairedDelimiter", + target, + left, + right, + })); + } + + targetEveryScopeType(arg: { scopeType: ScopeType }) { + this.targeted.modifyTargetContainingScope({ ...arg, type: "everyScope" }); + } targetRelativeExclusiveScope({ offset, @@ -95,6 +116,19 @@ export class KeyboardCommandHandler { scopeType, }); } + + targetRelativeInclusiveScope({ + offset, + scopeType, + }: TargetRelativeInclusiveScopeArg) { + this.targeted.targetModifier({ + type: "relativeScope", + offset: 0, + direction: offset?.direction ?? "forward", + length: offset?.number ?? 1, + scopeType, + }); + } } interface DecoratedMarkArg { @@ -108,6 +142,10 @@ interface TargetRelativeExclusiveScopeArg { length: number | null; scopeType: ScopeType; } +interface TargetRelativeInclusiveScopeArg { + offset: Offset; + scopeType: ScopeType; +} interface Offset { direction: "forward" | "backward" | null; diff --git a/packages/cursorless-vscode/src/keyboard/TokenTypes.ts b/packages/cursorless-vscode/src/keyboard/TokenTypes.ts index f40e59c3503..ee1db690b75 100644 --- a/packages/cursorless-vscode/src/keyboard/TokenTypes.ts +++ b/packages/cursorless-vscode/src/keyboard/TokenTypes.ts @@ -1,4 +1,4 @@ -import { SimpleScopeTypeType } from "@cursorless/common"; +import { SimpleScopeTypeType, SurroundingPairName } from "@cursorless/common"; import { HatColor, HatShape } from "../ide/vscode/hatStyles.types"; import { KeyboardActionType, @@ -14,17 +14,19 @@ export interface SectionTypes { color: HatColor; misc: MiscValue; scope: SimpleScopeTypeType; + pairedDelimiter: SurroundingPairName; shape: HatShape; vscodeCommand: ModalVscodeCommandDescriptor; modifier: ModifierType; } -type ModifierType = "nextPrev"; +type ModifierType = "nextPrev" | "every"; type MiscValue = | "combineColorAndShape" | "makeRange" | "makeList" | "forward" - | "backward"; + | "backward" + | "wrap"; // TODO: move wrap somewhere out of misc /** * Maps from token type used in parser to the type of values that the token type @@ -48,17 +50,21 @@ export interface TokenTypeValueMap { color: HatColor; shape: HatShape; vscodeCommand: ModalVscodeCommandDescriptor; + pairedDelimiter: SurroundingPairName; // action config section simpleAction: SimpleKeyboardActionType; + wrap: "wrap"; // misc config section makeRange: "makeRange"; + makeList: "makeList"; combineColorAndShape: "combineColorAndShape"; direction: "forward" | "backward"; // modifier config section nextPrev: "nextPrev"; + every: "every"; digit: number; } diff --git a/packages/cursorless-vscode/src/keyboard/getTokenTypeKeyMaps.ts b/packages/cursorless-vscode/src/keyboard/getTokenTypeKeyMaps.ts index 383a11b0bf4..65dcbd86cee 100644 --- a/packages/cursorless-vscode/src/keyboard/getTokenTypeKeyMaps.ts +++ b/packages/cursorless-vscode/src/keyboard/getTokenTypeKeyMaps.ts @@ -36,6 +36,7 @@ export function getTokenTypeKeyMaps( color: config.getTokenKeyMap("color"), shape: config.getTokenKeyMap("shape"), vscodeCommand: config.getTokenKeyMap("vscodeCommand"), + pairedDelimiter: config.getTokenKeyMap("pairedDelimiter"), // action config section simpleAction: config.getTokenKeyMap( @@ -43,9 +44,11 @@ export function getTokenTypeKeyMaps( "action", simpleKeyboardActionNames, ), + wrap: config.getTokenKeyMap("wrap", "misc", ["wrap"]), // misc config section makeRange: config.getTokenKeyMap("makeRange", "misc", ["makeRange"]), + makeList: config.getTokenKeyMap("makeList", "misc", ["makeList"]), combineColorAndShape: config.getTokenKeyMap( "combineColorAndShape", "misc", @@ -57,6 +60,7 @@ export function getTokenTypeKeyMaps( ]), // modifier config section + every: config.getTokenKeyMap("every", "modifier", ["every"]), nextPrev: config.getTokenKeyMap("nextPrev", "modifier", ["nextPrev"]), digit: Object.fromEntries( diff --git a/packages/cursorless-vscode/src/keyboard/grammar/generated/grammar.ts b/packages/cursorless-vscode/src/keyboard/grammar/generated/grammar.ts index fd2b867d6ba..464ff213e19 100644 --- a/packages/cursorless-vscode/src/keyboard/grammar/generated/grammar.ts +++ b/packages/cursorless-vscode/src/keyboard/grammar/generated/grammar.ts @@ -4,8 +4,12 @@ // @ts-ignore function id(d: any[]): any { return d[0]; } declare var makeRange: any; +declare var makeList: any; +declare var every: any; declare var nextPrev: any; declare var simpleAction: any; +declare var wrap: any; +declare var pairedDelimiter: any; declare var vscodeCommand: any; declare var simpleScopeTypeType: any; declare var color: any; @@ -51,7 +55,11 @@ const grammar: Grammar = { {"name": "main", "symbols": [(keyboardLexer.has("makeRange") ? {type: "makeRange"} : makeRange), "decoratedMark"], "postprocess": command("targetDecoratedMarkExtend", [_, "decoratedMark"]) }, + {"name": "main", "symbols": [(keyboardLexer.has("makeList") ? {type: "makeList"} : makeList), "decoratedMark"], "postprocess": + command("targetDecoratedMarkAppend", [_, "decoratedMark"]) + }, {"name": "main", "symbols": ["scopeType"], "postprocess": command("modifyTargetContainingScope", ["scopeType"])}, + {"name": "main", "symbols": [(keyboardLexer.has("every") ? {type: "every"} : every), "scopeType"], "postprocess": command("targetEveryScopeType", [_, "scopeType"])}, {"name": "main$ebnf$1", "symbols": ["offset"], "postprocess": id}, {"name": "main$ebnf$1", "symbols": [], "postprocess": () => null}, {"name": "main$ebnf$2", "symbols": ["number"], "postprocess": id}, @@ -62,9 +70,18 @@ const grammar: Grammar = { ["offset", _, "length", "scopeType"], ) }, + {"name": "main", "symbols": ["offset", "scopeType"], "postprocess": + command("targetRelativeInclusiveScope", ["offset", "scopeType"]) + }, {"name": "main", "symbols": [(keyboardLexer.has("simpleAction") ? {type: "simpleAction"} : simpleAction)], "postprocess": command("performSimpleActionOnTarget", ["actionName"])}, + {"name": "main", "symbols": [(keyboardLexer.has("wrap") ? {type: "wrap"} : wrap), (keyboardLexer.has("pairedDelimiter") ? {type: "pairedDelimiter"} : pairedDelimiter)], "postprocess": + command("performWrapActionOnTarget", [_, "delimiter"]) + }, {"name": "main", "symbols": [(keyboardLexer.has("vscodeCommand") ? {type: "vscodeCommand"} : vscodeCommand)], "postprocess": command("vscodeCommand", ["command"])}, {"name": "scopeType", "symbols": [(keyboardLexer.has("simpleScopeTypeType") ? {type: "simpleScopeTypeType"} : simpleScopeTypeType)], "postprocess": capture("type")}, + {"name": "scopeType", "symbols": [(keyboardLexer.has("pairedDelimiter") ? {type: "pairedDelimiter"} : pairedDelimiter)], "postprocess": + ([delimiter]) => ({ type: "surroundingPair", delimiter }) + }, {"name": "decoratedMark", "symbols": [(keyboardLexer.has("color") ? {type: "color"} : color)], "postprocess": capture("color")}, {"name": "decoratedMark", "symbols": [(keyboardLexer.has("shape") ? {type: "shape"} : shape)], "postprocess": capture("shape")}, {"name": "decoratedMark", "symbols": [(keyboardLexer.has("combineColorAndShape") ? {type: "combineColorAndShape"} : combineColorAndShape), (keyboardLexer.has("color") ? {type: "color"} : color), (keyboardLexer.has("shape") ? {type: "shape"} : shape)], "postprocess": capture(_, "color", "shape")}, diff --git a/packages/cursorless-vscode/src/keyboard/grammar/grammar.ne b/packages/cursorless-vscode/src/keyboard/grammar/grammar.ne index 657895d9900..89c87ba015a 100644 --- a/packages/cursorless-vscode/src/keyboard/grammar/grammar.ne +++ b/packages/cursorless-vscode/src/keyboard/grammar/grammar.ne @@ -14,9 +14,17 @@ main -> %makeRange decoratedMark {% command("targetDecoratedMarkExtend", [_, "decoratedMark"]) %} +# "and air" +main -> %makeList decoratedMark {% + command("targetDecoratedMarkAppend", [_, "decoratedMark"]) +%} + # "funk" main -> scopeType {% command("modifyTargetContainingScope", ["scopeType"]) %} +# "every funk" +main -> %every scopeType {% command("targetEveryScopeType", [_, "scopeType"]) %} + # "[third] next [two] funks" # "[third] previous [two] funks" main -> offset:? %nextPrev number:? scopeType {% @@ -26,14 +34,27 @@ main -> offset:? %nextPrev number:? scopeType {% ) %} +# "three funks [backward]" +main -> offset scopeType {% + command("targetRelativeInclusiveScope", ["offset", "scopeType"]) +%} + # "chuck" main -> %simpleAction {% command("performSimpleActionOnTarget", ["actionName"]) %} +# "round wrap" +main -> %wrap %pairedDelimiter {% + command("performWrapActionOnTarget", [_, "delimiter"]) +%} + # Custom vscode command main -> %vscodeCommand {% command("vscodeCommand", ["command"]) %} # ========================== Captures ========================= scopeType -> %simpleScopeTypeType {% capture("type") %} +scopeType -> %pairedDelimiter {% + ([delimiter]) => ({ type: "surroundingPair", delimiter }) +%} decoratedMark -> %color {% capture("color") %} diff --git a/packages/cursorless-vscode/src/keyboard/keyboard-config.fixture.json b/packages/cursorless-vscode/src/keyboard/keyboard-config.fixture.json index 5e9006333c1..22ce14986c7 100644 --- a/packages/cursorless-vscode/src/keyboard/keyboard-config.fixture.json +++ b/packages/cursorless-vscode/src/keyboard/keyboard-config.fixture.json @@ -60,10 +60,13 @@ "cursorless.experimental.keyboard.modal.keybindings.misc": { "fx": "combineColorAndShape", "fk": "makeRange", - "-": "backward" + "fa": "makeList", + "-": "backward", + "aw": "wrap" }, "cursorless.experimental.keyboard.modal.keybindings.modifier": { - "n": "nextPrev" + "n": "nextPrev", + "x": "every" }, "cursorless.experimental.keyboard.modal.keybindings.vscodeCommand": { "va": "editor.action.addCommentLine", @@ -77,5 +80,21 @@ "keepChangedSelection": true, "exitCursorlessMode": true } + }, + "cursorless.experimental.keyboard.modal.keybindings.pairedDelimiter": { + "wl": "angleBrackets", + "wt": "backtickQuotes", + "wb": "curlyBrackets", + "wd": "doubleQuotes", + "wwd": "escapedDoubleQuotes", + "wwp": "escapedParentheses", + "wws": "escapedSquareBrackets", + "wwq": "escapedSingleQuotes", + "wp": "parentheses", + "wq": "singleQuotes", + "ws": "squareBrackets", + "wg": "string", + "wj": "any", + "wk": "collectionBoundary" } }