diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 69cd1ca783..b58d9e71b1 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -32,6 +32,16 @@ }, "group": "build" }, + { + "label": "Build tutorial webview", + "type": "npm", + "script": "compile:dev", + "path": "packages/cursorless-vscode-tutorial-webview", + "presentation": { + "reveal": "silent" + }, + "group": "build" + }, { "label": "Build test harness", "type": "npm", @@ -57,6 +67,7 @@ "type": "npm", "script": "populate-dist", "path": "packages/cursorless-vscode", + "dependsOn": ["Build tutorial webview"], "presentation": { "reveal": "silent" }, diff --git a/cursorless-talon/src/cursorless.py b/cursorless-talon/src/cursorless.py index 9617f51593..4f0e1b7b01 100644 --- a/cursorless-talon/src/cursorless.py +++ b/cursorless-talon/src/cursorless.py @@ -21,3 +21,9 @@ def private_cursorless_show_command_statistics(): actions.user.private_cursorless_run_rpc_command_no_wait( "cursorless.analyzeCommandHistory" ) + + def private_cursorless_start_tutorial(): + """Start Cursorless tutorial""" + actions.user.private_cursorless_run_rpc_command_no_wait( + "cursorless.tutorial.start", "unit-2-basic-coding" + ) diff --git a/cursorless-talon/src/cursorless.talon b/cursorless-talon/src/cursorless.talon index f12bab8a3e..14c6ac9839 100644 --- a/cursorless-talon/src/cursorless.talon +++ b/cursorless-talon/src/cursorless.talon @@ -46,3 +46,6 @@ bar {user.cursorless_homophone}: {user.cursorless_homophone} stats: user.private_cursorless_show_command_statistics() + +{user.cursorless_homophone} tutorial: + user.private_cursorless_start_tutorial() diff --git a/data/playground/tutorial/extra-cloning-a-talon-list.py b/data/playground/tutorial/extra-cloning-a-talon-list.py new file mode 100644 index 0000000000..75d0c0b759 --- /dev/null +++ b/data/playground/tutorial/extra-cloning-a-talon-list.py @@ -0,0 +1,9 @@ +from talon import Context, Module + +mod = Module() +ctx = Context() + +mod.list("cursorless_walkthrough_list", desc="My tutorial list") +ctx.list["user.cursorless_walkthrough_list"] = { + "spoken form": "whatever", +} diff --git a/data/playground/tutorial/unit-1-basics.txt b/data/playground/tutorial/unit-1-basics.txt new file mode 100644 index 0000000000..ac66cd3ef8 --- /dev/null +++ b/data/playground/tutorial/unit-1-basics.txt @@ -0,0 +1,11 @@ +================================================== +========== ========== +========== Welcome to Cursorless! ========== +========== ========== +========== Let's start using marks ========== +========== ========== +========== so we can navigate around ========== +========== ========== +========== without lifting a finger! ========== +========== ========== +================================================== diff --git a/data/playground/tutorial/unit-2-basic-coding.py b/data/playground/tutorial/unit-2-basic-coding.py new file mode 100644 index 0000000000..636809337d --- /dev/null +++ b/data/playground/tutorial/unit-2-basic-coding.py @@ -0,0 +1,13 @@ +def print_color(color, invert=False): + if invert: + print(invert_color(color)) + else: + print(color) + + +def invert_color(color): + if color == "black": + return "white" + + +print_color("black") diff --git a/packages/common/src/cursorlessCommandIds.ts b/packages/common/src/cursorlessCommandIds.ts index ecbda32f31..1abf347e57 100644 --- a/packages/common/src/cursorlessCommandIds.ts +++ b/packages/common/src/cursorlessCommandIds.ts @@ -47,6 +47,7 @@ export const cursorlessCommandIds = [ "cursorless.toggleDecorations", "cursorless.showScopeVisualizer", "cursorless.hideScopeVisualizer", + "cursorless.tutorial.start", "cursorless.analyzeCommandHistory", ] as const satisfies readonly `cursorless.${string}`[]; @@ -81,6 +82,7 @@ export const cursorlessCommandDescriptions: Record< "Analyze collected command history", ), + ["cursorless.tutorial.start"]: new HiddenCommand("Start a tutorial"), ["cursorless.command"]: new HiddenCommand("The core cursorless command"), ["cursorless.showQuickPick"]: new HiddenCommand( "Pop up a quick pick of all cursorless commands", diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 224b5708aa..d27fad0195 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -49,7 +49,9 @@ export * from "./types/HatTokenMap"; export * from "./types/ScopeProvider"; export * from "./types/SpokenForm"; export * from "./types/commandHistory"; +export * from "./types/tutorial.types"; export * from "./util/textFormatters"; +export * from "./util/serializedMarksToTokenHats"; export * from "./types/snippet.types"; export * from "./testUtil/fromPlainObject"; export * from "./testUtil/spyToPlainObject"; diff --git a/packages/common/src/types/tutorial.types.ts b/packages/common/src/types/tutorial.types.ts new file mode 100644 index 0000000000..00f51fb9af --- /dev/null +++ b/packages/common/src/types/tutorial.types.ts @@ -0,0 +1,16 @@ +export type TutorialId = "introduction"; + +interface PickingTutorialState { + type: "pickingTutorial"; +} + +export interface ActiveTutorialState { + type: "doingTutorial"; + title: string; + tutorialId: TutorialId; + stepNumber: number; + stepContent: string; + stepCount: number; +} + +export type TutorialState = PickingTutorialState | ActiveTutorialState; diff --git a/packages/common/src/util/serializedMarksToTokenHats.ts b/packages/common/src/util/serializedMarksToTokenHats.ts new file mode 100644 index 0000000000..94203c2901 --- /dev/null +++ b/packages/common/src/util/serializedMarksToTokenHats.ts @@ -0,0 +1,36 @@ +import { plainObjectToRange } from "../testUtil/fromPlainObject"; +import { splitKey } from "./splitKey"; +import { SerializedMarks } from "./toPlainObject"; +import { TextEditor } from "../types/TextEditor"; +import { TokenHat } from "../types/HatTokenMap"; + +export function serializedMarksToTokenHats( + marks: SerializedMarks | undefined, + editor: TextEditor, +): TokenHat[] { + if (marks == null) { + return []; + } + + return Object.entries(marks).map(([key, token]) => { + const { hatStyle, character } = splitKey(key); + const range = plainObjectToRange(token); + + return { + hatStyle, + grapheme: character, + token: { + editor, + range, + offsets: { + start: editor.document.offsetAt(range.start), + end: editor.document.offsetAt(range.end), + }, + text: editor.document.getText(range), + }, + + // NB: We don't care about the hat range for this test + hatRange: range, + }; + }); +} diff --git a/packages/cursorless-engine/src/api/CursorlessEngineApi.ts b/packages/cursorless-engine/src/api/CursorlessEngineApi.ts index 63e4b8d591..1ed93585ec 100644 --- a/packages/cursorless-engine/src/api/CursorlessEngineApi.ts +++ b/packages/cursorless-engine/src/api/CursorlessEngineApi.ts @@ -9,6 +9,7 @@ import type { import type { CommandRunner } from "../CommandRunner"; import type { Snippets } from "../core/Snippets"; import type { StoredTargetMap } from "../core/StoredTargets"; +import { Tutorial } from "./Tutorial"; export interface CursorlessEngine { commandApi: CommandApi; @@ -17,6 +18,7 @@ export interface CursorlessEngine { storedTargets: StoredTargetMap; hatTokenMap: HatTokenMap; snippets: Snippets; + tutorial: Tutorial; injectIde: (ide: IDE | undefined) => void; runIntegrationTests: () => Promise; addCommandRunnerDecorator: ( diff --git a/packages/cursorless-engine/src/api/Tutorial.ts b/packages/cursorless-engine/src/api/Tutorial.ts new file mode 100644 index 0000000000..7174d05398 --- /dev/null +++ b/packages/cursorless-engine/src/api/Tutorial.ts @@ -0,0 +1,55 @@ +import { TutorialId } from "@cursorless/common"; + +export interface TutorialContent { + /** + * The title of the tutorial + */ + title: string; + + /** + * The steps of the current tutorial + */ + steps: Array; +} + +export interface RawTutorialContent { + /** + * The title of the tutorial + */ + title: string; + + /** + * The steps of the current tutorial + */ + steps: string[]; +} + +export interface TutorialStep { + /** + * The text content of the current step + */ + content: string; + + /** + * The path to the yaml file that should be used to setup the current step (if + * any). The path is relative to the tutorial directory for the given tutorial. + */ + fixturePath?: string; +} + +export interface TutorialSetupStepArg { + /** + * The id of the current tutorial + */ + tutorialId: string; + + /** + * The yaml file for the current step + */ + fixturePath: string; +} + +export interface Tutorial { + getContent(id: TutorialId): Promise; + setupStep(arg: TutorialSetupStepArg): Promise; +} diff --git a/packages/cursorless-engine/src/core/IndividualHatMap.ts b/packages/cursorless-engine/src/core/IndividualHatMap.ts index 5b00390b4e..334c15b516 100644 --- a/packages/cursorless-engine/src/core/IndividualHatMap.ts +++ b/packages/cursorless-engine/src/core/IndividualHatMap.ts @@ -58,7 +58,7 @@ export class IndividualHatMap implements ReadOnlyHatMap { } /** - * Overwrites the hat assignemnt for this hat token map. + * Overwrites the hat assignment for this hat token map. * * @param tokenHats The new hat assignments */ diff --git a/packages/cursorless-engine/src/core/TutorialImpl.ts b/packages/cursorless-engine/src/core/TutorialImpl.ts new file mode 100644 index 0000000000..ebe892a8da --- /dev/null +++ b/packages/cursorless-engine/src/core/TutorialImpl.ts @@ -0,0 +1,195 @@ +import { + ScopeType, + TestCaseFixture, + TextEditor, + TutorialId, + plainObjectToSelection, + serializedMarksToTokenHats, +} from "@cursorless/common"; +import * as yaml from "js-yaml"; +import { readFile } from "node:fs/promises"; +import path from "path"; +import { + RawTutorialContent, + Tutorial, + TutorialContent, + TutorialSetupStepArg, +} from "../api/Tutorial"; +import { CustomSpokenFormGeneratorImpl } from "../generateSpokenForm/CustomSpokenFormGeneratorImpl"; +import { actions } from "../generateSpokenForm/defaultSpokenForms/actions"; +import { ide } from "../singletons/ide.singleton"; +import { HatTokenMapImpl } from "./HatTokenMapImpl"; +import { canonicalizeAndValidateCommand } from "./commandVersionUpgrades/canonicalizeAndValidateCommand"; + +export class TutorialImpl implements Tutorial { + private tutorialRootDir: string; + private editor?: TextEditor; + + constructor( + private hatTokenMap: HatTokenMapImpl, + private customSpokenFormGenerator: CustomSpokenFormGeneratorImpl, + ) { + this.getContent = this.getContent.bind(this); + this.setupStep = this.setupStep.bind(this); + + this.tutorialRootDir = path.join(ide().assetsRoot, "tutorial"); + } + + /** + * Handle the argument of a "%%step:cloneStateInk.yml%%"" + */ + private async processStep(tutorialName: string, yamlFilename: string) { + const fixture = await this.loadFixture(tutorialName, yamlFilename); + + // command to be said for moving to the next step + const spokenForm = this.customSpokenFormGenerator.commandToSpokenForm( + canonicalizeAndValidateCommand(fixture.command), + ); + + if (spokenForm.type === "error") { + throw new Error( + `Error while processing spoken form for command ${fixture.command}: ${spokenForm.reason}`, + ); + } + + return spokenForm.spokenForms[0]; + } + + private async loadFixture( + tutorialName: string, + yamlFilename: string, + ): Promise { + const yamlPath = path.join( + this.tutorialRootDir, + tutorialName, + yamlFilename, + ); + + const buffer = await readFile(yamlPath); + return yaml.load(buffer.toString()) as TestCaseFixture; + } + + /** + * Handle the argument of a "%%scopeType:{type: statement}%%" + */ + private async processScopeType(arg: string) { + const scopeType = yaml.load(arg) as ScopeType; + const spokenForm = + this.customSpokenFormGenerator.scopeTypeToSpokenForm(scopeType); + + if (spokenForm.type === "error") { + throw new Error( + `Error while processing spoken form for command ${arg}: ${spokenForm.reason}`, + ); + } + + return spokenForm.spokenForms[0]; + } + + /** + * Load the "script.json" script for the current tutorial + */ + private async loadTutorialScript( + tutorialName: string, + ): Promise { + const scriptFile = path.join( + this.tutorialRootDir, + tutorialName, + "script.json", + ); + + const buffer = await readFile(scriptFile); + return JSON.parse(buffer.toString()); + } + + /** + * Handle the "cursorless.tutorial.getContent" command + */ + async getContent(tutorialId: TutorialId) { + const rawContent = await this.loadTutorialScript(tutorialId); + + // this is trying to catch occurrences of things like "%%step:cloneStateInk.yml%%" + const re = /%%(\w+):([^%]+)%%/; + + let spokenForm; + const response: TutorialContent = { + title: rawContent.title, + steps: [], + }; + // we need to replace the {...} with the right content + for (let content of rawContent.steps) { + let fixturePath: string | undefined = undefined; + let m = re.exec(content); + while (m) { + const [fullMatch, type, arg] = m; + switch (type) { + case "step": + fixturePath = arg; + spokenForm = await this.processStep(tutorialId, arg); + content = content.replace(fullMatch, ``); + break; + case "literalStep": + content = content.replace(fullMatch, ``); + break; + case "action": + // TODO: don't use hardcoded list of default spoken form for an action (not yet the user customized one) + spokenForm = actions[arg as keyof typeof actions]; + content = content.replace(fullMatch, `<*"${spokenForm}"/>`); + break; + case "scopeType": + spokenForm = await this.processScopeType(arg); + content = content.replace(fullMatch, `<*"${spokenForm}"/>`); + break; + default: + throw new Error(`Unknown name: ${type}`); + } + m = re.exec(content); + } + response.steps.push({ + content, + fixturePath, + }); + } + + // return to the talon side + return response; + } + + /** + * Handle the "cursorless.tutorial.setupStep" command + * @see packages/cursorless-vscode-e2e/src/suite/recorded.vscode.test.ts + */ + async setupStep({ tutorialId, fixturePath }: TutorialSetupStepArg) { + const fixture = await this.loadFixture(tutorialId, fixturePath); + + if (this.editor == null) { + this.editor = await ide().openUntitledTextDocument({ + content: fixture.initialState.documentContents, + language: fixture.languageId, + }); + } + + const editableEditor = ide().getEditableTextEditor(this.editor); + + await editableEditor.edit([ + { + range: editableEditor.document.range, + text: fixture.initialState.documentContents, + isReplace: true, + }, + ]); + + // Ensure that the expected cursor/selections are present + editableEditor.selections = fixture.initialState.selections.map( + plainObjectToSelection, + ); + + // Ensure that the expected hats are present + await this.hatTokenMap.allocateHats( + serializedMarksToTokenHats(fixture.initialState.marks, this.editor), + ); + + // TODO: Handle case where editor is in a background tab + await editableEditor.focus(); + } +} diff --git a/packages/cursorless-engine/src/cursorlessEngine.ts b/packages/cursorless-engine/src/cursorlessEngine.ts index 71dd1b1d4e..161380b6c6 100644 --- a/packages/cursorless-engine/src/cursorlessEngine.ts +++ b/packages/cursorless-engine/src/cursorlessEngine.ts @@ -14,6 +14,7 @@ import { Debug } from "./core/Debug"; import { HatTokenMapImpl } from "./core/HatTokenMapImpl"; import { Snippets } from "./core/Snippets"; import { StoredTargetMap } from "./core/StoredTargets"; +import { TutorialImpl } from "./core/TutorialImpl"; import { ensureCommandShape } from "./core/commandVersionUpgrades/ensureCommandShape"; import { RangeUpdater } from "./core/updateSelections/RangeUpdater"; import { CustomSpokenFormGeneratorImpl } from "./generateSpokenForm/CustomSpokenFormGeneratorImpl"; @@ -65,6 +66,8 @@ export function createCursorlessEngine( talonSpokenForms, ); + const tutorial = new TutorialImpl(hatTokenMap, customSpokenFormGenerator); + ide.disposeOnExit(rangeUpdater, languageDefinitions, hatTokenMap, debug); const commandRunnerDecorators: CommandRunnerDecorator[] = []; @@ -116,6 +119,7 @@ export function createCursorlessEngine( addCommandRunnerDecorator: (decorator: CommandRunnerDecorator) => { commandRunnerDecorators.push(decorator); }, + tutorial, }; } diff --git a/packages/cursorless-engine/src/index.ts b/packages/cursorless-engine/src/index.ts index 9087d6a60c..bed23ab54e 100644 --- a/packages/cursorless-engine/src/index.ts +++ b/packages/cursorless-engine/src/index.ts @@ -1,5 +1,6 @@ export * from "./testUtil/plainObjectToTarget"; export * from "./core/Cheatsheet"; +export * from "./api/Tutorial"; export * from "./testUtil/takeSnapshot"; export * from "./testCaseRecorder/TestCaseRecorder"; export * from "./core/StoredTargets"; diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/extra-cloning-a-talon-list/bringBlockMade.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/extra-cloning-a-talon-list/bringBlockMade.yml new file mode 100644 index 0000000000..212981bacf --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/extra-cloning-a-talon-list/bringBlockMade.yml @@ -0,0 +1,52 @@ +languageId: python +command: + version: 6 + spokenForm: bring block made + action: + name: replaceWithTarget + source: + type: primitive + modifiers: + - type: containingScope + scopeType: {type: paragraph} + mark: {type: decoratedSymbol, symbolColor: default, character: m} + destination: {type: implicit} + usePrePhraseSnapshot: false +initialState: + documentContents: |+ + from talon import Context, Module + + mod = Module() + ctx = Context() + + mod.list("cursorless_walkthrough_list", desc="My tutorial list") + ctx.list['user.cursorless_walkthrough_list'] = { + "spoken form": "whatever", + } + + selections: + - anchor: {line: 10, character: 0} + active: {line: 10, character: 0} + marks: + default.m: + start: {line: 5, character: 0} + end: {line: 5, character: 3} +finalState: + documentContents: |- + from talon import Context, Module + + mod = Module() + ctx = Context() + + mod.list("cursorless_walkthrough_list", desc="My tutorial list") + ctx.list['user.cursorless_walkthrough_list'] = { + "spoken form": "whatever", + } + + mod.list("cursorless_walkthrough_list", desc="My tutorial list") + ctx.list['user.cursorless_walkthrough_list'] = { + "spoken form": "whatever", + } + selections: + - anchor: {line: 13, character: 1} + active: {line: 13, character: 1} diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/extra-cloning-a-talon-list/clearCoreSun.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/extra-cloning-a-talon-list/clearCoreSun.yml new file mode 100644 index 0000000000..8a52a4f680 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/extra-cloning-a-talon-list/clearCoreSun.yml @@ -0,0 +1,56 @@ +languageId: python +command: + version: 6 + spokenForm: change inside pair sun + action: + name: clearAndSetSelection + target: + type: primitive + modifiers: + - {type: interiorOnly} + - type: containingScope + scopeType: {type: surroundingPair, delimiter: any} + mark: {type: decoratedSymbol, symbolColor: default, character: s} + usePrePhraseSnapshot: false +initialState: + documentContents: |- + from talon import Context, Module + + mod = Module() + ctx = Context() + + mod.list("cursorless_walkthrough_list", desc="My tutorial list") + ctx.list['user.cursorless_walkthrough_list'] = { + "spoken form": "whatever", + } + + mod.list("emoji", desc="Emojis") + ctx.list['user.emoji'] = { + "spoken form": "whatever", + } + selections: + - anchor: {line: 10, character: 30} + active: {line: 10, character: 30} + marks: + default.s: + start: {line: 12, character: 5} + end: {line: 12, character: 11} +finalState: + documentContents: |- + from talon import Context, Module + + mod = Module() + ctx = Context() + + mod.list("cursorless_walkthrough_list", desc="My tutorial list") + ctx.list['user.cursorless_walkthrough_list'] = { + "spoken form": "whatever", + } + + mod.list("emoji", desc="Emojis") + ctx.list['user.emoji'] = { + "": "whatever", + } + selections: + - anchor: {line: 12, character: 5} + active: {line: 12, character: 5} diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/extra-cloning-a-talon-list/clearCoreYank.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/extra-cloning-a-talon-list/clearCoreYank.yml new file mode 100644 index 0000000000..3e2eb34a2f --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/extra-cloning-a-talon-list/clearCoreYank.yml @@ -0,0 +1,58 @@ +languageId: python +command: + version: 6 + spokenForm: change inside pair yank + action: + name: clearAndSetSelection + target: + type: primitive + modifiers: + - {type: interiorOnly} + - type: containingScope + scopeType: {type: surroundingPair, delimiter: any} + mark: {type: decoratedSymbol, symbolColor: default, character: 'y'} + usePrePhraseSnapshot: false +initialState: + documentContents: |- + from talon import Context, Module + + mod = Module() + ctx = Context() + + mod.list("cursorless_walkthrough_list", desc="My tutorial list") + ctx.list['user.cursorless_walkthrough_list'] = { + "spoken form": "whatever", + } + + mod.list("emoji", desc="My tutorial list") + ctx.list['user.emoji'] = { + "spoken form": "whatever", + } + selections: + - anchor: {line: 10, character: 15} + active: {line: 10, character: 15} + - anchor: {line: 11, character: 20} + active: {line: 11, character: 20} + marks: + default.y: + start: {line: 10, character: 24} + end: {line: 10, character: 26} +finalState: + documentContents: |- + from talon import Context, Module + + mod = Module() + ctx = Context() + + mod.list("cursorless_walkthrough_list", desc="My tutorial list") + ctx.list['user.cursorless_walkthrough_list'] = { + "spoken form": "whatever", + } + + mod.list("emoji", desc="") + ctx.list['user.emoji'] = { + "spoken form": "whatever", + } + selections: + - anchor: {line: 10, character: 24} + active: {line: 10, character: 24} diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/extra-cloning-a-talon-list/clearDownScoreAndCap.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/extra-cloning-a-talon-list/clearDownScoreAndCap.yml new file mode 100644 index 0000000000..4285f52d7a --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/extra-cloning-a-talon-list/clearDownScoreAndCap.yml @@ -0,0 +1,61 @@ +languageId: python +command: + version: 6 + spokenForm: change underscore and cap + action: + name: clearAndSetSelection + target: + type: list + elements: + - type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: _} + - type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: c} + usePrePhraseSnapshot: false +initialState: + documentContents: |- + from talon import Context, Module + + mod = Module() + ctx = Context() + + mod.list("cursorless_walkthrough_list", desc="My tutorial list") + ctx.list['user.cursorless_walkthrough_list'] = { + "spoken form": "whatever", + } + + mod.list("cursorless_walkthrough_list", desc="My tutorial list") + ctx.list['user.cursorless_walkthrough_list'] = { + "spoken form": "whatever", + } + selections: + - anchor: {line: 13, character: 1} + active: {line: 13, character: 1} + marks: + default._: + start: {line: 10, character: 10} + end: {line: 10, character: 37} + default.c: + start: {line: 11, character: 15} + end: {line: 11, character: 42} +finalState: + documentContents: |- + from talon import Context, Module + + mod = Module() + ctx = Context() + + mod.list("cursorless_walkthrough_list", desc="My tutorial list") + ctx.list['user.cursorless_walkthrough_list'] = { + "spoken form": "whatever", + } + + mod.list("", desc="My tutorial list") + ctx.list['user.'] = { + "spoken form": "whatever", + } + selections: + - anchor: {line: 10, character: 10} + active: {line: 10, character: 10} + - anchor: {line: 11, character: 15} + active: {line: 11, character: 15} diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/extra-cloning-a-talon-list/clearRepperLeper.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/extra-cloning-a-talon-list/clearRepperLeper.yml new file mode 100644 index 0000000000..2ac1b7da70 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/extra-cloning-a-talon-list/clearRepperLeper.yml @@ -0,0 +1,54 @@ +languageId: python +command: + version: 6 + spokenForm: change right paren + action: + name: clearAndSetSelection + target: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: )} + usePrePhraseSnapshot: false +initialState: + documentContents: |- + from talon import Context, Module + + mod = Module() + ctx = Context() + + mod.list("cursorless_walkthrough_list", desc="My tutorial list") + ctx.list['user.cursorless_walkthrough_list'] = { + "spoken form": "whatever", + } + + mod.list("emoji", desc="Emojis") + ctx.list['user.emoji'] = { + "smile": ":-)", + "frown": ":-)", + } + selections: + - anchor: {line: 13, character: 10} + active: {line: 13, character: 10} + marks: + default.): + start: {line: 13, character: 16} + end: {line: 13, character: 17} +finalState: + documentContents: |- + from talon import Context, Module + + mod = Module() + ctx = Context() + + mod.list("cursorless_walkthrough_list", desc="My tutorial list") + ctx.list['user.cursorless_walkthrough_list'] = { + "spoken form": "whatever", + } + + mod.list("emoji", desc="Emojis") + ctx.list['user.emoji'] = { + "smile": ":-)", + "frown": ":-", + } + selections: + - anchor: {line: 13, character: 16} + active: {line: 13, character: 16} diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/extra-cloning-a-talon-list/clearWhale.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/extra-cloning-a-talon-list/clearWhale.yml new file mode 100644 index 0000000000..48a9e9ce5b --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/extra-cloning-a-talon-list/clearWhale.yml @@ -0,0 +1,52 @@ +languageId: python +command: + version: 6 + spokenForm: change whale + action: + name: clearAndSetSelection + target: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: w} + usePrePhraseSnapshot: false +initialState: + documentContents: |- + from talon import Context, Module + + mod = Module() + ctx = Context() + + mod.list("cursorless_walkthrough_list", desc="My tutorial list") + ctx.list['user.cursorless_walkthrough_list'] = { + "spoken form": "whatever", + } + + mod.list("emoji", desc="Emojis") + ctx.list['user.emoji'] = { + "smile": "whatever", + } + selections: + - anchor: {line: 12, character: 10} + active: {line: 12, character: 10} + marks: + default.w: + start: {line: 12, character: 14} + end: {line: 12, character: 22} +finalState: + documentContents: |- + from talon import Context, Module + + mod = Module() + ctx = Context() + + mod.list("cursorless_walkthrough_list", desc="My tutorial list") + ctx.list['user.cursorless_walkthrough_list'] = { + "spoken form": "whatever", + } + + mod.list("emoji", desc="Emojis") + ctx.list['user.emoji'] = { + "smile": "", + } + selections: + - anchor: {line: 12, character: 14} + active: {line: 12, character: 14} diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/extra-cloning-a-talon-list/takeLookCloneLineWordFrown.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/extra-cloning-a-talon-list/takeLookCloneLineWordFrown.yml new file mode 100644 index 0000000000..8afbba4b72 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/extra-cloning-a-talon-list/takeLookCloneLineWordFrown.yml @@ -0,0 +1,52 @@ +languageId: python +command: + version: 6 + spokenForm: take look + action: + name: setSelection + target: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: l} + usePrePhraseSnapshot: false +initialState: + documentContents: |- + from talon import Context, Module + + mod = Module() + ctx = Context() + + mod.list("cursorless_walkthrough_list", desc="My tutorial list") + ctx.list['user.cursorless_walkthrough_list'] = { + "spoken form": "whatever", + } + + mod.list("emoji", desc="Emojis") + ctx.list['user.emoji'] = { + "smile": ":-)", + } + selections: + - anchor: {line: 12, character: 17} + active: {line: 12, character: 17} + marks: + default.l: + start: {line: 12, character: 5} + end: {line: 12, character: 10} +finalState: + documentContents: |- + from talon import Context, Module + + mod = Module() + ctx = Context() + + mod.list("cursorless_walkthrough_list", desc="My tutorial list") + ctx.list['user.cursorless_walkthrough_list'] = { + "spoken form": "whatever", + } + + mod.list("emoji", desc="Emojis") + ctx.list['user.emoji'] = { + "smile": ":-)", + } + selections: + - anchor: {line: 12, character: 5} + active: {line: 12, character: 10} diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/extra-cloning-a-talon-list/takeLookCloneLineWordFrown2.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/extra-cloning-a-talon-list/takeLookCloneLineWordFrown2.yml new file mode 100644 index 0000000000..44706fe1ae --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/extra-cloning-a-talon-list/takeLookCloneLineWordFrown2.yml @@ -0,0 +1,52 @@ +languageId: python +command: + version: 6 + spokenForm: clone line + action: + name: insertCopyAfter + target: + type: primitive + modifiers: + - type: containingScope + scopeType: {type: line} + usePrePhraseSnapshot: false +initialState: + documentContents: |- + from talon import Context, Module + + mod = Module() + ctx = Context() + + mod.list("cursorless_walkthrough_list", desc="My tutorial list") + ctx.list['user.cursorless_walkthrough_list'] = { + "spoken form": "whatever", + } + + mod.list("emoji", desc="Emojis") + ctx.list['user.emoji'] = { + "smile": ":-)", + } + selections: + - anchor: {line: 12, character: 5} + active: {line: 12, character: 10} + marks: {} +finalState: + documentContents: |- + from talon import Context, Module + + mod = Module() + ctx = Context() + + mod.list("cursorless_walkthrough_list", desc="My tutorial list") + ctx.list['user.cursorless_walkthrough_list'] = { + "spoken form": "whatever", + } + + mod.list("emoji", desc="Emojis") + ctx.list['user.emoji'] = { + "smile": ":-)", + "smile": ":-)", + } + selections: + - anchor: {line: 13, character: 5} + active: {line: 13, character: 10} diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-1-basics/chuckDrum.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-1-basics/chuckDrum.yml new file mode 100644 index 0000000000..e3b02d98f4 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-1-basics/chuckDrum.yml @@ -0,0 +1,50 @@ +languageId: plaintext +command: + version: 6 + spokenForm: chuck drum + action: + name: remove + target: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: d} + usePrePhraseSnapshot: false +initialState: + documentContents: |- + ================================================== + ========== ========== + ========== Welcome to Cursorless! ========== + ========== ========== + ========== Let's start using marks ========== + ========== ========== + ========== so we can navigate around ========== + ========== ========== + ========== without lifting a finger! ========== + ========== ========== + ================================================== + selections: + - anchor: {line: 2, character: 14} + active: {line: 2, character: 21} + - anchor: {line: 6, character: 15} + active: {line: 6, character: 17} + marks: + default.d: + start: {line: 6, character: 31} + end: {line: 6, character: 37} +finalState: + documentContents: |- + ================================================== + ========== ========== + ========== Welcome to Cursorless! ========== + ========== ========== + ========== Let's start using marks ========== + ========== ========== + ========== so we can navigate ========== + ========== ========== + ========== without lifting a finger! ========== + ========== ========== + ================================================== + selections: + - anchor: {line: 2, character: 14} + active: {line: 2, character: 21} + - anchor: {line: 6, character: 15} + active: {line: 6, character: 17} diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-1-basics/chuckLineLook.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-1-basics/chuckLineLook.yml new file mode 100644 index 0000000000..3cbbb5d3e5 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-1-basics/chuckLineLook.yml @@ -0,0 +1,52 @@ +languageId: plaintext +command: + version: 6 + spokenForm: chuck line look + action: + name: remove + target: + type: primitive + modifiers: + - type: containingScope + scopeType: {type: line} + mark: {type: decoratedSymbol, symbolColor: default, character: l} + usePrePhraseSnapshot: false +initialState: + documentContents: |- + ================================================== + ========== ========== + ========== Welcome to Cursorless! ========== + ========== ========== + ========== Let's start using marks ========== + ========== ========== + ========== so we can navigate ========== + ========== ========== + ========== without lifting a finger! ========== + ========== ========== + ================================================== + selections: + - anchor: {line: 2, character: 14} + active: {line: 2, character: 21} + - anchor: {line: 6, character: 15} + active: {line: 6, character: 17} + marks: + default.l: + start: {line: 4, character: 13} + end: {line: 4, character: 16} +finalState: + documentContents: |- + ================================================== + ========== ========== + ========== Welcome to Cursorless! ========== + ========== ========== + ========== ========== + ========== so we can navigate ========== + ========== ========== + ========== without lifting a finger! ========== + ========== ========== + ================================================== + selections: + - anchor: {line: 2, character: 14} + active: {line: 2, character: 21} + - anchor: {line: 5, character: 15} + active: {line: 5, character: 17} diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-1-basics/clearTrap.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-1-basics/clearTrap.yml new file mode 100644 index 0000000000..d4ebd4430f --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-1-basics/clearTrap.yml @@ -0,0 +1,44 @@ +languageId: plaintext +command: + version: 6 + spokenForm: change trap + action: + name: clearAndSetSelection + target: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: t} + usePrePhraseSnapshot: false +initialState: + documentContents: |- + ================================================== + ========== ========== + ========== Welcome to Cursorless! ========== + ========== ========== + ========== ========== + ========== so we can navigate ========== + ========== ========== + ========== without lifting a finger! ========== + ========== ========== + ================================================== + selections: + - anchor: {line: 7, character: 27} + active: {line: 7, character: 27} + marks: + default.t: + start: {line: 2, character: 22} + end: {line: 2, character: 24} +finalState: + documentContents: |- + ================================================== + ========== ========== + ========== Welcome Cursorless! ========== + ========== ========== + ========== ========== + ========== so we can navigate ========== + ========== ========== + ========== without lifting a finger! ========== + ========== ========== + ================================================== + selections: + - anchor: {line: 2, character: 22} + active: {line: 2, character: 22} diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-1-basics/clearWhaleWordYou.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-1-basics/clearWhaleWordYou.yml new file mode 100644 index 0000000000..e195c11635 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-1-basics/clearWhaleWordYou.yml @@ -0,0 +1,44 @@ +languageId: plaintext +command: + version: 6 + spokenForm: change whale + action: + name: clearAndSetSelection + target: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: w} + usePrePhraseSnapshot: false +initialState: + documentContents: |- + ================================================== + ========== ========== + ========== Welcome Cursorless! ========== + ========== ========== + ========== ========== + ========== so we can navigate ========== + ========== ========== + ========== without lifting a finger! ========== + ========== ========== + ================================================== + selections: + - anchor: {line: 2, character: 22} + active: {line: 2, character: 22} + marks: + default.w: + start: {line: 5, character: 15} + end: {line: 5, character: 17} +finalState: + documentContents: |- + ================================================== + ========== ========== + ========== Welcome Cursorless! ========== + ========== ========== + ========== ========== + ========== so can navigate ========== + ========== ========== + ========== without lifting a finger! ========== + ========== ========== + ================================================== + selections: + - anchor: {line: 5, character: 15} + active: {line: 5, character: 15} diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-1-basics/postLook.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-1-basics/postLook.yml new file mode 100644 index 0000000000..5e8f0c8355 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-1-basics/postLook.yml @@ -0,0 +1,44 @@ +languageId: plaintext +command: + version: 6 + spokenForm: post look + action: + name: setSelectionAfter + target: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: l} + usePrePhraseSnapshot: false +initialState: + documentContents: |- + ================================================== + ========== ========== + ========== Welcome to Cursorless! ========== + ========== ========== + ========== ========== + ========== so we can navigate ========== + ========== ========== + ========== without lifting a finger! ========== + ========== ========== + ================================================== + selections: + - anchor: {line: 7, character: 12} + active: {line: 7, character: 12} + marks: + default.l: + start: {line: 7, character: 20} + end: {line: 7, character: 27} +finalState: + documentContents: |- + ================================================== + ========== ========== + ========== Welcome to Cursorless! ========== + ========== ========== + ========== ========== + ========== so we can navigate ========== + ========== ========== + ========== without lifting a finger! ========== + ========== ========== + ================================================== + selections: + - anchor: {line: 7, character: 27} + active: {line: 7, character: 27} diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-1-basics/preInk.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-1-basics/preInk.yml new file mode 100644 index 0000000000..4e93164b93 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-1-basics/preInk.yml @@ -0,0 +1,46 @@ +languageId: plaintext +command: + version: 6 + spokenForm: pre sit + action: + name: setSelectionBefore + target: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: i} + usePrePhraseSnapshot: false +initialState: + documentContents: |- + ================================================== + ========== ========== + ========== Welcome to Cursorless! ========== + ========== ========== + ========== ========== + ========== so we can navigate ========== + ========== ========== + ========== without lifting a finger! ========== + ========== ========== + ================================================== + selections: + - anchor: {line: 2, character: 0} + active: {line: 2, character: 50} + - anchor: {line: 5, character: 0} + active: {line: 5, character: 41} + marks: + default.i: + start: {line: 7, character: 12} + end: {line: 7, character: 19} +finalState: + documentContents: |- + ================================================== + ========== ========== + ========== Welcome to Cursorless! ========== + ========== ========== + ========== ========== + ========== so we can navigate ========== + ========== ========== + ========== without lifting a finger! ========== + ========== ========== + ================================================== + selections: + - anchor: {line: 7, character: 12} + active: {line: 7, character: 12} diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-1-basics/script.json b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-1-basics/script.json new file mode 100644 index 0000000000..c2260aee1b --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-1-basics/script.json @@ -0,0 +1,17 @@ +{ + "title": "Introduction", + "steps": [ + "Every cursorless command consists of an action performed on a target. For example, the command {step:takeWhale.yml} selects the token with a grey hat over the 'w'.", + "When a hat is not gray, we need to use a color to refer to it: {step:takeBlueSun.yml}", + "Selecting a single token is great but oftentimes we need something bigger. Say {step:takeEachPastKick.yml} to select a range.", + "Despite its name, one of the most powerful aspects of cursorless is the ability to use more than one cursor. Let's try that: {step:takeCapAndWhale.yml}", + "But let's show that cursorless can live up to its name: we can say {step:chuckDrum.yml} to delete a word without ever moving our cursor.", + "Tokens are great, but they're just one way to think of a document. Let's try working with lines: {step:chuckLineLook.yml}", + "We can also use {scopeType:line} to refer to the line containing our cursor: {step:takeLine.yml}", + "You now know how to select and delete; let's give you a couple more actions to play with: say {action:setSelectionBefore} to place the cursor before a target, as in {step:preInk.yml}", + "Say {action:setSelectionAfter} to place the cursor after a target: {step:postLook.yml}", + "Say {action:clearAndSetSelection} to delete a word and move your cursor to where it used to be: {step:clearTrap.yml}", + "Chaining commands is a great way to code faster: {step:clearWhaleWordYou.yml+wordYou}", + "And that wraps up unit 1 of the cursorless tutorial! Next time, we'll write some code ☺️" + ] +} diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-1-basics/takeBlueSun.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-1-basics/takeBlueSun.yml new file mode 100644 index 0000000000..04689305df --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-1-basics/takeBlueSun.yml @@ -0,0 +1,46 @@ +languageId: plaintext +command: + version: 6 + spokenForm: take blue sun + action: + name: setSelection + target: + type: primitive + mark: {type: decoratedSymbol, symbolColor: blue, character: s} + usePrePhraseSnapshot: false +initialState: + documentContents: |- + ================================================== + ========== ========== + ========== Welcome to Cursorless! ========== + ========== ========== + ========== Let's start using marks ========== + ========== ========== + ========== so we can navigate around ========== + ========== ========== + ========== without lifting a finger! ========== + ========== ========== + ================================================== + selections: + - anchor: {line: 2, character: 14} + active: {line: 2, character: 21} + marks: + blue.s: + start: {line: 6, character: 12} + end: {line: 6, character: 14} +finalState: + documentContents: |- + ================================================== + ========== ========== + ========== Welcome to Cursorless! ========== + ========== ========== + ========== Let's start using marks ========== + ========== ========== + ========== so we can navigate around ========== + ========== ========== + ========== without lifting a finger! ========== + ========== ========== + ================================================== + selections: + - anchor: {line: 6, character: 12} + active: {line: 6, character: 14} diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-1-basics/takeCapAndWhale.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-1-basics/takeCapAndWhale.yml new file mode 100644 index 0000000000..87307a9be7 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-1-basics/takeCapAndWhale.yml @@ -0,0 +1,55 @@ +languageId: plaintext +command: + version: 6 + spokenForm: take cap and whale + action: + name: setSelection + target: + type: list + elements: + - type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: c} + - type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: w} + usePrePhraseSnapshot: false +initialState: + documentContents: |- + ================================================== + ========== ========== + ========== Welcome to Cursorless! ========== + ========== ========== + ========== Let's start using marks ========== + ========== ========== + ========== so we can navigate around ========== + ========== ========== + ========== without lifting a finger! ========== + ========== ========== + ================================================== + selections: + - anchor: {line: 4, character: 13} + active: {line: 4, character: 36} + marks: + default.c: + start: {line: 2, character: 14} + end: {line: 2, character: 21} + default.w: + start: {line: 6, character: 15} + end: {line: 6, character: 17} +finalState: + documentContents: |- + ================================================== + ========== ========== + ========== Welcome to Cursorless! ========== + ========== ========== + ========== Let's start using marks ========== + ========== ========== + ========== so we can navigate around ========== + ========== ========== + ========== without lifting a finger! ========== + ========== ========== + ================================================== + selections: + - anchor: {line: 2, character: 14} + active: {line: 2, character: 21} + - anchor: {line: 6, character: 15} + active: {line: 6, character: 17} diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-1-basics/takeEachPastKick.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-1-basics/takeEachPastKick.yml new file mode 100644 index 0000000000..455ba3923a --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-1-basics/takeEachPastKick.yml @@ -0,0 +1,56 @@ +languageId: plaintext +command: + version: 6 + spokenForm: take each past crunch + action: + name: setSelection + target: + type: range + anchor: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: e} + active: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: k} + excludeAnchor: false + excludeActive: false + usePrePhraseSnapshot: false +initialState: + documentContents: |- + ================================================== + ========== ========== + ========== Welcome to Cursorless! ========== + ========== ========== + ========== Let's start using marks ========== + ========== ========== + ========== so we can navigate around ========== + ========== ========== + ========== without lifting a finger! ========== + ========== ========== + ================================================== + selections: + - anchor: {line: 6, character: 12} + active: {line: 6, character: 14} + marks: + default.e: + start: {line: 4, character: 13} + end: {line: 4, character: 16} + default.k: + start: {line: 4, character: 31} + end: {line: 4, character: 36} +finalState: + documentContents: |- + ================================================== + ========== ========== + ========== Welcome to Cursorless! ========== + ========== ========== + ========== Let's start using marks ========== + ========== ========== + ========== so we can navigate around ========== + ========== ========== + ========== without lifting a finger! ========== + ========== ========== + ================================================== + selections: + - anchor: {line: 4, character: 13} + active: {line: 4, character: 36} diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-1-basics/takeLine.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-1-basics/takeLine.yml new file mode 100644 index 0000000000..65e11df143 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-1-basics/takeLine.yml @@ -0,0 +1,47 @@ +languageId: plaintext +command: + version: 6 + spokenForm: take line + action: + name: setSelection + target: + type: primitive + modifiers: + - type: containingScope + scopeType: {type: line} + usePrePhraseSnapshot: false +initialState: + documentContents: |- + ================================================== + ========== ========== + ========== Welcome to Cursorless! ========== + ========== ========== + ========== ========== + ========== so we can navigate ========== + ========== ========== + ========== without lifting a finger! ========== + ========== ========== + ================================================== + selections: + - anchor: {line: 2, character: 14} + active: {line: 2, character: 21} + - anchor: {line: 5, character: 15} + active: {line: 5, character: 17} + marks: {} +finalState: + documentContents: |- + ================================================== + ========== ========== + ========== Welcome to Cursorless! ========== + ========== ========== + ========== ========== + ========== so we can navigate ========== + ========== ========== + ========== without lifting a finger! ========== + ========== ========== + ================================================== + selections: + - anchor: {line: 2, character: 0} + active: {line: 2, character: 50} + - anchor: {line: 5, character: 0} + active: {line: 5, character: 41} diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-1-basics/takeWhale.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-1-basics/takeWhale.yml new file mode 100644 index 0000000000..37cdf6c209 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-1-basics/takeWhale.yml @@ -0,0 +1,46 @@ +languageId: plaintext +command: + version: 6 + spokenForm: take whale + action: + name: setSelection + target: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: w} + usePrePhraseSnapshot: false +initialState: + documentContents: |- + ================================================== + ========== ========== + ========== Welcome to Cursorless! ========== + ========== ========== + ========== Let's start using marks ========== + ========== ========== + ========== so we can navigate around ========== + ========== ========== + ========== without lifting a finger! ========== + ========== ========== + ================================================== + selections: + - anchor: {line: 0, character: 0} + active: {line: 0, character: 0} + marks: + default.w: + start: {line: 2, character: 14} + end: {line: 2, character: 21} +finalState: + documentContents: |- + ================================================== + ========== ========== + ========== Welcome to Cursorless! ========== + ========== ========== + ========== Let's start using marks ========== + ========== ========== + ========== so we can navigate around ========== + ========== ========== + ========== without lifting a finger! ========== + ========== ========== + ================================================== + selections: + - anchor: {line: 2, character: 14} + active: {line: 2, character: 21} diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-2-basic-coding/bringBlueCapToValueRisk.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-2-basic-coding/bringBlueCapToValueRisk.yml new file mode 100644 index 0000000000..f91b7b3d8b --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-2-basic-coding/bringBlueCapToValueRisk.yml @@ -0,0 +1,68 @@ +languageId: python +command: + version: 6 + spokenForm: bring blue cap to value red + action: + name: replaceWithTarget + source: + type: primitive + mark: {type: decoratedSymbol, symbolColor: blue, character: c} + destination: + type: primitive + insertionMode: to + target: + type: primitive + modifiers: + - type: containingScope + scopeType: {type: value} + mark: {type: decoratedSymbol, symbolColor: default, character: r} + usePrePhraseSnapshot: false +initialState: + documentContents: | + def print_color(color, invert=False): + if invert: + print(invert_color(color)) + else: + print(color) + + + def invert_color(color): + if color == "black": + return "white" + if color == "white": + return "black" + return "black" + + + print_color("black") + selections: + - anchor: {line: 12, character: 18} + active: {line: 12, character: 18} + marks: + blue.c: + start: {line: 7, character: 17} + end: {line: 7, character: 22} + default.r: + start: {line: 12, character: 4} + end: {line: 12, character: 10} +finalState: + documentContents: | + def print_color(color, invert=False): + if invert: + print(invert_color(color)) + else: + print(color) + + + def invert_color(color): + if color == "black": + return "white" + if color == "white": + return "black" + return color + + + print_color("black") + selections: + - anchor: {line: 12, character: 16} + active: {line: 12, character: 16} diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-2-basic-coding/bringStateUrge.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-2-basic-coding/bringStateUrge.yml new file mode 100644 index 0000000000..8e7da2f51b --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-2-basic-coding/bringStateUrge.yml @@ -0,0 +1,60 @@ +languageId: python +command: + version: 6 + spokenForm: bring state urge + action: + name: replaceWithTarget + source: + type: primitive + modifiers: + - type: containingScope + scopeType: {type: statement} + mark: {type: decoratedSymbol, symbolColor: default, character: u} + destination: {type: implicit} + usePrePhraseSnapshot: false +initialState: + documentContents: | + def print_color(color, invert=False): + if invert: + print(invert_color(color)) + else: + print(color) + + + def invert_color(color): + if color == "black": + return "white" + if color == "white": + return "black" + + + + print_color("black") + selections: + - anchor: {line: 12, character: 4} + active: {line: 12, character: 4} + marks: + default.u: + start: {line: 11, character: 8} + end: {line: 11, character: 14} +finalState: + documentContents: | + def print_color(color, invert=False): + if invert: + print(invert_color(color)) + else: + print(color) + + + def invert_color(color): + if color == "black": + return "white" + if color == "white": + return "black" + return "black" + + + print_color("black") + selections: + - anchor: {line: 12, character: 18} + active: {line: 12, character: 18} diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-2-basic-coding/chuckArgueBlueVest.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-2-basic-coding/chuckArgueBlueVest.yml new file mode 100644 index 0000000000..de28729d20 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-2-basic-coding/chuckArgueBlueVest.yml @@ -0,0 +1,59 @@ +languageId: python +command: + version: 6 + spokenForm: chuck arg blue vest + action: + name: remove + target: + type: primitive + modifiers: + - type: containingScope + scopeType: {type: argumentOrParameter} + mark: {type: decoratedSymbol, symbolColor: blue, character: v} + usePrePhraseSnapshot: false +initialState: + documentContents: | + def print_color(color, invert=False): + if invert: + print(invert_color(color)) + else: + print(color) + + + def invert_color(color): + if color == "black": + return "white" + if color == "white": + return "black" + return color + + + print_color("black") + selections: + - anchor: {line: 12, character: 16} + active: {line: 12, character: 16} + marks: + blue.v: + start: {line: 0, character: 23} + end: {line: 0, character: 29} +finalState: + documentContents: | + def print_color(color): + if invert: + print(invert_color(color)) + else: + print(color) + + + def invert_color(color): + if color == "black": + return "white" + if color == "white": + return "black" + return color + + + print_color("black") + selections: + - anchor: {line: 12, character: 16} + active: {line: 12, character: 16} diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-2-basic-coding/cloneStateInk.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-2-basic-coding/cloneStateInk.yml new file mode 100644 index 0000000000..f3bd735023 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-2-basic-coding/cloneStateInk.yml @@ -0,0 +1,55 @@ +languageId: python +command: + version: 6 + spokenForm: clone state sit + action: + name: insertCopyAfter + target: + type: primitive + modifiers: + - type: containingScope + scopeType: {type: statement} + mark: {type: decoratedSymbol, symbolColor: default, character: i} + usePrePhraseSnapshot: false +initialState: + documentContents: | + def print_color(color, invert=False): + if invert: + print(invert_color(color)) + else: + print(color) + + + def invert_color(color): + if color == "black": + return "white" + + + print_color("black") + selections: + - anchor: {line: 13, character: 0} + active: {line: 13, character: 0} + marks: + default.i: + start: {line: 8, character: 4} + end: {line: 8, character: 6} +finalState: + documentContents: | + def print_color(color, invert=False): + if invert: + print(invert_color(color)) + else: + print(color) + + + def invert_color(color): + if color == "black": + return "white" + if color == "black": + return "white" + + + print_color("black") + selections: + - anchor: {line: 15, character: 0} + active: {line: 15, character: 0} diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-2-basic-coding/dedentThis.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-2-basic-coding/dedentThis.yml new file mode 100644 index 0000000000..5d7482ce83 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-2-basic-coding/dedentThis.yml @@ -0,0 +1,53 @@ +languageId: python +command: + version: 6 + spokenForm: dedent this + action: + name: outdentLine + target: + type: primitive + mark: {type: cursor} + usePrePhraseSnapshot: false +initialState: + documentContents: | + def print_color(color, invert=False): + if invert: + print(invert_color(color)) + else: + print(color) + + + def invert_color(color): + if color == "black": + return "white" + if color == "white": + return "black" + + + + print_color("black") + selections: + - anchor: {line: 12, character: 8} + active: {line: 12, character: 8} + marks: {} +finalState: + documentContents: | + def print_color(color, invert=False): + if invert: + print(invert_color(color)) + else: + print(color) + + + def invert_color(color): + if color == "black": + return "white" + if color == "white": + return "black" + + + + print_color("black") + selections: + - anchor: {line: 12, character: 4} + active: {line: 12, character: 4} diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-2-basic-coding/pourUrge.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-2-basic-coding/pourUrge.yml new file mode 100644 index 0000000000..9cb6944012 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-2-basic-coding/pourUrge.yml @@ -0,0 +1,55 @@ +languageId: python +command: + version: 6 + spokenForm: pour urge + action: + name: editNewLineAfter + target: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: u} + usePrePhraseSnapshot: false +initialState: + documentContents: | + def print_color(color, invert=False): + if invert: + print(invert_color(color)) + else: + print(color) + + + def invert_color(color): + if color == "black": + return "white" + if color == "white": + return "black" + + + print_color("black") + selections: + - anchor: {line: 15, character: 0} + active: {line: 15, character: 0} + marks: + default.u: + start: {line: 11, character: 8} + end: {line: 11, character: 14} +finalState: + documentContents: | + def print_color(color, invert=False): + if invert: + print(invert_color(color)) + else: + print(color) + + + def invert_color(color): + if color == "black": + return "white" + if color == "white": + return "black" + + + + print_color("black") + selections: + - anchor: {line: 12, character: 8} + active: {line: 12, character: 8} diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-2-basic-coding/script.json b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-2-basic-coding/script.json new file mode 100644 index 0000000000..b50194bde6 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-2-basic-coding/script.json @@ -0,0 +1,15 @@ +{ + "title": "Basic coding", + "steps": [ + "When editing code, we often think in terms of statements, functions, etc. Let's clone a statement: %%step:cloneStateInk.yml%%", + "%%scopeType:{type: statement}%% is one of many scopes supported by cursorless. To see all available scopes, use the command %%literalStep:cursorless help%%, and look at the Scopes section.", + "Cursorless tries its best to keep your commands short. In the following command, we just say %%scopeType:{type: surroundingPair, delimiter: string}%% once, but cursorless infers that both targets are strings: %%step:swapStringAirWithWhale.yml%%", + "Great. Let's learn a new action. The %%action:editNewLineAfter%% action lets you start editing a new line below any line on your screen: %%step:pourUrge.yml%%", + "Now let's try applying a cursorless action to the current line: %%step:dedentThis.yml%%", + "Code reuse is a fact of life as a programmer. Cursorless makes this easy with the %%action:replaceWithTarget%% command: %%step:bringStateUrge.yml%%", + "%%action:replaceWithTarget%% also works with two targets just like %%action:swapTargets%%: %%step:bringBlueCapToValueRisk.yml%%", + "Cursorless tries its best to use its knowledge of programming languages to leave you with syntactically valid code. Note how it cleans up the comma here: %%step:chuckArgueBlueVest.yml%%", + "We introduced a lot of different scopes today. If you're anything like us, you've already forgotten them all. The important thing to remember is that you can always say %%literalStep:cursorless help%% to see a list.", + "As always, feel free to stick around and play with this file to practice what you've just learned. Happy coding :)" + ] +} diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-2-basic-coding/swapStringAirWithWhale.yml b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-2-basic-coding/swapStringAirWithWhale.yml new file mode 100644 index 0000000000..3c07f87549 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial/unit-2-basic-coding/swapStringAirWithWhale.yml @@ -0,0 +1,63 @@ +languageId: python +command: + version: 6 + spokenForm: swap string air with whale + action: + name: swapTargets + target1: + type: primitive + modifiers: + - type: containingScope + scopeType: {type: surroundingPair, delimiter: string} + mark: {type: decoratedSymbol, symbolColor: default, character: a} + target2: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: w} + usePrePhraseSnapshot: false +initialState: + documentContents: | + def print_color(color, invert=False): + if invert: + print(invert_color(color)) + else: + print(color) + + + def invert_color(color): + if color == "black": + return "white" + if color == "black": + return "white" + + + print_color("black") + selections: + - anchor: {line: 15, character: 0} + active: {line: 15, character: 0} + marks: + default.a: + start: {line: 10, character: 17} + end: {line: 10, character: 22} + default.w: + start: {line: 11, character: 16} + end: {line: 11, character: 21} +finalState: + documentContents: | + def print_color(color, invert=False): + if invert: + print(invert_color(color)) + else: + print(color) + + + def invert_color(color): + if color == "black": + return "white" + if color == "white": + return "black" + + + print_color("black") + selections: + - anchor: {line: 15, character: 0} + active: {line: 15, character: 0} diff --git a/packages/cursorless-vscode-e2e/src/suite/recorded.vscode.test.ts b/packages/cursorless-vscode-e2e/src/suite/recorded.vscode.test.ts index 22258d170c..9403c72fd9 100644 --- a/packages/cursorless-vscode-e2e/src/suite/recorded.vscode.test.ts +++ b/packages/cursorless-vscode-e2e/src/suite/recorded.vscode.test.ts @@ -1,30 +1,28 @@ import { - asyncSafety, CommandResponse, DEFAULT_TEXT_EDITOR_OPTIONS_FOR_TEST, ExcludableSnapshotField, - extractTargetedMarks, Fallback, - getRecordedTestPaths, HatStability, - marksToPlainObject, - omitByDeep, - plainObjectToRange, PositionPlainObject, - rangeToPlainObject, ReadOnlyHatMap, SelectionPlainObject, SerializedMarks, + SpyIDE, + TestCaseFixtureLegacy, + asyncSafety, + clientSupportsFallback, + extractTargetedMarks, + getRecordedTestPaths, + marksToPlainObject, + omitByDeep, + rangeToPlainObject, serializeTestFixture, + serializedMarksToTokenHats, shouldUpdateFixtures, splitKey, - SpyIDE, spyIDERecordedValuesToPlainObject, storedTargetKeys, - TestCaseFixtureLegacy, - TextEditor, - TokenHat, - clientSupportsFallback, } from "@cursorless/common"; import { getCursorlessApi, @@ -112,7 +110,10 @@ async function runTest(file: string, spyIde: SpyIDE) { // Ensure that the expected hats are present await hatTokenMap.allocateHats( - getTokenHats(fixture.initialState.marks, spyIde.activeTextEditor!), + serializedMarksToTokenHats( + fixture.initialState.marks, + spyIde.activeTextEditor!, + ), ); const readableHatMap = await hatTokenMap.getReadableMap(usePrePhraseSnapshot); @@ -261,34 +262,3 @@ function checkMarks( assert.deepStrictEqual(rangeToPlainObject(currentToken.range), token); }); } - -function getTokenHats( - marks: SerializedMarks | undefined, - editor: TextEditor, -): TokenHat[] { - if (marks == null) { - return []; - } - - return Object.entries(marks).map(([key, token]) => { - const { hatStyle, character } = splitKey(key); - const range = plainObjectToRange(token); - - return { - hatStyle, - grapheme: character, - token: { - editor, - range, - offsets: { - start: editor.document.offsetAt(range.start), - end: editor.document.offsetAt(range.end), - }, - text: editor.document.getText(range), - }, - - // NB: We don't care about the hat range for this test - hatRange: range, - }; - }); -} diff --git a/packages/cursorless-vscode-tutorial-webview/package.json b/packages/cursorless-vscode-tutorial-webview/package.json new file mode 100644 index 0000000000..1c07173f0d --- /dev/null +++ b/packages/cursorless-vscode-tutorial-webview/package.json @@ -0,0 +1,35 @@ +{ + "name": "@cursorless/cursorless-vscode-tutorial-webview", + "version": "1.0.0", + "description": "Contains the VSCode webview frontend for the Cursorless tutorial", + "private": true, + "main": "./out/index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "compile:tsc": "tsc --build", + "compile:esbuild": "esbuild ./src/index.tsx --sourcemap --format=cjs --bundle --outfile=./out/index.js", + "compile:tailwind": "tailwindcss -i ./src/index.css -o ./out/index.css", + "compile": "pnpm compile:tsc && pnpm compile:esbuild --minify && pnpm compile:tailwind --minify", + "compile:dev": "pnpm compile:esbuild && pnpm compile:tailwind", + "watch:tsc": "pnpm compile:tsc --watch", + "watch:esbuild": "pnpm compile:esbuild --watch", + "watch:tailwind": "pnpm compile:tailwind --watch", + "watch": "pnpm run --filter @cursorless/cursorless-vscode-tutorial-webview --parallel '/^watch:.*/'", + "clean": "rm -rf ./out tsconfig.tsbuildinfo ./dist ./build" + }, + "keywords": [], + "author": "", + "license": "MIT", + "type": "module", + "devDependencies": { + "@types/react": "18.0.28", + "@types/react-dom": "18.0.11", + "@types/vscode-webview": "1.57.5", + "tailwindcss": "3.3.5" + }, + "dependencies": { + "@cursorless/common": "workspace:*", + "react": "^18.2.0", + "react-dom": "^18.2.0" + } +} diff --git a/packages/cursorless-vscode-tutorial-webview/src/App.tsx b/packages/cursorless-vscode-tutorial-webview/src/App.tsx new file mode 100644 index 0000000000..40de0f255d --- /dev/null +++ b/packages/cursorless-vscode-tutorial-webview/src/App.tsx @@ -0,0 +1,35 @@ +import { TutorialState } from "@cursorless/common"; +import { useEffect, useState, type FunctionComponent } from "react"; +import { WebviewApi } from "vscode-webview"; +import { TutorialStep } from "./TutorialStep"; + +interface Props { + vscode: WebviewApi; +} + +export const App: FunctionComponent = ({ vscode }) => { + const [state, setState] = useState(); + + useEffect(() => { + // Handle messages sent from the extension to the webview + window.addEventListener( + "message", + ({ data: newState }: { data: TutorialState }) => { + setState(newState); + }, + ); + + vscode.postMessage({ type: "getInitialState" }); + }, []); + + if (state == null) { + // Just show nothing while we're waiting for initial state + return <>; + } + + return state.type === "pickingTutorial" ? ( + Say "cursorless tutorial" + ) : ( + + ); +}; diff --git a/packages/cursorless-vscode-tutorial-webview/src/TutorialStep.tsx b/packages/cursorless-vscode-tutorial-webview/src/TutorialStep.tsx new file mode 100644 index 0000000000..5aec9e2e96 --- /dev/null +++ b/packages/cursorless-vscode-tutorial-webview/src/TutorialStep.tsx @@ -0,0 +1,20 @@ +import { ActiveTutorialState } from "@cursorless/common"; +import { type FunctionComponent } from "react"; + +interface TutorialStepProps { + state: ActiveTutorialState; +} + +export const TutorialStep: FunctionComponent = ({ + state, +}) => { + return ( +
+

+ {state.title} +

+ + {state.stepContent} +
+ ); +}; diff --git a/packages/cursorless-vscode-tutorial-webview/src/index.css b/packages/cursorless-vscode-tutorial-webview/src/index.css new file mode 100644 index 0000000000..b5c61c9567 --- /dev/null +++ b/packages/cursorless-vscode-tutorial-webview/src/index.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/packages/cursorless-vscode-tutorial-webview/src/index.tsx b/packages/cursorless-vscode-tutorial-webview/src/index.tsx new file mode 100644 index 0000000000..c52cfae5bc --- /dev/null +++ b/packages/cursorless-vscode-tutorial-webview/src/index.tsx @@ -0,0 +1,6 @@ +import { createRoot } from "react-dom/client"; +import { App } from "./App"; + +createRoot(document.getElementById("root")!).render( + , +); diff --git a/packages/cursorless-vscode-tutorial-webview/tailwind.config.js b/packages/cursorless-vscode-tutorial-webview/tailwind.config.js new file mode 100644 index 0000000000..2da9910980 --- /dev/null +++ b/packages/cursorless-vscode-tutorial-webview/tailwind.config.js @@ -0,0 +1,13 @@ +import { readFileSync } from "fs"; + +const references = JSON.parse( + readFileSync("tsconfig.json", "utf-8"), +).references.map((ref) => ref.path); + +export const content = [".", ...references].map( + (dir) => `${dir}/src/**/*!(*.stories|*.spec).{ts,tsx,html}`, +); +export const theme = { + extend: {}, +}; +export const plugins = []; diff --git a/packages/cursorless-vscode-tutorial-webview/tsconfig.json b/packages/cursorless-vscode-tutorial-webview/tsconfig.json new file mode 100644 index 0000000000..121770ed68 --- /dev/null +++ b/packages/cursorless-vscode-tutorial-webview/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "out", + "jsx": "react-jsx", + "lib": ["es2022", "dom"] + }, + "references": [ + { + "path": "../common" + } + ], + "include": [ + "src/**/*.ts", + "src/**/*.json", + "src/**/*.tsx", + "../../typings/**/*.d.ts" + ] +} diff --git a/packages/cursorless-vscode/package.json b/packages/cursorless-vscode/package.json index 8d04213065..60c0b6b553 100644 --- a/packages/cursorless-vscode/package.json +++ b/packages/cursorless-vscode/package.json @@ -71,6 +71,7 @@ "onCommand:cursorless.toggleDecorations", "onCommand:cursorless.showScopeVisualizer", "onCommand:cursorless.hideScopeVisualizer", + "onCommand:cursorless.tutorial.start", "onCommand:cursorless.analyzeCommandHistory" ], "main": "./extension.cjs", @@ -82,6 +83,11 @@ "contributes": { "views": { "cursorless": [ + { + "type": "webview", + "id": "cursorless.tutorial", + "name": "Tutorial" + }, { "id": "cursorless.scopes", "name": "Scopes" @@ -129,6 +135,11 @@ "command": "cursorless.analyzeCommandHistory", "title": "Cursorless: Analyze collected command history" }, + { + "command": "cursorless.tutorial.start", + "title": "Cursorless: Start a tutorial", + "enablement": "false" + }, { "command": "cursorless.command", "title": "Cursorless: The core cursorless command", @@ -1181,8 +1192,8 @@ }, "funding": "https://github.com/sponsors/pokey", "scripts": { - "build": "pnpm run esbuild:prod && pnpm -F cheatsheet-local build:prod && pnpm run populate-dist", - "build:dev": "pnpm generate-grammar && pnpm run esbuild && pnpm -F cheatsheet-local build && pnpm run populate-dist", + "build": "pnpm run esbuild:prod && pnpm -F cheatsheet-local build:prod && pnpm -F cursorless-vscode-tutorial-webview build:prod && pnpm run populate-dist", + "build:dev": "pnpm generate-grammar && pnpm run esbuild && pnpm -F cheatsheet-local build && pnpm -F cursorless-vscode-tutorial-webview build && pnpm run populate-dist", "esbuild:base": "esbuild ./src/extension.ts --conditions=cursorless:bundler --bundle --outfile=dist/extension.cjs --external:vscode --format=cjs --platform=node", "install-local": "bash ./scripts/install-local.sh", "install-from-pr": "bash ./scripts/install-from-pr.sh", diff --git a/packages/cursorless-vscode/src/VscodeTutorial.ts b/packages/cursorless-vscode/src/VscodeTutorial.ts new file mode 100644 index 0000000000..ca66e35bf3 --- /dev/null +++ b/packages/cursorless-vscode/src/VscodeTutorial.ts @@ -0,0 +1,142 @@ +import { TutorialId, TutorialState } from "@cursorless/common"; +import { Tutorial, TutorialContent } from "@cursorless/cursorless-engine"; +import { VscodeApi } from "@cursorless/vscode-common"; +import { + CancellationToken, + ExtensionContext, + Uri, + Webview, + WebviewView, + WebviewViewProvider, + WebviewViewResolveContext, + commands, +} from "vscode"; + +const VSCODE_TUTORIAL_WEBVIEW_ID = "cursorless.tutorial"; + +export class VscodeTutorial implements WebviewViewProvider { + private state: TutorialState = { type: "pickingTutorial" }; + private currentTutorial?: TutorialContent; + private view?: WebviewView; + + constructor( + private context: ExtensionContext, + vscodeApi: VscodeApi, + private tutorial: Tutorial, + ) { + context.subscriptions.push( + vscodeApi.window.registerWebviewViewProvider( + VSCODE_TUTORIAL_WEBVIEW_ID, + this, + ), + ); + this.start = this.start.bind(this); + } + + public resolveWebviewView( + webviewView: WebviewView, + _context: WebviewViewResolveContext, + _token: CancellationToken, + ) { + this.view = webviewView; + + webviewView.webview.options = { + // Allow scripts in the webview + enableScripts: true, + + localResourceRoots: [this.context.extensionUri], + }; + + webviewView.webview.html = this.getHtmlForWebview(webviewView.webview); + + webviewView.webview.onDidReceiveMessage((data) => { + switch (data.type) { + case "getInitialState": + this.view!.webview.postMessage(this.state); + break; + } + }); + } + + public async start(tutorialId: TutorialId) { + this.currentTutorial = await this.tutorial.getContent(tutorialId); + + this.setState({ + type: "doingTutorial", + tutorialId, + stepNumber: 0, + stepContent: this.currentTutorial.steps[0].content, + stepCount: this.currentTutorial.steps.length, + title: this.currentTutorial.title, + }); + + if (this.view != null) { + this.view.show(true); + } else { + await commands.executeCommand("cursorless.tutorial.focus"); + } + + await this.tutorial.setupStep({ + fixturePath: this.currentTutorial.steps[0].fixturePath!, + tutorialId: tutorialId, + }); + } + + private setState(state: TutorialState) { + this.state = state; + + if (this.view != null) { + this.view.webview.postMessage(state); + } + } + + private getHtmlForWebview(webview: Webview) { + // Get the local path to main script run in the webview, then convert it to a uri we can use in the webview. + const scriptUri = webview.asWebviewUri( + Uri.joinPath(this.context.extensionUri, "media", "tutorialWebview.js"), + ); + + // Do the same for the stylesheet. + const styleMainUri = webview.asWebviewUri( + Uri.joinPath(this.context.extensionUri, "media", "tutorialWebview.css"), + ); + + // Use a nonce to only allow a specific script to be run. + const nonce = getNonce(); + + return ` + + + + + + + + + + + + Cat Colors + + +
+ + + + `; + } +} + +function getNonce() { + let text = ""; + const possible = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + for (let i = 0; i < 32; i++) { + text += possible.charAt(Math.floor(Math.random() * possible.length)); + } + return text; +} diff --git a/packages/cursorless-vscode/src/extension.ts b/packages/cursorless-vscode/src/extension.ts index fcc7e2334d..cc33c415c7 100644 --- a/packages/cursorless-vscode/src/extension.ts +++ b/packages/cursorless-vscode/src/extension.ts @@ -50,6 +50,7 @@ import { import { StatusBarItem } from "./StatusBarItem"; import { storedTargetHighlighter } from "./storedTargetHighlighter"; import { vscodeApi } from "./vscodeApi"; +import { VscodeTutorial } from "./VscodeTutorial"; /** * Extension entrypoint called by VSCode on Cursorless startup. @@ -92,6 +93,7 @@ export async function activate( runIntegrationTests, addCommandRunnerDecorator, customSpokenFormGenerator, + tutorial, } = createCursorlessEngine( treeSitter, normalizedIde, @@ -133,6 +135,8 @@ export async function activate( context.subscriptions.push(storedTargetHighlighter(vscodeIDE, storedTargets)); + const vscodeTutorial = new VscodeTutorial(context, vscodeApi, tutorial); + registerCommands( context, vscodeIDE, @@ -142,6 +146,7 @@ export async function activate( scopeVisualizer, keyboardCommands, hats, + vscodeTutorial, ); new ReleaseNotes(vscodeApi, context, normalizedIde.messages).maybeShow(); diff --git a/packages/cursorless-vscode/src/registerCommands.ts b/packages/cursorless-vscode/src/registerCommands.ts index c070e296a6..6ff48e54ce 100644 --- a/packages/cursorless-vscode/src/registerCommands.ts +++ b/packages/cursorless-vscode/src/registerCommands.ts @@ -13,6 +13,7 @@ import { } from "@cursorless/cursorless-engine"; import * as vscode from "vscode"; import { ScopeVisualizer } from "./ScopeVisualizerCommandApi"; +import { VscodeTutorial } from "./VscodeTutorial"; import { showDocumentation, showQuickPick } from "./commands"; import { VscodeIDE } from "./ide/vscode/VscodeIDE"; import { VscodeHats } from "./ide/vscode/hats/VscodeHats"; @@ -28,6 +29,7 @@ export function registerCommands( scopeVisualizer: ScopeVisualizer, keyboardCommands: KeyboardCommands, hats: VscodeHats, + tutorial: VscodeTutorial, ): void { const commands: Record any> = { // The core Cursorless command @@ -98,6 +100,9 @@ export function registerCommands( ["cursorless.keyboard.modal.modeOn"]: keyboardCommands.modal.modeOn, ["cursorless.keyboard.modal.modeOff"]: keyboardCommands.modal.modeOff, ["cursorless.keyboard.modal.modeToggle"]: keyboardCommands.modal.modeToggle, + + // Tutorial commands + ["cursorless.tutorial.start"]: tutorial.start, }; extensionContext.subscriptions.push( diff --git a/packages/cursorless-vscode/src/scripts/populateDist/assets.ts b/packages/cursorless-vscode/src/scripts/populateDist/assets.ts index 8f0b1cbde8..80c812da9b 100644 --- a/packages/cursorless-vscode/src/scripts/populateDist/assets.ts +++ b/packages/cursorless-vscode/src/scripts/populateDist/assets.ts @@ -24,6 +24,18 @@ export const assets: Asset[] = [ destination: "fonts/cursorless.woff", }, { source: "../../images/hats", destination: "images/hats" }, + { + source: "../cursorless-vscode-e2e/src/suite/fixtures/recorded/tutorial", + destination: "tutorial", + }, + { + source: "../cursorless-vscode-tutorial-webview/out/index.js", + destination: "media/tutorialWebview.js", + }, + { + source: "../cursorless-vscode-tutorial-webview/out/index.css", + destination: "media/tutorialWebview.css", + }, { source: "./images/logo.png", destination: "images/logo.png" }, { source: "../../images/logo.svg", destination: "images/logo.svg" }, { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0744cade5c..2b9146e3a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -577,6 +577,31 @@ importers: specifier: ^17.0.1 version: 17.0.1 + packages/cursorless-vscode-tutorial-webview: + dependencies: + '@cursorless/common': + specifier: workspace:* + version: link:../common + react: + specifier: ^18.2.0 + version: 18.2.0 + react-dom: + specifier: ^18.2.0 + version: 18.2.0(react@18.2.0) + devDependencies: + '@types/react': + specifier: 18.0.28 + version: 18.0.28 + '@types/react-dom': + specifier: 18.0.11 + version: 18.0.11 + '@types/vscode-webview': + specifier: 1.57.5 + version: 1.57.5 + tailwindcss: + specifier: 3.3.5 + version: 3.3.5 + packages/meta-updater: dependencies: '@cursorless/common': @@ -5117,6 +5142,12 @@ packages: /@types/range-parser@1.2.7: resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + /@types/react-dom@18.0.11: + resolution: {integrity: sha512-O38bPbI2CWtgw/OoQoY+BRelw7uysmXbWvw3nLWO21H1HSh+GOlqPuXshJfjmpNlKiiSDG9cc1JZAaMmVdcTlw==} + dependencies: + '@types/react': 18.2.71 + dev: true + /@types/react-dom@18.2.22: resolution: {integrity: sha512-fHkBXPeNtfvri6gdsMYyW+dW7RXFo6Ad09nLFK0VQWR7yGLai/Cyvyj696gbwYvBnhGtevUG9cET0pmUbMtoPQ==} dependencies: @@ -5149,6 +5180,14 @@ packages: '@types/history': 4.7.11 '@types/react': 18.2.71 + /@types/react@18.0.28: + resolution: {integrity: sha512-RD0ivG1kEztNBdoAK7lekI9M+azSnitIn85h4iOiaLjaTrMjzslhaqCGaI4IyCJ1RljWiLCEu4jyrLLgqxBTew==} + dependencies: + '@types/prop-types': 15.7.12 + '@types/scheduler': 0.23.0 + csstype: 3.1.3 + dev: true + /@types/react@18.2.71: resolution: {integrity: sha512-PxEsB9OjmQeYGffoWnYAd/r5FiJuUw2niFQHPc2v2idwh8wGPkkYzOHuinNJJY6NZqfoTCiOIizDOz38gYNsyw==} dependencies: @@ -5247,6 +5286,10 @@ packages: '@types/expect': 1.20.4 '@types/node': 18.18.2 + /@types/vscode-webview@1.57.5: + resolution: {integrity: sha512-iBAUYNYkz+uk1kdsq05fEcoh8gJmwT3lqqFPN7MGyjQ3HVloViMdo7ZJ8DFIP8WOK74PjOEilosqAyxV2iUFUw==} + dev: true + /@types/vscode@1.75.1: resolution: {integrity: sha512-emg7wdsTFzdi+elvoyoA+Q8keEautdQHyY5LNmHVM4PTpY8JgOTVADrGVyXGepJ6dVW2OS5/xnLUWh+nZxvdiA==} dev: true @@ -15889,6 +15932,37 @@ packages: - typescript dev: true + /tailwindcss@3.3.5: + resolution: {integrity: sha512-5SEZU4J7pxZgSkv7FP1zY8i2TIAOooNZ1e/OGtxIEv6GltpoiXUqWvLy89+a10qYTB1N5Ifkuw9lqQkN9sscvA==} + engines: {node: '>=14.0.0'} + hasBin: true + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.2 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.0 + lilconfig: 2.1.0 + micromatch: 4.0.5 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.0.0 + postcss: 8.4.38 + postcss-import: 15.1.0(postcss@8.4.38) + postcss-js: 4.0.1(postcss@8.4.38) + postcss-load-config: 4.0.2(postcss@8.4.38)(ts-node@10.9.2) + postcss-nested: 6.0.1(postcss@8.4.38) + postcss-selector-parser: 6.0.16 + resolve: 1.22.8 + sucrase: 3.35.0 + transitivePeerDependencies: + - ts-node + dev: true + /tailwindcss@3.4.1(ts-node@10.9.2): resolution: {integrity: sha512-qAYmXRfk3ENzuPBakNK0SRrUDipP8NQnEY6772uDhflcQz5EhRdD7JNZxyrFHVQNCwULPBn6FNPp9brpO7ctcA==} engines: {node: '>=14.0.0'} diff --git a/tsconfig.json b/tsconfig.json index e05ff589f5..185f941dae 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,6 +26,9 @@ { "path": "./packages/cursorless-vscode-e2e" }, + { + "path": "./packages/cursorless-vscode-tutorial-webview" + }, { "path": "./packages/meta-updater" },