diff --git a/docs/user/experimental/keyboard/modal.md b/docs/user/experimental/keyboard/modal.md index c8c0d5ba29..9a379f73c3 100644 --- a/docs/user/experimental/keyboard/modal.md +++ b/docs/user/experimental/keyboard/modal.md @@ -20,6 +20,11 @@ Paste the following into your [VSCode `keybindings.json`](https://code.visualstu "command": "cursorless.keyboard.modal.modeOn", "when": "editorTextFocus" }, + { + "key": "ctrl+c", + "command": "cursorless.keyboard.targeted.targetSelection", + "when": "cursorless.keyboard.modal.mode && editorTextFocus" + }, { "key": "escape", "command": "cursorless.keyboard.escape", @@ -37,6 +42,8 @@ Any keybindings that use modifier keys should go in `keybindings.json` as well, 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. +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. + ### `settings.json` 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"`): diff --git a/packages/cursorless-engine/src/generateSpokenForm/defaultSpokenForms/modifiers.ts b/packages/cursorless-engine/src/generateSpokenForm/defaultSpokenForms/modifiers.ts index 1652c9e02b..d2ea3f9f31 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 0000000000..ff3a19bbe1 --- /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 37648cf84c..c68a23dc48 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 c7378dab67..fca3f98546 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,70 @@ import path from "path"; import { getCursorlessRepoRoot } from "@cursorless/common"; import { readFile } from "node:fs/promises"; +interface TestCase { + name: string; + initialContent: string; + /** + * The sequence of keypresses that will be sent. The list of strings will simply + * be concatenated before sending. We could just represent this as a single string + * but it is more readable if each "token" is a separate string. + */ + 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 \n", + }, + { + name: "every", + initialContent: "a a\nb b\n", + // change every token air + keySequence: ["da", "x", "st", "c"], + finalContent: " \nb b\n", + }, + { + name: "three", + initialContent: "a b c d e\n", + // change three tokens bat + keySequence: ["db", "3", "st", "c"], + finalContent: "a e\n", + }, + { + name: "three backwards", + initialContent: "a b c d e\n", + // change three tokens backwards drum + keySequence: ["dd", "-3", "st", "c"], + finalContent: "a e\n", + }, + { + 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 +86,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 +149,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.join("")); + assert.equal(editor.document.getText(), 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 423a19fb8f..1a27b608f4 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 { Modifier, 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 @@ -27,15 +28,8 @@ import { ModalVscodeCommandDescriptor } from "./TokenTypes"; export class KeyboardCommandHandler { constructor(private targeted: KeyboardCommandsTargeted) {} - targetDecoratedMarkReplace({ decoratedMark }: DecoratedMarkArg) { - this.targeted.targetDecoratedMark(decoratedMark); - } - - targetDecoratedMarkExtend({ decoratedMark }: DecoratedMarkArg) { - this.targeted.targetDecoratedMark({ - ...decoratedMark, - mode: "extend", - }); + targetDecoratedMark({ decoratedMark, mode }: DecoratedMarkArg) { + this.targeted.targetDecoratedMark({ ...decoratedMark, mode }); } async vscodeCommand({ @@ -78,22 +72,18 @@ export class KeyboardCommandHandler { this.targeted.performSimpleActionOnTarget(actionName); } - 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, + })); } - targetRelativeExclusiveScope({ - offset, - length, - scopeType, - }: TargetRelativeExclusiveScopeArg) { - this.targeted.targetModifier({ - type: "relativeScope", - offset: offset?.number ?? 1, - direction: offset?.direction ?? "forward", - length: length ?? 1, - scopeType, - }); + modifyTarget({ modifier }: { modifier: Modifier }) { + this.targeted.targetModifier(modifier); } } @@ -102,16 +92,7 @@ interface DecoratedMarkArg { color?: HatColor; shape?: HatShape; }; -} -interface TargetRelativeExclusiveScopeArg { - offset: Offset; - length: number | null; - scopeType: ScopeType; -} - -interface Offset { - direction: "forward" | "backward" | null; - number: number | null; + mode: "replace" | "extend" | "append"; } function isString(input: any): input is string { diff --git a/packages/cursorless-vscode/src/keyboard/KeyboardCommandsModal.ts b/packages/cursorless-vscode/src/keyboard/KeyboardCommandsModal.ts index faebcf2204..53a0fe1e2b 100644 --- a/packages/cursorless-vscode/src/keyboard/KeyboardCommandsModal.ts +++ b/packages/cursorless-vscode/src/keyboard/KeyboardCommandsModal.ts @@ -1,4 +1,4 @@ -import { pick, toPairs } from "lodash"; +import { pick, sortedUniq, toPairs } from "lodash"; import { Grammar, Parser } from "nearley"; import * as vscode from "vscode"; import { KeyboardCommandsModalLayer } from "./KeyboardCommandsModalLayer"; @@ -90,9 +90,9 @@ export default class KeyboardCommandsModal { private computeLayer() { const acceptableTokenTypeInfos = getAcceptableTokenTypes(this.parser); // FIXME: Here's where we'd update sidebar - const acceptableTokenTypes = acceptableTokenTypeInfos - .map(({ type }) => type) - .sort(); + const acceptableTokenTypes = sortedUniq( + acceptableTokenTypeInfos.map(({ type }) => type).sort(), + ); let layer = this.layerCache.get(acceptableTokenTypes); if (layer == null) { layer = new KeyboardCommandsModalLayer( diff --git a/packages/cursorless-vscode/src/keyboard/TokenTypes.ts b/packages/cursorless-vscode/src/keyboard/TokenTypes.ts index f40e59c350..58dd20135e 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,11 +14,12 @@ 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" @@ -48,17 +49,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 383a11b0bf..62c6c95017 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", "action", ["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/CommandRulePostProcessor.ts b/packages/cursorless-vscode/src/keyboard/grammar/CommandRulePostProcessor.ts index 1e9581f2c1..4e09bf242d 100644 --- a/packages/cursorless-vscode/src/keyboard/grammar/CommandRulePostProcessor.ts +++ b/packages/cursorless-vscode/src/keyboard/grammar/CommandRulePostProcessor.ts @@ -2,7 +2,6 @@ import { KeyboardCommand, KeyboardCommandArgTypes, } from "../KeyboardCommandTypeHelpers"; -import { Unused } from "./grammarHelpers"; /** * Represents a post-processing function for a top-level rule of our grammar. @@ -19,7 +18,5 @@ export interface CommandRulePostProcessor< metadata: { /** The command type */ type: T; - /** The names of the arguments to the command's argument payload */ - argNames: (keyof KeyboardCommandArgTypes[T] | Unused)[]; }; } diff --git a/packages/cursorless-vscode/src/keyboard/grammar/generated/grammar.ts b/packages/cursorless-vscode/src/keyboard/grammar/generated/grammar.ts index fd2b867d6b..78d161e194 100644 --- a/packages/cursorless-vscode/src/keyboard/grammar/generated/grammar.ts +++ b/packages/cursorless-vscode/src/keyboard/grammar/generated/grammar.ts @@ -4,9 +4,13 @@ // @ts-ignore function id(d: any[]): any { return d[0]; } declare var makeRange: any; -declare var nextPrev: any; +declare var makeList: any; declare var simpleAction: any; +declare var wrap: any; +declare var pairedDelimiter: any; declare var vscodeCommand: any; +declare var every: any; +declare var nextPrev: any; declare var simpleScopeTypeType: any; declare var color: any; declare var shape: any; @@ -14,9 +18,11 @@ declare var combineColorAndShape: any; declare var direction: any; declare var digit: any; -import { capture, command, UNUSED as _ } from "../grammarHelpers" +import { capture, command, UNUSED as _, argPositions } from "../grammarHelpers" import { keyboardLexer } from "../keyboardLexer"; +const { $0, $1, $2 } = argPositions; + interface NearleyToken { value: any; [key: string]: any; @@ -47,24 +53,49 @@ interface Grammar { const grammar: Grammar = { Lexer: keyboardLexer, ParserRules: [ - {"name": "main", "symbols": ["decoratedMark"], "postprocess": command("targetDecoratedMarkReplace", ["decoratedMark"])}, + {"name": "main", "symbols": ["decoratedMark"], "postprocess": + command("targetDecoratedMark", { decoratedMark: $0, mode: "replace" }) + }, {"name": "main", "symbols": [(keyboardLexer.has("makeRange") ? {type: "makeRange"} : makeRange), "decoratedMark"], "postprocess": - command("targetDecoratedMarkExtend", [_, "decoratedMark"]) + command("targetDecoratedMark", { decoratedMark: $1, mode: "extend" }) }, - {"name": "main", "symbols": ["scopeType"], "postprocess": command("modifyTargetContainingScope", ["scopeType"])}, - {"name": "main$ebnf$1", "symbols": ["offset"], "postprocess": id}, - {"name": "main$ebnf$1", "symbols": [], "postprocess": () => null}, - {"name": "main$ebnf$2", "symbols": ["number"], "postprocess": id}, - {"name": "main$ebnf$2", "symbols": [], "postprocess": () => null}, - {"name": "main", "symbols": ["main$ebnf$1", (keyboardLexer.has("nextPrev") ? {type: "nextPrev"} : nextPrev), "main$ebnf$2", "scopeType"], "postprocess": - command( - "targetRelativeExclusiveScope", - ["offset", _, "length", "scopeType"], - ) + {"name": "main", "symbols": [(keyboardLexer.has("makeList") ? {type: "makeList"} : makeList), "decoratedMark"], "postprocess": + command("targetDecoratedMark", { decoratedMark: $1, mode: "append" }) }, + {"name": "main", "symbols": ["modifier"], "postprocess": command("modifyTarget", { modifier: $0 })}, {"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": "modifier", "symbols": ["scopeType"], "postprocess": capture({ type: "containingScope", scopeType: $0 })}, + {"name": "modifier", "symbols": [(keyboardLexer.has("every") ? {type: "every"} : every), "scopeType"], "postprocess": capture({ type: "everyScope", scopeType: $1 })}, + {"name": "modifier$ebnf$1", "symbols": ["offset"], "postprocess": id}, + {"name": "modifier$ebnf$1", "symbols": [], "postprocess": () => null}, + {"name": "modifier$ebnf$2", "symbols": ["number"], "postprocess": id}, + {"name": "modifier$ebnf$2", "symbols": [], "postprocess": () => null}, + {"name": "modifier", "symbols": ["modifier$ebnf$1", (keyboardLexer.has("nextPrev") ? {type: "nextPrev"} : nextPrev), "modifier$ebnf$2", "scopeType"], "postprocess": + ([offset, _, length, scopeType]) => ({ + type: "relativeScope", + offset: offset?.number ?? 1, + direction: offset?.direction ?? "forward", + length: length ?? 1, + scopeType, + }) + }, + {"name": "modifier", "symbols": ["offset", "scopeType"], "postprocess": + ([offset, scopeType]) => ({ + type: "relativeScope", + offset: 0, + direction: offset?.direction ?? "forward", + length: offset?.number ?? 1, + scopeType, + }) + }, {"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/getAcceptableTokenTypes.ts b/packages/cursorless-vscode/src/keyboard/grammar/getAcceptableTokenTypes.ts index f98e65a01d..e621f3d19d 100644 --- a/packages/cursorless-vscode/src/keyboard/grammar/getAcceptableTokenTypes.ts +++ b/packages/cursorless-vscode/src/keyboard/grammar/getAcceptableTokenTypes.ts @@ -3,9 +3,7 @@ import { isEqual } from "lodash"; import { CommandRulePostProcessor } from "./CommandRulePostProcessor"; import { UniqueWorkQueue } from "./UniqueWorkQueue"; import { uniqWithHash } from "@cursorless/common"; -import { UNUSED } from "./grammarHelpers"; import { KeyboardCommandHandler } from "../KeyboardCommandHandler"; -import { KeyboardCommandArgTypes } from "../KeyboardCommandTypeHelpers"; /** * Given a parser, returns a list of acceptable token types at the current state @@ -76,19 +74,8 @@ function getRootStates(state: nearley.State) { * @returns A partial argument for the command that the state represents */ function computePartialArg( - state: nearley.State, -) { - const { argNames } = getMetadata(state); - let currentState = state; - const partialArg: Partial> = {}; - - while (currentState.dot > 0) { - const argName = argNames[currentState.dot - 1]!; - if (argName !== UNUSED) { - partialArg[argName] = currentState.right?.data; - } - currentState = currentState.left!; - } - - return partialArg; + _state: nearley.State, +): Partial> { + // FIXME: Fill this out + return {}; } diff --git a/packages/cursorless-vscode/src/keyboard/grammar/grammar.ne b/packages/cursorless-vscode/src/keyboard/grammar/grammar.ne index 657895d990..2b3f5f3089 100644 --- a/packages/cursorless-vscode/src/keyboard/grammar/grammar.ne +++ b/packages/cursorless-vscode/src/keyboard/grammar/grammar.ne @@ -1,40 +1,87 @@ @preprocessor typescript @{% -import { capture, command, UNUSED as _ } from "../grammarHelpers" +import { capture, command, UNUSED as _, argPositions } from "../grammarHelpers" import { keyboardLexer } from "../keyboardLexer"; + +const { $0, $1, $2 } = argPositions; %} @lexer keyboardLexer # ===================== Top-level commands =================== + +# --------------------------- Marks -------------------------- # "air" -main -> decoratedMark {% command("targetDecoratedMarkReplace", ["decoratedMark"]) %} +main -> decoratedMark {% + command("targetDecoratedMark", { decoratedMark: $0, mode: "replace" }) +%} # "past air" main -> %makeRange decoratedMark {% - command("targetDecoratedMarkExtend", [_, "decoratedMark"]) + command("targetDecoratedMark", { decoratedMark: $1, mode: "extend" }) %} -# "funk" -main -> scopeType {% command("modifyTargetContainingScope", ["scopeType"]) %} - -# "[third] next [two] funks" -# "[third] previous [two] funks" -main -> offset:? %nextPrev number:? scopeType {% - command( - "targetRelativeExclusiveScope", - ["offset", _, "length", "scopeType"], - ) +# "and air" +main -> %makeList decoratedMark {% + command("targetDecoratedMark", { decoratedMark: $1, mode: "append" }) %} +# --------------------------- Modifier -------------------------- + +main -> modifier {% command("modifyTarget", { modifier: $0 }) %} + +# --------------------------- Actions -------------------------- + # "chuck" main -> %simpleAction {% command("performSimpleActionOnTarget", ["actionName"]) %} +# "round wrap" +main -> %wrap %pairedDelimiter {% + command("performWrapActionOnTarget", [_, "delimiter"]) +%} + # Custom vscode command main -> %vscodeCommand {% command("vscodeCommand", ["command"]) %} -# ========================== Captures ========================= +# ========================== Captures ============================= + +# --------------------------- Modifiers --------------------------- + +# "funk" +modifier -> scopeType {% capture({ type: "containingScope", scopeType: $0 }) %} + +# "every funk" +modifier -> %every scopeType {% capture({ type: "everyScope", scopeType: $1 }) %} + +# "[third] next [two] funks" +# "[third] previous [two] funks" +modifier -> offset:? %nextPrev number:? scopeType {% + ([offset, _, length, scopeType]) => ({ + type: "relativeScope", + offset: offset?.number ?? 1, + direction: offset?.direction ?? "forward", + length: length ?? 1, + scopeType, + }) +%} + +# "three funks [backward]" +modifier -> offset scopeType {% + ([offset, scopeType]) => ({ + type: "relativeScope", + offset: 0, + direction: offset?.direction ?? "forward", + length: offset?.number ?? 1, + scopeType, + }) +%} + +# --------------------------- Scope types --------------------------- scopeType -> %simpleScopeTypeType {% capture("type") %} +scopeType -> %pairedDelimiter {% + ([delimiter]) => ({ type: "surroundingPair", delimiter }) +%} +# --------------------------- Other --------------------------- decoratedMark -> %color {% capture("color") %} | %shape {% capture("shape") %} diff --git a/packages/cursorless-vscode/src/keyboard/grammar/grammar.test.ts b/packages/cursorless-vscode/src/keyboard/grammar/grammar.test.ts index 817e994c73..c8fa351667 100644 --- a/packages/cursorless-vscode/src/keyboard/grammar/grammar.test.ts +++ b/packages/cursorless-vscode/src/keyboard/grammar/grammar.test.ts @@ -18,8 +18,9 @@ const testCases: TestCase[] = [ decoratedMark: { shape: "fox", }, + mode: "replace", }, - type: "targetDecoratedMarkReplace", + type: "targetDecoratedMark", }, }, { @@ -29,8 +30,9 @@ const testCases: TestCase[] = [ decoratedMark: { color: "green", }, + mode: "replace", }, - type: "targetDecoratedMarkReplace", + type: "targetDecoratedMark", }, }, { @@ -45,8 +47,9 @@ const testCases: TestCase[] = [ color: "green", shape: "fox", }, + mode: "replace", }, - type: "targetDecoratedMarkReplace", + type: "targetDecoratedMark", }, }, { @@ -61,8 +64,9 @@ const testCases: TestCase[] = [ color: "green", shape: "fox", }, + mode: "replace", }, - type: "targetDecoratedMarkReplace", + type: "targetDecoratedMark", }, }, { @@ -75,8 +79,9 @@ const testCases: TestCase[] = [ decoratedMark: { color: "green", }, + mode: "extend", }, - type: "targetDecoratedMarkExtend", + type: "targetDecoratedMark", }, }, { @@ -88,16 +93,17 @@ const testCases: TestCase[] = [ ], expected: { arg: { - length: null, - offset: { - number: 12, - direction: null, - }, - scopeType: { - type: "namedFunction", + modifier: { + type: "relativeScope", + length: 1, + offset: 12, + direction: "forward", + scopeType: { + type: "namedFunction", + }, }, }, - type: "targetRelativeExclusiveScope", + type: "modifyTarget", }, }, { @@ -108,16 +114,17 @@ const testCases: TestCase[] = [ ], expected: { arg: { - length: null, - offset: { - number: null, + modifier: { + type: "relativeScope", + length: 1, + offset: 1, direction: "backward", - }, - scopeType: { - type: "namedFunction", + scopeType: { + type: "namedFunction", + }, }, }, - type: "targetRelativeExclusiveScope", + type: "modifyTarget", }, }, { diff --git a/packages/cursorless-vscode/src/keyboard/grammar/grammarHelpers.ts b/packages/cursorless-vscode/src/keyboard/grammar/grammarHelpers.ts index 5e654b47c0..a716da26c8 100644 --- a/packages/cursorless-vscode/src/keyboard/grammar/grammarHelpers.ts +++ b/packages/cursorless-vscode/src/keyboard/grammar/grammarHelpers.ts @@ -1,3 +1,4 @@ +import { isString } from "lodash"; import { KeyboardCommandArgTypes } from "../KeyboardCommandTypeHelpers"; import { CommandRulePostProcessor } from "./CommandRulePostProcessor"; @@ -6,56 +7,120 @@ export type Unused = typeof UNUSED; /** * @param args The values output by the parser rule - * @param argNames The keys to use for the payload + * @param argExtractors Extractors to get values for payload * @returns An object with the given keys mapped to the values at the same * positions in the parser rule's output */ -function constructPayload(args: any[], argNames: (string | Unused)[]) { - const arg: Record = {}; - for (let i = 0; i < argNames.length; i++) { - const name = argNames[i]; - if (name === UNUSED) { - continue; +function constructPayload( + args: any[], + argExtractors: KeyboardArgExtractor, +): Record { + const arg: Partial> = {}; + for (const [key, value] of Object.entries(argExtractors)) { + if (value instanceof ArgPosition) { + arg[key as keyof K] = args[value.position]; + } else { + arg[key as keyof K] = value; } - arg[name] = args[i]; } - return arg; + return arg as Record; } +class ArgPosition { + constructor(public position: number) {} +} + +export const argPositions: Record = { + $0: new ArgPosition(0), + $1: new ArgPosition(1), + $2: new ArgPosition(2), +}; + +type KeyboardArgExtractor = { + [K in keyof T]: T[K] | ArgPosition; +}; + /** * Creates a postprocess function for a top-level rule of our grammar. This is a * function that takes the output of a rule and transforms it into a command * usable by our command handler. It does so by constructing a payload object * with `type` as provided in {@link type}, and `args` constructed by mapping - * {@link argNames} to the values at the same positions in the parser rule's - * output. + * {@link argExtractors} to the values at the same positions in the parser + * rule's output. + * + * We also keep metadata about the rule on the postprocess function so that we + * can display it to the user, eg in the sidebar. The reason we keep the + * metadata here is that the postprocess function is the only thing we have + * control over in the nearley parser. + * + * The {@link argExtractors} argument can be either: + * + * - A function that takes the output of the parser rule and returns the + * command's argument payload. For example: + * + * ```ts + * p = command("foo", (args) => ({ bar: args[0], baz: args[1] })) + * assert(p(["a", "b"]) === { type: "foo", arg: { bar: "a", baz: "b" } } + * ``` + * - An object mapping the names of the arguments to the command's argument + * payload to the positions of the values in the parser rule's output. For + * example: + * + * ```ts + * p = command("foo", { bar: $1, baz: "hello" }) + * assert(p(["a", "b"]) === { type: "foo", arg: { bar: "b", baz: "hello" } } + * ``` + * + * - An array of the names of the arguments to the command's argument payload. + * For example: * - * We also keep metadata about the rule on the - * postprocess function so that we can display it to the user, eg in the - * sidebar. The reason we keep the metadata here is that the postprocess - * function is the only thing we have control over in the nearley parser. + * ```ts + * p = command("foo", ["bar", "baz"]) + * assert(p(["a", "b"]) === { type: "foo", arg: { bar: "a", baz: "b" } } + * ``` * * @param type The type of the command - * @param argNames The names of the arguments to the command's argument payload + * @param argExtractors The extractors to use to get the command's argument (see + * above) * @returns A postprocess function for the command */ export function command( type: T, - argNames: (keyof KeyboardCommandArgTypes[T] | Unused)[], + argExtractors: + | KeyboardArgExtractor + | (keyof KeyboardCommandArgTypes[T] | Unused)[] + | ((args: any[]) => KeyboardCommandArgTypes[T]), ): CommandRulePostProcessor { + let extractArgs: (args: any[]) => KeyboardCommandArgTypes[T]; + + if (typeof argExtractors === "function") { + extractArgs = argExtractors; + } else { + const extractors = Array.isArray(argExtractors) + ? getArgExtractors(argExtractors) + : argExtractors; + extractArgs = (args: any[]) => + constructPayload(args, extractors) as KeyboardCommandArgTypes[T]; + } + function ret(args: any[]) { return { type, - arg: constructPayload( - args, - argNames as (string | Unused)[], - ) as KeyboardCommandArgTypes[T], + arg: extractArgs(args), }; } - ret.metadata = { type, argNames }; + ret.metadata = { type }; return ret; } +function getArgExtractors(argExtractors: (keyof T | typeof UNUSED)[]): T { + return Object.fromEntries( + argExtractors + .map((arg, i) => [arg, new ArgPosition(i)]) + .filter(([arg]) => arg !== UNUSED), + ); +} + /** * Creates a postprocess function for a lower-level capture in our keyboard * grammar. The output will be an object with the keys of {@link argNames} @@ -80,13 +145,24 @@ export function command( * { bar: 0, baz: 1 } * ``` * - * @param argNames The keys to use for the payload + * @param argExtractor The extractors to use to get the argument payload * @returns A postprocess function that constructs a payload with the given keys * mapped to the values at the same positions in the parser rule's output */ -export function capture(...argNames: (string | Unused)[]) { - function ret(args: any[]) { - return constructPayload(args, argNames); - } - return ret; +export function capture( + argExtractor: KeyboardArgExtractor>, +): (args: any[]) => Record; +export function capture( + ...argNames: (string | Unused)[] +): (args: any[]) => Record; +export function capture( + arg0: (string | Unused) | KeyboardArgExtractor>, + ...argNames: (string | Unused)[] +): (args: any[]) => Record { + const extractors = + isString(arg0) || arg0 === UNUSED + ? getArgExtractors([arg0, ...argNames]) + : arg0; + + return (args: any[]) => constructPayload(args, extractors); } diff --git a/packages/cursorless-vscode/src/keyboard/keyboard-config.fixture.json b/packages/cursorless-vscode/src/keyboard/keyboard-config.fixture.json index 5e9006333c..d98896e572 100644 --- a/packages/cursorless-vscode/src/keyboard/keyboard-config.fixture.json +++ b/packages/cursorless-vscode/src/keyboard/keyboard-config.fixture.json @@ -37,7 +37,8 @@ "ac": "copyToClipboard", "ax": "cutToClipboard", "ap": "pasteFromClipboard", - "ad": "followLink" + "ad": "followLink", + "aw": "wrap" }, "cursorless.experimental.keyboard.modal.keybindings.color": { "d": "default", @@ -60,10 +61,12 @@ "cursorless.experimental.keyboard.modal.keybindings.misc": { "fx": "combineColorAndShape", "fk": "makeRange", + "fa": "makeList", "-": "backward" }, "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" } }