From be21ac43e8c822e4523c9faf1c2c8ac657e4d71e Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Tue, 16 Jul 2024 19:23:52 +0100 Subject: [PATCH] Cursorless tutorial --- .vscode/tasks.json | 22 ++ .../src/cheatsheet/cheat_sheet.py | 7 + .../src/cheatsheet/sections/tutorial.py | 83 +++++ cursorless-talon/src/cursorless.py | 68 +++- cursorless-talon/src/cursorless.talon | 10 + .../tutorial/unit-1-basics/changeSit.yml | 30 ++ .../tutorial/unit-1-basics/chuckLineOdd.yml | 39 +++ .../tutorial/unit-1-basics/chuckTrap.yml | 38 +++ .../tutorial/unit-1-basics/postAir.yml | 30 ++ .../tutorial/unit-1-basics/preUrge.yml | 30 ++ .../tutorial/unit-1-basics/script.json | 17 + .../tutorial/unit-1-basics/takeBlueSun.yml | 34 ++ .../tutorial/unit-1-basics/takeCap.yml | 34 ++ .../unit-1-basics/takeHarpPastDrum.yml | 44 +++ .../tutorial/unit-1-basics/takeLine.yml | 31 ++ .../tutorial/unit-1-basics/takeNearAndSun.yml | 43 +++ .../bringBlueCapToValueRisk.yml | 68 ++++ .../unit-2-basic-coding/bringStateUrge.yml | 60 ++++ .../chuckArgueBlueVest.yml | 59 ++++ .../unit-2-basic-coding/cloneStateInk.yml | 55 +++ .../unit-2-basic-coding/dedentThis.yml | 53 +++ .../tutorial/unit-2-basic-coding/pourUrge.yml | 55 +++ .../tutorial/unit-2-basic-coding/script.json | 17 + .../swapStringAirWithWhale.yml | 63 ++++ .../tutorial/extra-cloning-a-talon-list.py | 9 + data/playground/tutorial/unit-1-basics.txt | 11 + .../tutorial/unit-2-basic-coding.py | 13 + .../lib/sampleSpokenFormInfos/defaults.json | 86 +++++ packages/common/src/cursorlessCommandIds.ts | 16 + packages/common/src/ide/types/State.ts | 22 +- .../src/ide/types/TutorialContentProvider.ts | 44 +++ packages/common/src/index.ts | 2 + packages/common/src/types/tutorial.types.ts | 75 ++++ .../src/api/CursorlessEngineApi.ts | 2 + .../cursorless-engine/src/api/Tutorial.ts | 30 ++ .../cursorless-engine/src/cursorlessEngine.ts | 49 ++- .../disabledComponents/DisabledTutorial.ts | 40 +++ .../CustomSpokenFormGeneratorImpl.ts | 4 + packages/cursorless-engine/src/index.ts | 1 + .../src/tutorial/TutorialError.ts | 12 + .../src/tutorial/TutorialImpl.ts | 322 ++++++++++++++++++ .../src/tutorial/TutorialStepParser.ts | 195 +++++++++++ .../src/tutorial/arePreconditionsMet.ts | 50 +++ .../tutorial/getScopeTypeSpokenFormStrict.ts | 19 ++ .../src/tutorial/loadTutorial.ts | 71 ++++ .../src/tutorial/setupStep.ts | 117 +++++++ .../src/tutorial/specialTerms.ts | 7 + .../ActionComponentParser.ts | 48 +++ .../CursorlessCommandComponentParser.ts | 56 +++ .../GraphemeComponentParser.ts | 28 ++ .../parseSpecialComponent.ts | 40 +++ .../parseVisualizeComponent.ts | 28 ++ .../src/tutorial/tutorialWrapCommandRunner.ts | 35 ++ .../src/tutorial/types/StepComponent.ts | 32 ++ .../src/tutorial/types/TutorialStepTrigger.ts | 41 +++ .../src/tutorial/types/tutorial.types.ts | 47 +++ .../src/endToEndTestSetup.ts | 14 +- .../suite/tutorial/tutorial.vscode.test.ts | 229 +++++++++++++ .../README.md | 12 + .../package.json | 35 ++ .../src/App.tsx | 101 ++++++ .../src/CloseIcon.tsx | 21 ++ .../src/Command.tsx | 9 + .../src/ProgressBar.tsx | 26 ++ .../src/TutorialStep.tsx | 66 ++++ .../src/index.css | 3 + .../src/index.tsx | 6 + .../tailwind.config.js | 13 + .../tsconfig.json | 20 ++ packages/cursorless-vscode/package.json | 39 ++- .../cursorless-vscode/src/SpyWebviewView.ts | 62 ++++ .../cursorless-vscode/src/VscodeTutorial.ts | 226 ++++++++++++ .../src/constructTestHelpers.ts | 5 + packages/cursorless-vscode/src/extension.ts | 38 ++- .../cursorless-vscode/src/registerCommands.ts | 12 +- .../src/scripts/populateDist/assets.ts | 12 + packages/cursorless-vscode/src/vscodeApi.ts | 3 +- .../src/FileSystemTutorialContentProvider.ts | 49 +++ packages/node-common/src/index.ts | 2 + packages/vscode-common/src/SpyWebViewEvent.ts | 17 + packages/vscode-common/src/TestHelpers.ts | 12 + packages/vscode-common/src/VscodeApi.ts | 3 +- packages/vscode-common/src/index.ts | 1 + pnpm-lock.yaml | 30 ++ tsconfig.json | 3 + 85 files changed, 3577 insertions(+), 34 deletions(-) create mode 100644 cursorless-talon/src/cheatsheet/sections/tutorial.py create mode 100644 data/fixtures/recorded/tutorial/unit-1-basics/changeSit.yml create mode 100644 data/fixtures/recorded/tutorial/unit-1-basics/chuckLineOdd.yml create mode 100644 data/fixtures/recorded/tutorial/unit-1-basics/chuckTrap.yml create mode 100644 data/fixtures/recorded/tutorial/unit-1-basics/postAir.yml create mode 100644 data/fixtures/recorded/tutorial/unit-1-basics/preUrge.yml create mode 100644 data/fixtures/recorded/tutorial/unit-1-basics/script.json create mode 100644 data/fixtures/recorded/tutorial/unit-1-basics/takeBlueSun.yml create mode 100644 data/fixtures/recorded/tutorial/unit-1-basics/takeCap.yml create mode 100644 data/fixtures/recorded/tutorial/unit-1-basics/takeHarpPastDrum.yml create mode 100644 data/fixtures/recorded/tutorial/unit-1-basics/takeLine.yml create mode 100644 data/fixtures/recorded/tutorial/unit-1-basics/takeNearAndSun.yml create mode 100644 data/fixtures/recorded/tutorial/unit-2-basic-coding/bringBlueCapToValueRisk.yml create mode 100644 data/fixtures/recorded/tutorial/unit-2-basic-coding/bringStateUrge.yml create mode 100644 data/fixtures/recorded/tutorial/unit-2-basic-coding/chuckArgueBlueVest.yml create mode 100644 data/fixtures/recorded/tutorial/unit-2-basic-coding/cloneStateInk.yml create mode 100644 data/fixtures/recorded/tutorial/unit-2-basic-coding/dedentThis.yml create mode 100644 data/fixtures/recorded/tutorial/unit-2-basic-coding/pourUrge.yml create mode 100644 data/fixtures/recorded/tutorial/unit-2-basic-coding/script.json create mode 100644 data/fixtures/recorded/tutorial/unit-2-basic-coding/swapStringAirWithWhale.yml create mode 100644 data/playground/tutorial/extra-cloning-a-talon-list.py create mode 100644 data/playground/tutorial/unit-1-basics.txt create mode 100644 data/playground/tutorial/unit-2-basic-coding.py create mode 100644 packages/common/src/ide/types/TutorialContentProvider.ts create mode 100644 packages/common/src/types/tutorial.types.ts create mode 100644 packages/cursorless-engine/src/api/Tutorial.ts create mode 100644 packages/cursorless-engine/src/disabledComponents/DisabledTutorial.ts create mode 100644 packages/cursorless-engine/src/tutorial/TutorialError.ts create mode 100644 packages/cursorless-engine/src/tutorial/TutorialImpl.ts create mode 100644 packages/cursorless-engine/src/tutorial/TutorialStepParser.ts create mode 100644 packages/cursorless-engine/src/tutorial/arePreconditionsMet.ts create mode 100644 packages/cursorless-engine/src/tutorial/getScopeTypeSpokenFormStrict.ts create mode 100644 packages/cursorless-engine/src/tutorial/loadTutorial.ts create mode 100644 packages/cursorless-engine/src/tutorial/setupStep.ts create mode 100644 packages/cursorless-engine/src/tutorial/specialTerms.ts create mode 100644 packages/cursorless-engine/src/tutorial/stepComponentParsers/ActionComponentParser.ts create mode 100644 packages/cursorless-engine/src/tutorial/stepComponentParsers/CursorlessCommandComponentParser.ts create mode 100644 packages/cursorless-engine/src/tutorial/stepComponentParsers/GraphemeComponentParser.ts create mode 100644 packages/cursorless-engine/src/tutorial/stepComponentParsers/parseSpecialComponent.ts create mode 100644 packages/cursorless-engine/src/tutorial/stepComponentParsers/parseVisualizeComponent.ts create mode 100644 packages/cursorless-engine/src/tutorial/tutorialWrapCommandRunner.ts create mode 100644 packages/cursorless-engine/src/tutorial/types/StepComponent.ts create mode 100644 packages/cursorless-engine/src/tutorial/types/TutorialStepTrigger.ts create mode 100644 packages/cursorless-engine/src/tutorial/types/tutorial.types.ts create mode 100644 packages/cursorless-vscode-e2e/src/suite/tutorial/tutorial.vscode.test.ts create mode 100644 packages/cursorless-vscode-tutorial-webview/README.md create mode 100644 packages/cursorless-vscode-tutorial-webview/package.json create mode 100644 packages/cursorless-vscode-tutorial-webview/src/App.tsx create mode 100644 packages/cursorless-vscode-tutorial-webview/src/CloseIcon.tsx create mode 100644 packages/cursorless-vscode-tutorial-webview/src/Command.tsx create mode 100644 packages/cursorless-vscode-tutorial-webview/src/ProgressBar.tsx create mode 100644 packages/cursorless-vscode-tutorial-webview/src/TutorialStep.tsx create mode 100644 packages/cursorless-vscode-tutorial-webview/src/index.css create mode 100644 packages/cursorless-vscode-tutorial-webview/src/index.tsx create mode 100644 packages/cursorless-vscode-tutorial-webview/tailwind.config.js create mode 100644 packages/cursorless-vscode-tutorial-webview/tsconfig.json create mode 100644 packages/cursorless-vscode/src/SpyWebviewView.ts create mode 100644 packages/cursorless-vscode/src/VscodeTutorial.ts create mode 100644 packages/node-common/src/FileSystemTutorialContentProvider.ts create mode 100644 packages/vscode-common/src/SpyWebViewEvent.ts diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 412dd4c42..5fee2d1c3 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -32,6 +32,16 @@ }, "group": "build" }, + { + "label": "Build tutorial webview", + "type": "npm", + "script": "build: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" }, @@ -103,6 +114,17 @@ "dependsOn": ["Watch esbuild", "Watch typescript"], "group": "build" }, + { + "label": "watch tutorial", + "type": "npm", + "script": "watch:tailwind", + "isBackground": true, + "presentation": { + "reveal": "never" + }, + "path": "packages/cursorless-vscode-tutorial-webview", + "group": "build" + }, { "type": "npm", "script": "watch:esbuild", diff --git a/cursorless-talon/src/cheatsheet/cheat_sheet.py b/cursorless-talon/src/cheatsheet/cheat_sheet.py index 5d4b93857..7b82177e2 100644 --- a/cursorless-talon/src/cheatsheet/cheat_sheet.py +++ b/cursorless-talon/src/cheatsheet/cheat_sheet.py @@ -11,6 +11,7 @@ from .sections.get_scope_visualizer import get_scope_visualizer from .sections.modifiers import get_modifiers from .sections.scopes import get_scopes from .sections.special_marks import get_special_marks +from .sections.tutorial import get_tutorial_entries mod = Module() ctx = Context() @@ -37,6 +38,7 @@ class Actions: def private_cursorless_open_instructions(): """Open web page with cursorless instructions""" + actions.user.private_cursorless_notify_docs_opened() webbrowser.open(instructions_url) @@ -150,5 +152,10 @@ def cursorless_cheat_sheet_get_json(): "id": "shapes", "items": get_list("hat_shape", "hatShape"), }, + { + "name": "Tutorial", + "id": "tutorial", + "items": get_tutorial_entries(), + }, ] } diff --git a/cursorless-talon/src/cheatsheet/sections/tutorial.py b/cursorless-talon/src/cheatsheet/sections/tutorial.py new file mode 100644 index 000000000..38189cfe1 --- /dev/null +++ b/cursorless-talon/src/cheatsheet/sections/tutorial.py @@ -0,0 +1,83 @@ +def get_tutorial_entries(): + return [ + { + "id": "start_tutorial", + "type": "command", + "variations": [ + { + "spokenForm": "cursorless tutorial", + "description": "Start the introductory Cursorless tutorial", + }, + ], + }, + { + "id": "tutorial_next", + "type": "command", + "variations": [ + { + "spokenForm": "tutorial next", + "description": "Advance to next step in tutorial", + }, + ], + }, + { + "id": "tutorial_previous", + "type": "command", + "variations": [ + { + "spokenForm": "tutorial previous", + "description": "Go back to previous step in tutorial", + }, + ], + }, + { + "id": "tutorial_restart", + "type": "command", + "variations": [ + { + "spokenForm": "tutorial restart", + "description": "Restart the tutorial", + }, + ], + }, + { + "id": "tutorial_resume", + "type": "command", + "variations": [ + { + "spokenForm": "tutorial resume", + "description": "Resume the tutorial", + }, + ], + }, + { + "id": "tutorial_list", + "type": "command", + "variations": [ + { + "spokenForm": "tutorial list", + "description": "List all available tutorials", + }, + ], + }, + { + "id": "tutorial_close", + "type": "command", + "variations": [ + { + "spokenForm": "tutorial close", + "description": "Close the tutorial", + }, + ], + }, + { + "id": "tutorial_start_by_number", + "type": "command", + "variations": [ + { + "spokenForm": "tutorial ", + "description": "Start a specific tutorial by number", + }, + ], + }, + ] diff --git a/cursorless-talon/src/cursorless.py b/cursorless-talon/src/cursorless.py index 9617f5159..c337d85bd 100644 --- a/cursorless-talon/src/cursorless.py +++ b/cursorless-talon/src/cursorless.py @@ -1,4 +1,4 @@ -from talon import Module, actions +from talon import Context, Module, actions mod = Module() @@ -7,6 +7,13 @@ mod.tag( "Application supporting cursorless commands", ) +global_ctx = Context() + +cursorless_ctx = Context() +cursorless_ctx.matches = r""" +tag: user.cursorless +""" + @mod.action_class class Actions: @@ -16,8 +23,67 @@ class Actions: def private_cursorless_show_sidebar(): """Show Cursorless-specific settings in ide""" + def private_cursorless_notify_docs_opened(): + """Notify the ide that the docs were opened in case the tutorial is waiting for that event""" + ... + def private_cursorless_show_command_statistics(): """Show Cursorless command statistics""" actions.user.private_cursorless_run_rpc_command_no_wait( "cursorless.analyzeCommandHistory" ) + + def private_cursorless_start_tutorial(): + """Start the introductory Cursorless tutorial""" + actions.user.private_cursorless_run_rpc_command_no_wait( + "cursorless.tutorial.start", "unit-1-basics" + ) + + def private_cursorless_tutorial_next(): + """Cursorless tutorial: next""" + actions.user.private_cursorless_run_rpc_command_no_wait( + "cursorless.tutorial.next" + ) + + def private_cursorless_tutorial_previous(): + """Cursorless tutorial: previous""" + actions.user.private_cursorless_run_rpc_command_no_wait( + "cursorless.tutorial.previous" + ) + + def private_cursorless_tutorial_restart(): + """Cursorless tutorial: restart""" + actions.user.private_cursorless_run_rpc_command_no_wait( + "cursorless.tutorial.restart" + ) + + def private_cursorless_tutorial_resume(): + """Cursorless tutorial: resume""" + actions.user.private_cursorless_run_rpc_command_no_wait( + "cursorless.tutorial.resume" + ) + + def private_cursorless_tutorial_list(): + """Cursorless tutorial: list all available tutorials""" + actions.user.private_cursorless_run_rpc_command_no_wait( + "cursorless.tutorial.list" + ) + + def private_cursorless_tutorial_start_by_number(number: int): # pyright: ignore [reportGeneralTypeIssues] + """Start Cursorless tutorial by number""" + actions.user.private_cursorless_run_rpc_command_no_wait( + "cursorless.tutorial.start", number - 1 + ) + + +@global_ctx.action_class("user") +class GlobalActions: + def private_cursorless_notify_docs_opened(): + # Do nothing if we're not in a Cursorless context + pass + + +@cursorless_ctx.action_class("user") +class CursorlessActions: + def private_cursorless_notify_docs_opened(): + actions.user.private_cursorless_run_rpc_command_no_wait("cursorless.docsOpened") diff --git a/cursorless-talon/src/cursorless.talon b/cursorless-talon/src/cursorless.talon index a748282ab..68047b9f0 100644 --- a/cursorless-talon/src/cursorless.talon +++ b/cursorless-talon/src/cursorless.talon @@ -37,3 +37,13 @@ bar {user.cursorless_homophone}: {user.cursorless_homophone} stats: user.private_cursorless_show_command_statistics() + +{user.cursorless_homophone} tutorial: + user.private_cursorless_start_tutorial() +tutorial next: user.private_cursorless_tutorial_next() +tutorial (previous | last): user.private_cursorless_tutorial_previous() +tutorial restart: user.private_cursorless_tutorial_restart() +tutorial resume: user.private_cursorless_tutorial_resume() +tutorial (list | close): user.private_cursorless_tutorial_list() +tutorial : + user.private_cursorless_tutorial_start_by_number(private_cursorless_number_small) diff --git a/data/fixtures/recorded/tutorial/unit-1-basics/changeSit.yml b/data/fixtures/recorded/tutorial/unit-1-basics/changeSit.yml new file mode 100644 index 000000000..8d1e7a8e8 --- /dev/null +++ b/data/fixtures/recorded/tutorial/unit-1-basics/changeSit.yml @@ -0,0 +1,30 @@ +languageId: plaintext +command: + version: 7 + spokenForm: change sit + action: + name: clearAndSetSelection + target: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: i} + usePrePhraseSnapshot: true +initialState: + documentContents: | + Welcome Cursorless! + + Notice the hats above each word in this sentence. + selections: + - anchor: {line: 2, character: 15} + active: {line: 2, character: 15} + marks: + default.i: + start: {line: 2, character: 32} + end: {line: 2, character: 34} +finalState: + documentContents: | + Welcome Cursorless! + + Notice the hats above each word this sentence. + selections: + - anchor: {line: 2, character: 32} + active: {line: 2, character: 32} diff --git a/data/fixtures/recorded/tutorial/unit-1-basics/chuckLineOdd.yml b/data/fixtures/recorded/tutorial/unit-1-basics/chuckLineOdd.yml new file mode 100644 index 000000000..dff637f91 --- /dev/null +++ b/data/fixtures/recorded/tutorial/unit-1-basics/chuckLineOdd.yml @@ -0,0 +1,39 @@ +languageId: plaintext +command: + version: 7 + spokenForm: chuck line odd + action: + name: remove + target: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: o} + modifiers: + - type: containingScope + scopeType: {type: line} + usePrePhraseSnapshot: true +initialState: + documentContents: |- + Welcome Cursorless! + + Notice the hats above each word in this sentence. + + Now, see the sidebar. + selections: + - anchor: {line: 2, character: 0} + active: {line: 2, character: 6} + - anchor: {line: 2, character: 35} + active: {line: 2, character: 39} + marks: + default.o: + start: {line: 4, character: 0} + end: {line: 4, character: 3} +finalState: + documentContents: | + Welcome Cursorless! + + Notice the hats above each word in this sentence. + selections: + - anchor: {line: 2, character: 0} + active: {line: 2, character: 6} + - anchor: {line: 2, character: 35} + active: {line: 2, character: 39} diff --git a/data/fixtures/recorded/tutorial/unit-1-basics/chuckTrap.yml b/data/fixtures/recorded/tutorial/unit-1-basics/chuckTrap.yml new file mode 100644 index 000000000..d9d8e75d3 --- /dev/null +++ b/data/fixtures/recorded/tutorial/unit-1-basics/chuckTrap.yml @@ -0,0 +1,38 @@ +languageId: plaintext +command: + version: 7 + spokenForm: chuck trap + action: + name: remove + target: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: t} + usePrePhraseSnapshot: true +initialState: + documentContents: |- + Welcome to Cursorless! + + Notice the hats above each word in this sentence. + + Now, see the sidebar. + selections: + - anchor: {line: 2, character: 0} + active: {line: 2, character: 6} + - anchor: {line: 2, character: 35} + active: {line: 2, character: 39} + marks: + default.t: + start: {line: 0, character: 8} + end: {line: 0, character: 10} +finalState: + documentContents: |- + Welcome Cursorless! + + Notice the hats above each word in this sentence. + + Now, see the sidebar. + selections: + - anchor: {line: 2, character: 0} + active: {line: 2, character: 6} + - anchor: {line: 2, character: 35} + active: {line: 2, character: 39} diff --git a/data/fixtures/recorded/tutorial/unit-1-basics/postAir.yml b/data/fixtures/recorded/tutorial/unit-1-basics/postAir.yml new file mode 100644 index 000000000..4d316dd7b --- /dev/null +++ b/data/fixtures/recorded/tutorial/unit-1-basics/postAir.yml @@ -0,0 +1,30 @@ +languageId: plaintext +command: + version: 7 + spokenForm: post air + action: + name: setSelectionAfter + target: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: a} + usePrePhraseSnapshot: true +initialState: + documentContents: | + Welcome Cursorless! + + Notice the hats above each word in this sentence. + selections: + - anchor: {line: 0, character: 8} + active: {line: 0, character: 8} + marks: + default.a: + start: {line: 2, character: 11} + end: {line: 2, character: 15} +finalState: + documentContents: | + Welcome Cursorless! + + Notice the hats above each word in this sentence. + selections: + - anchor: {line: 2, character: 15} + active: {line: 2, character: 15} diff --git a/data/fixtures/recorded/tutorial/unit-1-basics/preUrge.yml b/data/fixtures/recorded/tutorial/unit-1-basics/preUrge.yml new file mode 100644 index 000000000..18c028280 --- /dev/null +++ b/data/fixtures/recorded/tutorial/unit-1-basics/preUrge.yml @@ -0,0 +1,30 @@ +languageId: plaintext +command: + version: 7 + spokenForm: pre urge + action: + name: setSelectionBefore + target: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: u} + usePrePhraseSnapshot: true +initialState: + documentContents: | + Welcome Cursorless! + + Notice the hats above each word in this sentence. + selections: + - anchor: {line: 2, character: 0} + active: {line: 2, character: 49} + marks: + default.u: + start: {line: 0, character: 8} + end: {line: 0, character: 18} +finalState: + documentContents: | + Welcome Cursorless! + + Notice the hats above each word in this sentence. + selections: + - anchor: {line: 0, character: 8} + active: {line: 0, character: 8} diff --git a/data/fixtures/recorded/tutorial/unit-1-basics/script.json b/data/fixtures/recorded/tutorial/unit-1-basics/script.json new file mode 100644 index 000000000..40887ca44 --- /dev/null +++ b/data/fixtures/recorded/tutorial/unit-1-basics/script.json @@ -0,0 +1,17 @@ +{ + "title": "Introduction", + "version": 0, + "steps": [ + "Say {command:takeCap.yml}", + "Well done! 🙌 You just used the code word for 'c', {grapheme:c}, to refer to the word with a gray hat over the 'c'.\nWhen a hat is not gray, we say its color: say {command:takeBlueSun.yml}", + "Selecting a single token is great, but oftentimes we need something bigger.\nSay {command:takeHarpPastDrum.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.\nLet's try that: {command:takeNearAndSun.yml}", + "But let's show that cursorless can live up to its name: we can say {command:chuckTrap.yml} to delete a word without ever moving our cursor.", + "Tokens are great, but they're just one way to think of a document.\nLet's try working with lines: {command:chuckLineOdd.yml}", + "We can also use {scopeType:line} to refer to the line containing our cursor: {command:takeLine.yml}", + "You now know how to select and delete; let's give you a couple more actions to play with: say {action:pre} to place the cursor before a target, as in {command:preUrge.yml}", + "Say {action:post} to place the cursor after a target: {command:postAir.yml}", + "Say {action:change} to delete a word and move your cursor to where it used to be: {command:changeSit.yml}", + "And that wraps up unit 1 of the cursorless tutorial! Next time, we'll write some code 🙌.\nFeel free to keep playing with this document, then say {special:next} to continue." + ] +} diff --git a/data/fixtures/recorded/tutorial/unit-1-basics/takeBlueSun.yml b/data/fixtures/recorded/tutorial/unit-1-basics/takeBlueSun.yml new file mode 100644 index 000000000..33cfe5aa8 --- /dev/null +++ b/data/fixtures/recorded/tutorial/unit-1-basics/takeBlueSun.yml @@ -0,0 +1,34 @@ +languageId: plaintext +command: + version: 7 + spokenForm: take blue sun + action: + name: setSelection + target: + type: primitive + mark: {type: decoratedSymbol, symbolColor: blue, character: s} + usePrePhraseSnapshot: true +initialState: + documentContents: |- + Welcome to Cursorless! + + Notice the hats above each word in this sentence. + + Now, see the sidebar. + selections: + - anchor: {line: 2, character: 22} + active: {line: 2, character: 26} + marks: + blue.s: + start: {line: 4, character: 5} + end: {line: 4, character: 8} +finalState: + documentContents: |- + Welcome to Cursorless! + + Notice the hats above each word in this sentence. + + Now, see the sidebar. + selections: + - anchor: {line: 4, character: 5} + active: {line: 4, character: 8} diff --git a/data/fixtures/recorded/tutorial/unit-1-basics/takeCap.yml b/data/fixtures/recorded/tutorial/unit-1-basics/takeCap.yml new file mode 100644 index 000000000..f038c8215 --- /dev/null +++ b/data/fixtures/recorded/tutorial/unit-1-basics/takeCap.yml @@ -0,0 +1,34 @@ +languageId: plaintext +command: + version: 7 + spokenForm: take cap + action: + name: setSelection + target: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: c} + usePrePhraseSnapshot: true +initialState: + documentContents: |- + Welcome to Cursorless! + + Notice the hats above each word in this sentence. + + Now, see the sidebar. + selections: + - anchor: {line: 0, character: 0} + active: {line: 0, character: 0} + marks: + default.c: + start: {line: 2, character: 22} + end: {line: 2, character: 26} +finalState: + documentContents: |- + Welcome to Cursorless! + + Notice the hats above each word in this sentence. + + Now, see the sidebar. + selections: + - anchor: {line: 2, character: 22} + active: {line: 2, character: 26} diff --git a/data/fixtures/recorded/tutorial/unit-1-basics/takeHarpPastDrum.yml b/data/fixtures/recorded/tutorial/unit-1-basics/takeHarpPastDrum.yml new file mode 100644 index 000000000..5ac706056 --- /dev/null +++ b/data/fixtures/recorded/tutorial/unit-1-basics/takeHarpPastDrum.yml @@ -0,0 +1,44 @@ +languageId: plaintext +command: + version: 7 + spokenForm: take harp past drum + action: + name: setSelection + target: + type: range + anchor: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: h} + active: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: d} + excludeAnchor: false + excludeActive: false + usePrePhraseSnapshot: true +initialState: + documentContents: |- + Welcome to Cursorless! + + Notice the hats above each word in this sentence. + + Now, see the sidebar. + selections: + - anchor: {line: 4, character: 5} + active: {line: 4, character: 8} + marks: + default.h: + start: {line: 2, character: 7} + end: {line: 2, character: 10} + default.d: + start: {line: 2, character: 27} + end: {line: 2, character: 31} +finalState: + documentContents: |- + Welcome to Cursorless! + + Notice the hats above each word in this sentence. + + Now, see the sidebar. + selections: + - anchor: {line: 2, character: 7} + active: {line: 2, character: 31} diff --git a/data/fixtures/recorded/tutorial/unit-1-basics/takeLine.yml b/data/fixtures/recorded/tutorial/unit-1-basics/takeLine.yml new file mode 100644 index 000000000..46481b52a --- /dev/null +++ b/data/fixtures/recorded/tutorial/unit-1-basics/takeLine.yml @@ -0,0 +1,31 @@ +languageId: plaintext +command: + version: 7 + spokenForm: take line + action: + name: setSelection + target: + type: primitive + modifiers: + - type: containingScope + scopeType: {type: line} + usePrePhraseSnapshot: true +initialState: + documentContents: | + Welcome Cursorless! + + Notice the hats above each word in this sentence. + selections: + - anchor: {line: 2, character: 0} + active: {line: 2, character: 6} + - anchor: {line: 2, character: 35} + active: {line: 2, character: 39} + marks: {} +finalState: + documentContents: | + Welcome Cursorless! + + Notice the hats above each word in this sentence. + selections: + - anchor: {line: 2, character: 0} + active: {line: 2, character: 49} diff --git a/data/fixtures/recorded/tutorial/unit-1-basics/takeNearAndSun.yml b/data/fixtures/recorded/tutorial/unit-1-basics/takeNearAndSun.yml new file mode 100644 index 000000000..b64a0d311 --- /dev/null +++ b/data/fixtures/recorded/tutorial/unit-1-basics/takeNearAndSun.yml @@ -0,0 +1,43 @@ +languageId: plaintext +command: + version: 7 + spokenForm: take near and sun + action: + name: setSelection + target: + type: list + elements: + - type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: 'n'} + - type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: s} + usePrePhraseSnapshot: true +initialState: + documentContents: |- + Welcome to Cursorless! + + Notice the hats above each word in this sentence. + + Now, see the sidebar. + selections: + - anchor: {line: 2, character: 7} + active: {line: 2, character: 31} + marks: + default.n: + start: {line: 2, character: 0} + end: {line: 2, character: 6} + default.s: + start: {line: 2, character: 35} + end: {line: 2, character: 39} +finalState: + documentContents: |- + Welcome to Cursorless! + + Notice the hats above each word in this sentence. + + Now, see the sidebar. + selections: + - anchor: {line: 2, character: 0} + active: {line: 2, character: 6} + - anchor: {line: 2, character: 35} + active: {line: 2, character: 39} diff --git a/data/fixtures/recorded/tutorial/unit-2-basic-coding/bringBlueCapToValueRisk.yml b/data/fixtures/recorded/tutorial/unit-2-basic-coding/bringBlueCapToValueRisk.yml new file mode 100644 index 000000000..f91b7b3d8 --- /dev/null +++ b/data/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/data/fixtures/recorded/tutorial/unit-2-basic-coding/bringStateUrge.yml b/data/fixtures/recorded/tutorial/unit-2-basic-coding/bringStateUrge.yml new file mode 100644 index 000000000..8e7da2f51 --- /dev/null +++ b/data/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/data/fixtures/recorded/tutorial/unit-2-basic-coding/chuckArgueBlueVest.yml b/data/fixtures/recorded/tutorial/unit-2-basic-coding/chuckArgueBlueVest.yml new file mode 100644 index 000000000..de28729d2 --- /dev/null +++ b/data/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/data/fixtures/recorded/tutorial/unit-2-basic-coding/cloneStateInk.yml b/data/fixtures/recorded/tutorial/unit-2-basic-coding/cloneStateInk.yml new file mode 100644 index 000000000..f3bd73502 --- /dev/null +++ b/data/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/data/fixtures/recorded/tutorial/unit-2-basic-coding/dedentThis.yml b/data/fixtures/recorded/tutorial/unit-2-basic-coding/dedentThis.yml new file mode 100644 index 000000000..5d7482ce8 --- /dev/null +++ b/data/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/data/fixtures/recorded/tutorial/unit-2-basic-coding/pourUrge.yml b/data/fixtures/recorded/tutorial/unit-2-basic-coding/pourUrge.yml new file mode 100644 index 000000000..9cb694401 --- /dev/null +++ b/data/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/data/fixtures/recorded/tutorial/unit-2-basic-coding/script.json b/data/fixtures/recorded/tutorial/unit-2-basic-coding/script.json new file mode 100644 index 000000000..1f4d6de77 --- /dev/null +++ b/data/fixtures/recorded/tutorial/unit-2-basic-coding/script.json @@ -0,0 +1,17 @@ +{ + "title": "Basic coding", + "version": 0, + "steps": [ + "When editing code, we often think in terms of statements, functions, etc. Let's clone a statement: {command:cloneStateInk.yml}", + "{scopeType:state} is one of many scopes supported by cursorless. To see all available scopes, have a look at the Scopes section below, and use the {term:visualize} command to see them live: {visualize:funk}", + "Say {special:visualizeNothing} to hide the visualization.", + "Cursorless tries its best to keep your commands short.\nIn the following command, we just say {scopeType:string} once, but cursorless infers that both targets are strings: {command:swapStringAirWithWhale.yml}", + "Great. Let's learn a new action. The {action:pour} action lets you start editing a new line below any line on your screen: {command:pourUrge.yml}", + "Now let's try applying a cursorless action to the current line: {command:dedentThis.yml}", + "Code reuse is a fact of life as a programmer. Cursorless makes this easy with the {action:bring} command: {command:bringStateUrge.yml}", + "{action:bring} also works with two targets just like {action:swap}: {command:bringBlueCapToValueRisk.yml}", + "Cursorless tries its best to use its knowledge of programming languages to leave you with syntactically valid code.\nNote how it cleans up the comma here: {command:chuckArgueBlueVest.yml}", + "We introduced a lot of different scopes today. If you're anything like us, you've already forgotten them all.\nThe important thing to remember is that you can always say {special: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 😊. Say {special:next} to get back home." + ] +} diff --git a/data/fixtures/recorded/tutorial/unit-2-basic-coding/swapStringAirWithWhale.yml b/data/fixtures/recorded/tutorial/unit-2-basic-coding/swapStringAirWithWhale.yml new file mode 100644 index 000000000..3c07f8754 --- /dev/null +++ b/data/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/data/playground/tutorial/extra-cloning-a-talon-list.py b/data/playground/tutorial/extra-cloning-a-talon-list.py new file mode 100644 index 000000000..75d0c0b75 --- /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 000000000..ac66cd3ef --- /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 000000000..636809337 --- /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/cheatsheet/src/lib/sampleSpokenFormInfos/defaults.json b/packages/cheatsheet/src/lib/sampleSpokenFormInfos/defaults.json index afad5aff4..bbce939c6 100644 --- a/packages/cheatsheet/src/lib/sampleSpokenFormInfos/defaults.json +++ b/packages/cheatsheet/src/lib/sampleSpokenFormInfos/defaults.json @@ -1788,6 +1788,92 @@ ] } ] + }, + { + "name": "Tutorial", + "id": "tutorial", + "items": [ + { + "id": "start_tutorial", + "type": "command", + "variations": [ + { + "spokenForm": "cursorless tutorial", + "description": "Start the introductory Cursorless tutorial" + } + ] + }, + { + "id": "tutorial_close", + "type": "command", + "variations": [ + { + "spokenForm": "tutorial close", + "description": "Close the tutorial" + } + ] + }, + { + "id": "tutorial_list", + "type": "command", + "variations": [ + { + "spokenForm": "tutorial list", + "description": "List all available tutorials" + } + ] + }, + { + "id": "tutorial_next", + "type": "command", + "variations": [ + { + "spokenForm": "tutorial next", + "description": "Advance to next step in tutorial" + } + ] + }, + { + "id": "tutorial_previous", + "type": "command", + "variations": [ + { + "spokenForm": "tutorial previous", + "description": "Go back to previous step in tutorial" + } + ] + }, + { + "id": "tutorial_restart", + "type": "command", + "variations": [ + { + "spokenForm": "tutorial restart", + "description": "Restart the tutorial" + } + ] + }, + { + "id": "tutorial_resume", + "type": "command", + "variations": [ + { + "spokenForm": "tutorial resume", + "description": "Resume the tutorial" + } + ] + }, + { + "id": "tutorial_start_by_number", + "type": "command", + "variations": [ + { + "spokenForm": "tutorial ", + "description": "Start a specific tutorial by number" + } + ] + } + ] } ] } diff --git a/packages/common/src/cursorlessCommandIds.ts b/packages/common/src/cursorlessCommandIds.ts index 5b4cf36f7..84180f1bc 100644 --- a/packages/common/src/cursorlessCommandIds.ts +++ b/packages/common/src/cursorlessCommandIds.ts @@ -52,6 +52,13 @@ export const cursorlessCommandIds = [ "cursorless.toggleDecorations", "cursorless.showScopeVisualizer", "cursorless.hideScopeVisualizer", + "cursorless.tutorial.start", + "cursorless.tutorial.next", + "cursorless.tutorial.previous", + "cursorless.tutorial.restart", + "cursorless.tutorial.resume", + "cursorless.tutorial.list", + "cursorless.docsOpened", "cursorless.analyzeCommandHistory", ] as const satisfies readonly `cursorless.${string}`[]; @@ -92,6 +99,15 @@ export const cursorlessCommandDescriptions: Record< "Analyze collected command history", ), + ["cursorless.tutorial.start"]: new HiddenCommand("Start a tutorial"), + ["cursorless.tutorial.next"]: new VisibleCommand("Tutorial next"), + ["cursorless.tutorial.previous"]: new VisibleCommand("Tutorial previous"), + ["cursorless.tutorial.restart"]: new VisibleCommand("Tutorial restart"), + ["cursorless.tutorial.resume"]: new VisibleCommand("Tutorial resume"), + ["cursorless.tutorial.list"]: new VisibleCommand("Tutorial list"), + ["cursorless.docsOpened"]: new HiddenCommand( + "Used by talon to notify us that the docs have been opened; for use with tutorial", + ), ["cursorless.command"]: new HiddenCommand("The core cursorless command"), ["cursorless.repeatPreviousCommand"]: new VisibleCommand( "Repeat the previous Cursorless command", diff --git a/packages/common/src/ide/types/State.ts b/packages/common/src/ide/types/State.ts index 319629470..c9acc6b5e 100644 --- a/packages/common/src/ide/types/State.ts +++ b/packages/common/src/ide/types/State.ts @@ -1,11 +1,27 @@ +import { TutorialId } from "../../types/tutorial.types"; + +interface SingleTutorialProgress { + currentStep: number; + version: number; +} + +export type TutorialProgress = Partial< + Record +>; + +export interface StateData { + hideInferenceWarning: boolean; + tutorialProgress: TutorialProgress; +} +export type StateKey = keyof StateData; + /** * A mapping from allowable state keys to their default values */ -export const STATE_DEFAULTS = { +export const STATE_DEFAULTS: StateData = { hideInferenceWarning: false, + tutorialProgress: {}, }; -export type StateData = typeof STATE_DEFAULTS; -export type StateKey = keyof StateData; /** * A state represents a storage utility. It can store and retrieve diff --git a/packages/common/src/ide/types/TutorialContentProvider.ts b/packages/common/src/ide/types/TutorialContentProvider.ts new file mode 100644 index 000000000..80852b64b --- /dev/null +++ b/packages/common/src/ide/types/TutorialContentProvider.ts @@ -0,0 +1,44 @@ +import { TestCaseFixtureLegacy } from "../../types/TestCaseFixture"; +import { TutorialId } from "../../types/tutorial.types"; + +export interface TutorialContentProvider { + /** + * Loads the raw content of all tutorials. Just includes the information in + * the scripts, not the fixtures that represent commands to run. + */ + loadRawTutorials(): Promise; + + /** + * Loads a fixture file from the tutorial directory, eg "takeNear.yml" + * + * @param tutorialId The tutorial id + * @param fixtureName The name of the fixture, eg "takeNear.yml" + * @returns A promise that resolves to the parsed fixture content + */ + loadFixture( + tutorialId: TutorialId, + fixtureName: string, + ): Promise; +} + +export interface RawTutorialContent { + /** + * The unique identifier for the tutorial + */ + id: TutorialId; + + /** + * The title of the tutorial + */ + title: string; + + /** + * The version of the tutorial + */ + version: number; + + /** + * The steps of the current tutorial + */ + steps: string[]; +} diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index a1711fbc7..4db673dcc 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -29,6 +29,7 @@ export * from "./ide/types/events.types"; export * from "./ide/types/Paths"; export * from "./ide/types/CommandHistoryStorage"; export * from "./ide/types/RawTreeSitterQueryProvider"; +export * from "./ide/types/TutorialContentProvider"; export * from "./ide/types/FileSystem.types"; export * from "./types/RangeExpansionBehavior"; export * from "./types/InputBoxOptions"; @@ -52,6 +53,7 @@ export * from "./types/commandHistory"; export * from "./types/TalonSpokenForms"; export * from "./types/TestHelpers"; export * from "./types/TreeSitter"; +export * from "./types/tutorial.types"; export * from "./util/textFormatters"; export * from "./util/regex"; export * from "./util/serializedMarksToTokenHats"; diff --git a/packages/common/src/types/tutorial.types.ts b/packages/common/src/types/tutorial.types.ts new file mode 100644 index 000000000..5faf5d9e8 --- /dev/null +++ b/packages/common/src/types/tutorial.types.ts @@ -0,0 +1,75 @@ +export type TutorialId = "unit-1-basics" | "unit-2-basic-coding"; + +interface BaseTutorialInfo { + id: TutorialId; + title: string; +} + +export interface TutorialInfo extends BaseTutorialInfo { + version: number; + stepCount: number; + currentStep: number; +} + +interface PickingTutorialState { + type: "pickingTutorial"; + tutorials: TutorialInfo[]; +} + +interface LoadingState { + type: "loading"; +} + +/** + * Descriptive text as part of a tutorial step + */ +interface TutorialStepStringFragment { + type: "string"; + value: string; +} + +/** + * A command embedded in a tutorial step that the user must say + */ +interface TutorialStepCommandFragment { + type: "command"; + value: string; +} + +/** + * A term embedded in a tutorial step. This does not correspond to a complete + * command, but rather a single term that can be part of a command. For example: + * a scope, action name, etc + */ +interface TutorialStepTermFragment { + type: "term"; + value: string; +} + +export type TutorialStepFragment = + | TutorialStepCommandFragment + | TutorialStepStringFragment + | TutorialStepTermFragment; + +interface ActiveTutorialState extends BaseTutorialInfo { + type: "doingTutorial"; + stepNumber: number; + preConditionsMet: boolean; +} + +export interface ActiveTutorialNoErrorsState extends ActiveTutorialState { + hasErrors: false; + stepContent: TutorialStepFragment[][]; + stepCount: number; +} + +export interface ActiveTutorialErrorsState extends ActiveTutorialState { + hasErrors: true; + requiresTalonUpdate: boolean; +} + +export type TutorialState = + | PickingTutorialState + | LoadingState + | ActiveTutorialNoErrorsState + | ActiveTutorialErrorsState; diff --git a/packages/cursorless-engine/src/api/CursorlessEngineApi.ts b/packages/cursorless-engine/src/api/CursorlessEngineApi.ts index 1c17cf266..d99908df8 100644 --- a/packages/cursorless-engine/src/api/CursorlessEngineApi.ts +++ b/packages/cursorless-engine/src/api/CursorlessEngineApi.ts @@ -8,6 +8,7 @@ import type { } from "@cursorless/common"; import type { CommandRunner } from "../CommandRunner"; import type { StoredTargetMap } from "../core/StoredTargets"; +import { Tutorial } from "./Tutorial"; export interface CursorlessEngine { commandApi: CommandApi; @@ -15,6 +16,7 @@ export interface CursorlessEngine { customSpokenFormGenerator: CustomSpokenFormGenerator; storedTargets: StoredTargetMap; hatTokenMap: HatTokenMap; + 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 000000000..825513287 --- /dev/null +++ b/packages/cursorless-engine/src/api/Tutorial.ts @@ -0,0 +1,30 @@ +import { + Disposable, + ScopeType, + TutorialId, + TutorialState, +} from "@cursorless/common"; + +export interface Tutorial { + start(id: TutorialId | number): Promise; + next(): Promise; + previous(): Promise; + restart(): Promise; + resume(): Promise; + list(): Promise; + + onState(callback: (state: TutorialState) => void): Disposable; + readonly state: TutorialState; + + /** + * Call this when the user opens the documentation so that the tutorial can + * advance to the next step if it's waiting for that. + */ + docsOpened(): void; + + /** + * Call this when the user visualizes a scope type so that the tutorial can + * advance to the next step if it's waiting for that. + */ + scopeTypeVisualized(scopeType: ScopeType | undefined): void; +} diff --git a/packages/cursorless-engine/src/cursorlessEngine.ts b/packages/cursorless-engine/src/cursorlessEngine.ts index fa4fc541f..f4a470edf 100644 --- a/packages/cursorless-engine/src/cursorlessEngine.ts +++ b/packages/cursorless-engine/src/cursorlessEngine.ts @@ -4,6 +4,7 @@ import { Hats, IDE, ScopeProvider, + TutorialContentProvider, ensureCommandShape, type RawTreeSitterQueryProvider, type TalonSpokenForms, @@ -18,6 +19,7 @@ import { Debug } from "./core/Debug"; import { HatTokenMapImpl } from "./core/HatTokenMapImpl"; import type { Snippets } from "./core/Snippets"; import { StoredTargetMap } from "./core/StoredTargets"; +import { TutorialImpl } from "./tutorial/TutorialImpl"; import { RangeUpdater } from "./core/updateSelections/RangeUpdater"; import { DisabledCommandServerApi } from "./disabledComponents/DisabledCommandServerApi"; import { DisabledHatTokenMap } from "./disabledComponents/DisabledHatTokenMap"; @@ -40,14 +42,17 @@ import { ScopeRangeWatcher } from "./scopeProviders/ScopeRangeWatcher"; import { ScopeSupportChecker } from "./scopeProviders/ScopeSupportChecker"; import { ScopeSupportWatcher } from "./scopeProviders/ScopeSupportWatcher"; import { injectIde } from "./singletons/ide.singleton"; +import { DisabledTutorial } from "./disabledComponents/DisabledTutorial"; +import { Tutorial } from "./api/Tutorial"; -interface Props { +export interface EngineProps { ide: IDE; hats?: Hats; treeSitterQueryProvider?: RawTreeSitterQueryProvider; treeSitter?: TreeSitter; commandServerApi?: CommandServerApi; talonSpokenForms?: TalonSpokenForms; + tutorialContentProvider?: TutorialContentProvider; snippets?: Snippets; } @@ -58,8 +63,9 @@ export async function createCursorlessEngine({ treeSitter = new DisabledTreeSitter(), commandServerApi = new DisabledCommandServerApi(), talonSpokenForms = new DisabledTalonSpokenForms(), + tutorialContentProvider, snippets = new DisabledSnippets(), -}: Props): Promise { +}: EngineProps): Promise { injectIde(ide); const debug = new Debug(ide); @@ -84,16 +90,34 @@ export async function createCursorlessEngine({ ) : new DisabledLanguageDefinitions(); - ide.disposeOnExit( - rangeUpdater, - languageDefinitions, - hatTokenMap, - debug, - keyboardTargetUpdater, - ); - const commandRunnerDecorators: CommandRunnerDecorator[] = []; + const addCommandRunnerDecorator = (decorator: CommandRunnerDecorator) => { + commandRunnerDecorators.push(decorator); + }; + + let tutorial: Tutorial; + if (tutorialContentProvider != null) { + const tutorialImpl = new TutorialImpl( + hatTokenMap, + customSpokenFormGenerator, + tutorialContentProvider, + ); + ide.disposeOnExit(tutorialImpl); + addCommandRunnerDecorator(tutorialImpl); + tutorial = tutorialImpl; + } else { + tutorial = new DisabledTutorial(); + } + + ide.disposeOnExit( + debug, + hatTokenMap, + keyboardTargetUpdater, + languageDefinitions, + rangeUpdater, + ); + let previousCommand: Command | undefined = undefined; const runCommandClosure = (command: Command) => { @@ -141,9 +165,8 @@ export async function createCursorlessEngine({ injectIde, runIntegrationTests: () => runIntegrationTests(treeSitter, languageDefinitions), - addCommandRunnerDecorator: (decorator: CommandRunnerDecorator) => { - commandRunnerDecorators.push(decorator); - }, + addCommandRunnerDecorator, + tutorial, }; } diff --git a/packages/cursorless-engine/src/disabledComponents/DisabledTutorial.ts b/packages/cursorless-engine/src/disabledComponents/DisabledTutorial.ts new file mode 100644 index 000000000..1633db35f --- /dev/null +++ b/packages/cursorless-engine/src/disabledComponents/DisabledTutorial.ts @@ -0,0 +1,40 @@ +import { + TutorialId, + TutorialState, + Disposable, + ScopeType, +} from "@cursorless/common"; +import { Tutorial } from "../api/Tutorial"; + +export class DisabledTutorial implements Tutorial { + start(_id: number | TutorialId): Promise { + throw new Error("Method not implemented."); + } + next(): Promise { + throw new Error("Method not implemented."); + } + previous(): Promise { + throw new Error("Method not implemented."); + } + restart(): Promise { + throw new Error("Method not implemented."); + } + resume(): Promise { + throw new Error("Method not implemented."); + } + list(): Promise { + throw new Error("Method not implemented."); + } + + onState(_callback: (state: TutorialState) => void): Disposable { + return { dispose: () => {} }; + } + readonly state: TutorialState = { type: "loading" }; + + docsOpened(): void { + // Do nothing + } + scopeTypeVisualized(_scopeType: ScopeType | undefined): void { + // Do nothing + } +} diff --git a/packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGeneratorImpl.ts b/packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGeneratorImpl.ts index ed496e08c..b97da0534 100644 --- a/packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGeneratorImpl.ts +++ b/packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGeneratorImpl.ts @@ -59,6 +59,10 @@ export class CustomSpokenFormGeneratorImpl return this.customSpokenForms.spokenFormMap.action[actionId]; } + graphemeToSpokenForm(grapheme: string) { + return this.customSpokenForms.spokenFormMap.grapheme[grapheme]; + } + getCustomRegexScopeTypes() { return this.customSpokenForms.getCustomRegexScopeTypes(); } diff --git a/packages/cursorless-engine/src/index.ts b/packages/cursorless-engine/src/index.ts index 8be2018db..fc7ed5c82 100644 --- a/packages/cursorless-engine/src/index.ts +++ b/packages/cursorless-engine/src/index.ts @@ -1,4 +1,5 @@ export * from "./testUtil/plainObjectToTarget"; +export * from "./api/Tutorial"; export * from "./core/StoredTargets"; export * from "./cursorlessEngine"; export * from "./generateSpokenForm/defaultSpokenForms/surroundingPairsDelimiters"; diff --git a/packages/cursorless-engine/src/tutorial/TutorialError.ts b/packages/cursorless-engine/src/tutorial/TutorialError.ts new file mode 100644 index 000000000..a7042bc01 --- /dev/null +++ b/packages/cursorless-engine/src/tutorial/TutorialError.ts @@ -0,0 +1,12 @@ +export class TutorialError extends Error { + public readonly requiresTalonUpdate: boolean; + + constructor( + message: string, + { requiresTalonUpdate }: { requiresTalonUpdate: boolean }, + ) { + super(message); + + this.requiresTalonUpdate = requiresTalonUpdate; + } +} diff --git a/packages/cursorless-engine/src/tutorial/TutorialImpl.ts b/packages/cursorless-engine/src/tutorial/TutorialImpl.ts new file mode 100644 index 000000000..6ed4fdaf6 --- /dev/null +++ b/packages/cursorless-engine/src/tutorial/TutorialImpl.ts @@ -0,0 +1,322 @@ +import { + Disposable, + HatTokenMap, + Notifier, + RawTutorialContent, + ReadOnlyHatMap, + ScopeType, + TextEditor, + TutorialContentProvider, + TutorialId, + TutorialState, +} from "@cursorless/common"; +import { produce } from "immer"; +import { isEqual } from "lodash-es"; +import { CommandRunner } from "../CommandRunner"; +import { CommandRunnerDecorator } from "../api/CursorlessEngineApi"; +import { Tutorial } from "../api/Tutorial"; +import { TutorialContent } from "./types/tutorial.types"; +import { Debouncer } from "../core/Debouncer"; +import { CustomSpokenFormGeneratorImpl } from "../generateSpokenForm/CustomSpokenFormGeneratorImpl"; +import { ide } from "../singletons/ide.singleton"; +import { arePreconditionsMet } from "./arePreconditionsMet"; +import { setupStep } from "./setupStep"; +import { loadTutorial } from "./loadTutorial"; +import { tutorialWrapCommandRunner } from "./tutorialWrapCommandRunner"; + +export class TutorialImpl implements Tutorial, CommandRunnerDecorator { + /** + * The current editor that is being used to display the tutorial, if any. + */ + private editor?: TextEditor; + + /** + * The current state of the tutorial, as exposed by {@link Tutorial.state}. + */ + private state_: TutorialState = { type: "loading" }; + + /** + * If {@link state_} is "doingTutorial", this will be the fully parsed current + * tutorial, including information about triggers, etc. + */ + private currentTutorial: TutorialContent | undefined; + + private notifier: Notifier<[TutorialState]> = new Notifier(); + + private disposables: Disposable[] = []; + + /** + * The raw tutorials that are available to the user. These are the tutorials + * that are loaded from disk and have not been parsed yet. + */ + private rawTutorials!: RawTutorialContent[]; + + constructor( + private hatTokenMap: HatTokenMap, + private customSpokenFormGenerator: CustomSpokenFormGeneratorImpl, + private contentProvider: TutorialContentProvider, + ) { + this.setupStep = this.setupStep.bind(this); + this.reparseCurrentTutorial = this.reparseCurrentTutorial.bind(this); + const debouncer = new Debouncer(() => this.checkPreconditions(), 100); + + this.loadTutorials().then(() => { + if (this.state_.type === "loading") { + this.setState(this.getPickingTutorialState()); + } + }); + + this.disposables.push( + ide().onDidChangeActiveTextEditor(debouncer.run), + ide().onDidChangeTextDocument(debouncer.run), + ide().onDidChangeVisibleTextEditors(debouncer.run), + ide().onDidChangeTextEditorSelection(debouncer.run), + ide().onDidOpenTextDocument(debouncer.run), + ide().onDidCloseTextDocument(debouncer.run), + ide().onDidChangeTextEditorVisibleRanges(debouncer.run), + customSpokenFormGenerator.onDidChangeCustomSpokenForms( + this.reparseCurrentTutorial, + ), + debouncer, + ); + } + + /** + * This function is called when a scope type is visualized. If the current step + * is waiting for a visualization of the given scope type, the tutorial will + * advance to the next step. + * @param scopeType The scope type that was visualized + */ + scopeTypeVisualized(scopeType: ScopeType | undefined): void { + if (this.state_.type === "doingTutorial") { + const currentStep = this.currentTutorial!.steps[this.state_.stepNumber]; + if ( + currentStep.trigger?.type === "visualize" && + isEqual(currentStep.trigger.scopeType, scopeType) + ) { + this.next(); + } + } + } + + async loadTutorials() { + this.rawTutorials = await this.contentProvider.loadRawTutorials(); + } + + /** + * @returns A {@link TutorialState} object to use when the user is picking a + * tutorial to start. + */ + getPickingTutorialState(): TutorialState { + const tutorialProgress = ide().globalState.get("tutorialProgress"); + + return { + type: "pickingTutorial", + tutorials: this.rawTutorials.map((rawContent) => ({ + id: rawContent.id, + title: rawContent.title, + version: rawContent.version, + stepCount: rawContent.steps.length, + currentStep: tutorialProgress[rawContent.id]?.currentStep ?? 0, + })), + }; + } + + dispose() { + for (const disposable of this.disposables) { + disposable.dispose(); + } + } + + /** + * If the tutorial is currently active and we are in a step that is waiting + * for a command to be run, we wrap the command runner so that we can + * automatically advance to the next step when the expected command is run. + */ + wrapCommandRunner( + _readableHatMap: ReadOnlyHatMap, + commandRunner: CommandRunner, + ): CommandRunner { + return tutorialWrapCommandRunner(this, commandRunner, this.currentTutorial); + } + + public onState(callback: (state: TutorialState) => void): Disposable { + return this.notifier.registerListener(callback); + } + + /** + * Reparse the current tutorial. This is useful when the user has changed the + * spoken forms and we need to reparse the tutorial to use their new spoken + * forms. + */ + private async reparseCurrentTutorial() { + if (this.currentTutorial == null || this.state_.type !== "doingTutorial") { + return; + } + + const tutorialId = this.state_.id; + + const { tutorialContent, state } = await loadTutorial( + this.contentProvider, + tutorialId, + this.customSpokenFormGenerator, + this.getRawTutorial(tutorialId), + ); + + this.currentTutorial = tutorialContent; + this.setState( + state.hasErrors + ? { + ...state, + stepNumber: this.state_.stepNumber, + } + : { + ...state, + stepNumber: this.state_.stepNumber, + stepContent: tutorialContent.steps[this.state_.stepNumber].content, + }, + ); + } + + private getRawTutorial(tutorialId: string) { + return this.rawTutorials.find( + (rawContent) => rawContent.id === tutorialId, + )!; + } + + async start(tutorialId: TutorialId | number): Promise { + if (typeof tutorialId === "number") { + tutorialId = this.rawTutorials[tutorialId].id; + } + + const { tutorialContent, state } = await loadTutorial( + this.contentProvider, + tutorialId, + this.customSpokenFormGenerator, + this.getRawTutorial(tutorialId), + ); + + this.currentTutorial = tutorialContent; + this.setState(state); + + await this.setupStep(); + } + + docsOpened() { + if (this.state_.type === "doingTutorial") { + const currentStep = this.currentTutorial!.steps[this.state_.stepNumber]; + if (currentStep.trigger?.type === "help") { + this.next(); + } + } + } + + /** + * When currently doing a tutorial, change to a different step. + * + * @param getStep Indicates which step to change to + */ + private async changeStep( + getStep: (current: number) => number, + ): Promise { + if (this.state_.type !== "doingTutorial") { + throw new Error("Not currently doing a tutorial"); + } + + if (this.state_.hasErrors) { + throw new Error("Please see error message in tutorial sidebar"); + } + + const newStepNumber = getStep(this.state_.stepNumber); + + if (newStepNumber === this.state_.stepCount || newStepNumber < 0) { + await this.list(); + return; + } + + const nextStep = this.currentTutorial!.steps[newStepNumber]; + + this.setState({ + type: "doingTutorial", + hasErrors: false, + id: this.state_.id, + stepNumber: newStepNumber, + stepContent: nextStep.content, + stepCount: this.state_.stepCount, + title: this.state_.title, + preConditionsMet: true, + }); + + await this.setupStep(); + } + + next() { + return this.changeStep((current) => current + 1); + } + + previous() { + return this.changeStep((current) => current - 1); + } + + restart() { + return this.changeStep(() => 0); + } + + resume() { + return this.setupStep(); + } + + async list() { + this.setState(this.getPickingTutorialState()); + await this.setupStep(); + } + + private setState(state: TutorialState) { + this.state_ = state; + + if (state.type === "doingTutorial") { + ide().globalState.set( + "tutorialProgress", + produce(ide().globalState.get("tutorialProgress"), (draft) => { + draft[state.id] = { + currentStep: state.stepNumber, + version: this.currentTutorial!.version, + }; + }), + ); + } + + this.notifier.notifyListeners(state); + } + + get state() { + return this.state_; + } + + private async setupStep() { + this.editor = await setupStep( + this.hatTokenMap, + this.editor, + this.state, + this.currentTutorial, + ); + } + + private async checkPreconditions() { + if (this.state_.type === "doingTutorial") { + const currentStep = this.currentTutorial!.steps[this.state_.stepNumber]; + + const preConditionsMet = await arePreconditionsMet( + this.editor, + this.hatTokenMap, + currentStep, + ); + if (preConditionsMet !== this.state_.preConditionsMet) { + this.setState({ + ...this.state_, + preConditionsMet, + }); + } + } + } +} diff --git a/packages/cursorless-engine/src/tutorial/TutorialStepParser.ts b/packages/cursorless-engine/src/tutorial/TutorialStepParser.ts new file mode 100644 index 000000000..f806aaca1 --- /dev/null +++ b/packages/cursorless-engine/src/tutorial/TutorialStepParser.ts @@ -0,0 +1,195 @@ +import { + TutorialContentProvider, + TutorialId, + TutorialStepFragment, +} from "@cursorless/common"; +import { TutorialStep } from "./types/tutorial.types"; +import { parseScopeType } from "../customCommandGrammar/parseCommand"; +import { CustomSpokenFormGeneratorImpl } from "../generateSpokenForm/CustomSpokenFormGeneratorImpl"; +import { StepComponent } from "./types/StepComponent"; +import { getScopeTypeSpokenFormStrict } from "./getScopeTypeSpokenFormStrict"; +import { specialTerms } from "./specialTerms"; +import { ActionComponentParser } from "./stepComponentParsers/ActionComponentParser"; +import { CursorlessCommandComponentParser } from "./stepComponentParsers/CursorlessCommandComponentParser"; +import { GraphemeComponentParser } from "./stepComponentParsers/GraphemeComponentParser"; +import { parseSpecialComponent } from "./stepComponentParsers/parseSpecialComponent"; +import { parseVisualizeComponent } from "./stepComponentParsers/parseVisualizeComponent"; + +/** + * This is trying to catch occurrences of things like `{command:cloneStateInk.yml}` + * or `{action:chuck}` in the tutorial script. + */ +const componentRegex = /{(\w+):([^}]+)}/g; + +/** + * Parses a tutorial step from a raw string. Looks for components in the form + * `{action:chuck}`, `{command:cloneStateInk.yml}`, etc and parses them into + * {@link StepComponent}s, as well as fragments of plain text in between. + */ +export class TutorialStepParser { + /** + * A map of component type to a function that parses the component. + */ + private componentParsers: Record< + string, + (arg: string) => Promise + >; + + constructor( + contentProvider: TutorialContentProvider, + tutorialId: TutorialId, + customSpokenFormGenerator: CustomSpokenFormGeneratorImpl, + ) { + this.parseTutorialStep = this.parseTutorialStep.bind(this); + + const cursorlessCommandParser = new CursorlessCommandComponentParser( + contentProvider, + tutorialId, + customSpokenFormGenerator, + ); + + const actionParser = new ActionComponentParser(customSpokenFormGenerator); + + const graphemeParser = new GraphemeComponentParser( + customSpokenFormGenerator, + ); + + this.componentParsers = { + command: (arg) => cursorlessCommandParser.parse(arg), + special: parseSpecialComponent, + action: (arg) => actionParser.parse(arg), + grapheme: (arg) => graphemeParser.parse(arg), + + term: async (arg) => ({ + content: { + type: "term", + value: specialTerms[arg as keyof typeof specialTerms], + }, + }), + + scopeType: async (arg) => ({ + content: { + type: "term", + value: getScopeTypeSpokenFormStrict( + customSpokenFormGenerator, + parseScopeType(arg), + ), + }, + }), + + visualize: (arg) => + parseVisualizeComponent(customSpokenFormGenerator, arg), + }; + } + + /** + * Given the raw content of a tutorial step, parses it into a + * {@link TutorialStep} object. + * + * For example, given `Say {command:takeCap.yml}`, this would return: + * + * ```json + * { + * "content": [ + * [ + * { + * "type": "string", + * "value": "Say " + * }, + * { + * "type": "command", + * "value": "take cap" + * } + * ] + * ], + * "initialState": { + * "documentContents": "...", + * "selections": [ ... ], + * "marks": { ... } + * }, + * "languageId": "plaintext", + * "trigger": { + * "type": "command", + * "command": { ... } + * } + * } + * ``` + * + * Note that the `initialState`, `languageId`, and `trigger` fiels are optional, + * and in this case come from the `takeCap.yml` fixture. + * + * @param rawContent The raw content of the tutorial step to parse + * @returns A {@link TutorialStep} object representing the parsed step + */ + async parseTutorialStep(rawContent: string): Promise { + const ret: TutorialStep = { + content: [], + }; + + for (const line of rawContent.split("\n")) { + const lineContent: TutorialStepFragment[] = []; + let currentIndex = 0; + componentRegex.lastIndex = 0; + + for (const { + 0: { length }, + 1: type, + 2: arg, + index, + } of line.matchAll(componentRegex)) { + if (index > currentIndex) { + lineContent.push({ + type: "string", + value: line.slice(currentIndex, index), + }); + } + + currentIndex = index + length; + + const result = await this.componentParsers[type](arg); + + if (result == null) { + throw new Error(`Unknown component type: ${type}`); + } + + const { content, ...rest } = result; + + lineContent.push(content); + updateStrict(ret, rest); + } + + if (currentIndex < line.length) { + lineContent.push({ + type: "string", + value: line.slice(currentIndex), + }); + } + + ret.content.push(lineContent); + } + + return ret; + } +} + +/** + * Update {@link target} with the non-null values from {@link source}, throwing + * an error if a key from {@link source} already exists in {@link target} with a + * non-null value. + * + * @param target The object to update + * @param source The object to update from + */ +function updateStrict(target: T, source: T) { + for (const key in source) { + if (source[key] == null) { + continue; + } + + if (target[key] != null) { + throw new Error(`Duplicate key: ${key}`); + } + + target[key] = source[key]; + } +} diff --git a/packages/cursorless-engine/src/tutorial/arePreconditionsMet.ts b/packages/cursorless-engine/src/tutorial/arePreconditionsMet.ts new file mode 100644 index 000000000..274c72d7c --- /dev/null +++ b/packages/cursorless-engine/src/tutorial/arePreconditionsMet.ts @@ -0,0 +1,50 @@ +import { + HatTokenMap, + TextEditor, + plainObjectToSelection, + serializedMarksToTokenHats, +} from "@cursorless/common"; +import { isEqual } from "lodash-es"; +import { TutorialStep } from "./types/tutorial.types"; +import { ide } from "../singletons/ide.singleton"; + +export async function arePreconditionsMet( + editor: TextEditor | undefined, + hatTokenMap: HatTokenMap, + { initialState: snapshot, languageId }: TutorialStep, +): Promise { + if (snapshot == null) { + return true; + } + + if (ide().activeTextEditor !== editor) { + return false; + } + + if (editor == null || editor.document.languageId !== languageId) { + return false; + } + + if (editor.document.getText() !== snapshot.documentContents) { + return false; + } + + if ( + !isEqual(editor.selections, snapshot.selections.map(plainObjectToSelection)) + ) { + return false; + } + + const readableHatMap = await hatTokenMap.getReadableMap(false); + for (const mark of serializedMarksToTokenHats(snapshot.marks, editor)) { + if ( + !readableHatMap + .getToken(mark.hatStyle, mark.grapheme) + ?.range.isRangeEqual(mark.hatRange) + ) { + return false; + } + } + + return true; +} diff --git a/packages/cursorless-engine/src/tutorial/getScopeTypeSpokenFormStrict.ts b/packages/cursorless-engine/src/tutorial/getScopeTypeSpokenFormStrict.ts new file mode 100644 index 000000000..c2da27de2 --- /dev/null +++ b/packages/cursorless-engine/src/tutorial/getScopeTypeSpokenFormStrict.ts @@ -0,0 +1,19 @@ +import { ScopeType } from "@cursorless/common"; +import { CustomSpokenFormGeneratorImpl } from "../generateSpokenForm/CustomSpokenFormGeneratorImpl"; +import { TutorialError } from "./TutorialError"; + +export function getScopeTypeSpokenFormStrict( + customSpokenFormGenerator: CustomSpokenFormGeneratorImpl, + scopeType: ScopeType, +) { + const spokenForm = customSpokenFormGenerator.scopeTypeToSpokenForm(scopeType); + + if (spokenForm.type === "error") { + throw new TutorialError( + `Error while processing spoken form for scope type: ${spokenForm.reason}`, + { requiresTalonUpdate: spokenForm.requiresTalonUpdate }, + ); + } + + return spokenForm.spokenForms[0]; +} diff --git a/packages/cursorless-engine/src/tutorial/loadTutorial.ts b/packages/cursorless-engine/src/tutorial/loadTutorial.ts new file mode 100644 index 000000000..26bd461ba --- /dev/null +++ b/packages/cursorless-engine/src/tutorial/loadTutorial.ts @@ -0,0 +1,71 @@ +import { + RawTutorialContent, + TutorialContentProvider, + TutorialId, + TutorialState, +} from "@cursorless/common"; +import { TutorialContent } from "./types/tutorial.types"; +import { CustomSpokenFormGeneratorImpl } from "../generateSpokenForm/CustomSpokenFormGeneratorImpl"; +import { ide } from "../singletons/ide.singleton"; +import { TutorialError } from "./TutorialError"; +import { TutorialStepParser } from "./TutorialStepParser"; + +export async function loadTutorial( + contentProvider: TutorialContentProvider, + tutorialId: TutorialId, + customSpokenFormGenerator: CustomSpokenFormGeneratorImpl, + rawContent: RawTutorialContent, +) { + const parser = new TutorialStepParser( + contentProvider, + tutorialId, + customSpokenFormGenerator, + ); + + let tutorialContent: TutorialContent; + let state: TutorialState; + + try { + tutorialContent = { + title: rawContent.title, + version: rawContent.version, + steps: await Promise.all(rawContent.steps.map(parser.parseTutorialStep)), + }; + + let stepNumber = + ide().globalState.get("tutorialProgress")[tutorialId]?.currentStep ?? 0; + + if (stepNumber >= tutorialContent.steps.length - 1) { + stepNumber = 0; + } + + state = { + type: "doingTutorial", + hasErrors: false, + id: tutorialId, + stepNumber, + stepContent: tutorialContent.steps[stepNumber].content, + stepCount: tutorialContent.steps.length, + title: tutorialContent.title, + preConditionsMet: true, + }; + } catch (err) { + tutorialContent = { + title: rawContent.title, + steps: [], + version: rawContent.version, + }; + state = { + type: "doingTutorial", + hasErrors: true, + id: tutorialId, + stepNumber: 0, + title: tutorialContent.title, + preConditionsMet: true, + requiresTalonUpdate: + err instanceof TutorialError && err.requiresTalonUpdate, + }; + } + + return { tutorialContent, state }; +} diff --git a/packages/cursorless-engine/src/tutorial/setupStep.ts b/packages/cursorless-engine/src/tutorial/setupStep.ts new file mode 100644 index 000000000..7f05689f9 --- /dev/null +++ b/packages/cursorless-engine/src/tutorial/setupStep.ts @@ -0,0 +1,117 @@ +import { + HatTokenMap, + TestCaseSnapshot, + TextEditor, + TutorialState, + plainObjectToRange, + plainObjectToSelection, + serializedMarksToTokenHats, + toCharacterRange, +} from "@cursorless/common"; +import { ide } from "../singletons/ide.singleton"; +import { TutorialContent } from "./types/tutorial.types"; + +const HIGHLIGHT_COLOR = "highlight0"; + +/** + * Set up the current step. For example, if the current step requires that the + * user has an editor open with certain content, this function will ensure that + * the editor is open and has the correct content. + * + * Initially tries to reuse the existing editor if it's already open. If that + * fails, it will try again with a new editor. + * + * @param hatTokenMap The hat token map to use for allocating hats. + * @param editor The current editor, if any. + * @param state The current tutorial state. + * @param currentTutorial The current tutorial, if any. + * @returns The editor that was set up, or `undefined` if no editor was set up. + * If the current editor was reused, it will be returned. + */ +export async function setupStep( + hatTokenMap: HatTokenMap, + editor: TextEditor | undefined, + state: TutorialState, + currentTutorial: TutorialContent | undefined, +): Promise { + if (state.type !== "doingTutorial") { + if (editor != null) { + ide().setHighlightRanges(HIGHLIGHT_COLOR, editor, []); + } + return undefined; + } + + const { initialState: snapshot, languageId = "plaintext" } = + currentTutorial!.steps[state.stepNumber]; + + if (snapshot == null) { + if (editor != null) { + ide().setHighlightRanges(HIGHLIGHT_COLOR, editor, []); + } + return editor; + } + + return await applySnapshot(hatTokenMap, editor, snapshot, languageId); +} + +async function applySnapshot( + hatTokenMap: HatTokenMap, + editor: TextEditor | undefined, + snapshot: TestCaseSnapshot, + languageId: string, +) { + const retry = editor != null; + + try { + if (editor == null) { + editor = await ide().openUntitledTextDocument({ + content: snapshot.documentContents, + language: languageId, + }); + } + + const editableEditor = ide().getEditableTextEditor(editor); + + if (editableEditor.document.languageId !== languageId) { + throw new Error( + `Expected language id ${languageId}, but got ${editableEditor.document.languageId}`, + ); + } + + await editableEditor.edit([ + { + range: editableEditor.document.range, + text: snapshot.documentContents, + isReplace: true, + }, + ]); + + // Ensure that the expected cursor/selections are present + await editableEditor.setSelections( + snapshot.selections.map(plainObjectToSelection), + ); + + // Ensure that the expected hats are present + await hatTokenMap.allocateHats( + serializedMarksToTokenHats(snapshot.marks, editor), + ); + + ide().setHighlightRanges( + HIGHLIGHT_COLOR, + editor, + Object.values(snapshot.marks ?? {}).map((range) => + toCharacterRange(plainObjectToRange(range)), + ), + ); + + await editableEditor.focus(); + + return editor; + } catch (err) { + if (retry) { + return await applySnapshot(hatTokenMap, undefined, snapshot, languageId); + } else { + throw err; + } + } +} diff --git a/packages/cursorless-engine/src/tutorial/specialTerms.ts b/packages/cursorless-engine/src/tutorial/specialTerms.ts new file mode 100644 index 000000000..eb00beba2 --- /dev/null +++ b/packages/cursorless-engine/src/tutorial/specialTerms.ts @@ -0,0 +1,7 @@ +/** + * Special terms used in spoken forms that are referred to in the tutorial, but + * don't constitute a complete command. + */ +export const specialTerms = { + visualize: "visualize", +}; diff --git a/packages/cursorless-engine/src/tutorial/stepComponentParsers/ActionComponentParser.ts b/packages/cursorless-engine/src/tutorial/stepComponentParsers/ActionComponentParser.ts new file mode 100644 index 000000000..fabeb9cd8 --- /dev/null +++ b/packages/cursorless-engine/src/tutorial/stepComponentParsers/ActionComponentParser.ts @@ -0,0 +1,48 @@ +import { ActionType, actionNames } from "@cursorless/common"; +import { CustomSpokenFormGeneratorImpl } from "../../generateSpokenForm/CustomSpokenFormGeneratorImpl"; +import { defaultSpokenFormMap } from "../../spokenForms/defaultSpokenFormMap"; +import { StepComponent, StepComponentParser } from "../types/StepComponent"; + +/** + * Parses components of the form `{action:chuck}`. + */ +export class ActionComponentParser implements StepComponentParser { + private actionMap: Record = {}; + + constructor( + private customSpokenFormGenerator: CustomSpokenFormGeneratorImpl, + ) { + for (const actionName of actionNames) { + const { spokenForms } = defaultSpokenFormMap.action[actionName]; + for (const spokenForm of spokenForms) { + this.actionMap[spokenForm] = actionName; + } + } + } + + async parse(arg: string): Promise { + return { + content: { + type: "term", + value: this.getActionSpokenForm(this.parseActionName(arg)), + }, + }; + } + + private getActionSpokenForm(actionId: ActionType) { + const spokenForm = + this.customSpokenFormGenerator.actionIdToSpokenForm(actionId); + + return spokenForm.spokenForms[0]; + } + + private parseActionName(arg: string): ActionType { + const actionId = this.actionMap[arg]; + + if (actionId == null) { + throw new Error(`Unknown action: ${arg}`); + } + + return actionId; + } +} diff --git a/packages/cursorless-engine/src/tutorial/stepComponentParsers/CursorlessCommandComponentParser.ts b/packages/cursorless-engine/src/tutorial/stepComponentParsers/CursorlessCommandComponentParser.ts new file mode 100644 index 000000000..75a721d45 --- /dev/null +++ b/packages/cursorless-engine/src/tutorial/stepComponentParsers/CursorlessCommandComponentParser.ts @@ -0,0 +1,56 @@ +import { + CommandComplete, + TutorialContentProvider, + TutorialId, +} from "@cursorless/common"; +import { canonicalizeAndValidateCommand } from "../../core/commandVersionUpgrades/canonicalizeAndValidateCommand"; +import { CustomSpokenFormGeneratorImpl } from "../../generateSpokenForm/CustomSpokenFormGeneratorImpl"; +import { StepComponent, StepComponentParser } from "../types/StepComponent"; +import { TutorialError } from "../TutorialError"; + +/** + * Parses components of the form `{command:takeNear.yml}`. The argument + * (`takeNear.yml`) is the name of a fixture file in the tutorial directory. + */ +export class CursorlessCommandComponentParser implements StepComponentParser { + constructor( + private contentProvider: TutorialContentProvider, + private tutorialId: TutorialId, + private customSpokenFormGenerator: CustomSpokenFormGeneratorImpl, + ) {} + + async parse(arg: string): Promise { + const fixture = await this.contentProvider.loadFixture( + this.tutorialId, + arg, + ); + const command = canonicalizeAndValidateCommand(fixture.command); + + return { + initialState: fixture.initialState, + languageId: fixture.languageId, + trigger: { + type: "command", + command, + }, + content: { + type: "command", + value: this.getCommandSpokenForm(command), + }, + }; + } + + private getCommandSpokenForm(command: CommandComplete) { + const spokenForm = + this.customSpokenFormGenerator.commandToSpokenForm(command); + + if (spokenForm.type === "error") { + throw new TutorialError( + `Error while processing spoken form for command: ${spokenForm.reason}`, + { requiresTalonUpdate: spokenForm.requiresTalonUpdate }, + ); + } + + return spokenForm.spokenForms[0]; + } +} diff --git a/packages/cursorless-engine/src/tutorial/stepComponentParsers/GraphemeComponentParser.ts b/packages/cursorless-engine/src/tutorial/stepComponentParsers/GraphemeComponentParser.ts new file mode 100644 index 000000000..f561bd7df --- /dev/null +++ b/packages/cursorless-engine/src/tutorial/stepComponentParsers/GraphemeComponentParser.ts @@ -0,0 +1,28 @@ +import { CustomSpokenFormGeneratorImpl } from "../../generateSpokenForm/CustomSpokenFormGeneratorImpl"; +import { StepComponent, StepComponentParser } from "../types/StepComponent"; + +/** + * Parses components of the form `{grapheme:c}`. Used to refer to the user's + * custom spoken form for a grapheme. + */ +export class GraphemeComponentParser implements StepComponentParser { + constructor( + private customSpokenFormGenerator: CustomSpokenFormGeneratorImpl, + ) {} + + async parse(arg: string): Promise { + return { + content: { + type: "term", + value: this.getGraphemeSpokenForm(arg), + }, + }; + } + + private getGraphemeSpokenForm(grapheme: string) { + const spokenForm = + this.customSpokenFormGenerator.graphemeToSpokenForm(grapheme); + + return spokenForm.spokenForms[0]; + } +} diff --git a/packages/cursorless-engine/src/tutorial/stepComponentParsers/parseSpecialComponent.ts b/packages/cursorless-engine/src/tutorial/stepComponentParsers/parseSpecialComponent.ts new file mode 100644 index 000000000..728f744c9 --- /dev/null +++ b/packages/cursorless-engine/src/tutorial/stepComponentParsers/parseSpecialComponent.ts @@ -0,0 +1,40 @@ +import { TutorialStepTrigger } from "../types/TutorialStepTrigger"; +import { StepComponent } from "../types/StepComponent"; + +const SPECIAL_COMMANDS = { + help: "cursorless help", + next: "tutorial next", + visualizeNothing: "visualize nothing", +}; + +/** + * Parses components of the form `{special:help}`. These are special commands + * that don't correspond to any cursorless command. + */ +export async function parseSpecialComponent( + arg: string, +): Promise { + let trigger: TutorialStepTrigger | undefined = undefined; + + switch (arg) { + case "help": + trigger = { + type: "help", + }; + break; + case "visualizeNothing": + trigger = { + type: "visualize", + scopeType: undefined, + }; + break; + } + + return { + content: { + type: "command", + value: SPECIAL_COMMANDS[arg as keyof typeof SPECIAL_COMMANDS], + }, + trigger, + }; +} diff --git a/packages/cursorless-engine/src/tutorial/stepComponentParsers/parseVisualizeComponent.ts b/packages/cursorless-engine/src/tutorial/stepComponentParsers/parseVisualizeComponent.ts new file mode 100644 index 000000000..288b5d3f2 --- /dev/null +++ b/packages/cursorless-engine/src/tutorial/stepComponentParsers/parseVisualizeComponent.ts @@ -0,0 +1,28 @@ +import { parseScopeType } from "../../customCommandGrammar/parseCommand"; +import { CustomSpokenFormGeneratorImpl } from "../../generateSpokenForm/CustomSpokenFormGeneratorImpl"; +import { StepComponent } from "../types/StepComponent"; +import { getScopeTypeSpokenFormStrict } from "../getScopeTypeSpokenFormStrict"; +import { specialTerms } from "../specialTerms"; + +/** + * Parses components of the form `{visualize:funk}`. Displays the command for + * visualizing a scope type and causes the step to automatically advance when + * the user visualizes the scope type. + */ +export async function parseVisualizeComponent( + customSpokenFormGenerator: CustomSpokenFormGeneratorImpl, + arg: string, +): Promise { + const scopeType = parseScopeType(arg); + + return { + content: { + type: "command", + value: `${specialTerms.visualize} ${getScopeTypeSpokenFormStrict(customSpokenFormGenerator, scopeType)}`, + }, + trigger: { + type: "visualize", + scopeType, + }, + }; +} diff --git a/packages/cursorless-engine/src/tutorial/tutorialWrapCommandRunner.ts b/packages/cursorless-engine/src/tutorial/tutorialWrapCommandRunner.ts new file mode 100644 index 000000000..2b85ca1eb --- /dev/null +++ b/packages/cursorless-engine/src/tutorial/tutorialWrapCommandRunner.ts @@ -0,0 +1,35 @@ +import { CommandComplete } from "@cursorless/common"; +import { isEqual } from "lodash-es"; +import { CommandRunner } from "../CommandRunner"; +import { Tutorial } from "../api/Tutorial"; +import { TutorialContent } from "./types/tutorial.types"; + +export function tutorialWrapCommandRunner( + tutorial: Tutorial, + commandRunner: CommandRunner, + currentTutorial: TutorialContent | undefined, +): CommandRunner { + if (tutorial.state.type !== "doingTutorial") { + return commandRunner; + } + + const currentStep = currentTutorial?.steps[tutorial.state.stepNumber]; + + if (currentStep?.trigger?.type !== "command") { + return commandRunner; + } + + const trigger = currentStep.trigger; + + return { + run: async (commandComplete: CommandComplete) => { + const returnValue = await commandRunner.run(commandComplete); + + if (isEqual(trigger.command.action, commandComplete.action)) { + await tutorial.next(); + } + + return returnValue; + }, + }; +} diff --git a/packages/cursorless-engine/src/tutorial/types/StepComponent.ts b/packages/cursorless-engine/src/tutorial/types/StepComponent.ts new file mode 100644 index 000000000..3a75aaab0 --- /dev/null +++ b/packages/cursorless-engine/src/tutorial/types/StepComponent.ts @@ -0,0 +1,32 @@ +import { TutorialStepFragment } from "@cursorless/common"; +import { TutorialStep } from "./tutorial.types"; + +/** + * Represents a `{foo:bar}` component in a tutorial step, eg `{action:chuck}`. + * It will be dynamically rendered when the tutorial step is displayed. For + * example, the above component would render as the user's custom spoken form + * for the `chuck` action, surrounded by quotes. + * + * Note that in addition to the {@link content} to display, the component can + * optionally set other fields on the step, such as a {@link trigger} to advance + * to the next step. + */ +export interface StepComponent extends Omit { + /** + * The content of the component to display in the tutorial step. + */ + content: TutorialStepFragment; +} + +/** + * Interface for classes that parse a string into a {@link StepComponent}. + */ +export interface StepComponentParser { + /** + * Parses a string into a {@link StepComponent}. + * + * @param arg The string to parse. This will be between the `:` and `}` in a + * component, eg `chuck` in `{action:chuck}`. + */ + parse(arg: string): Promise; +} diff --git a/packages/cursorless-engine/src/tutorial/types/TutorialStepTrigger.ts b/packages/cursorless-engine/src/tutorial/types/TutorialStepTrigger.ts new file mode 100644 index 000000000..575e1cb84 --- /dev/null +++ b/packages/cursorless-engine/src/tutorial/types/TutorialStepTrigger.ts @@ -0,0 +1,41 @@ +import { CommandComplete, ScopeType } from "@cursorless/common"; + +/** + * Advance to the next step when the user completes a command + */ +export interface CommandTutorialStepTrigger { + type: "command"; + + /** + * The command we're waiting for to advance to the next step + */ + command: CommandComplete; +} + +/** + * Advance to the next step when the user completes a command + */ +export interface CommandTutorialVisualizeTrigger { + type: "visualize"; + + /** + * The command we're waiting for to advance to the next step + */ + scopeType: ScopeType | undefined; +} + +/** + * Advance to the next step when the user opens the documentation + */ +export interface HelpTutorialStepTrigger { + type: "help"; +} + +/** + * Represents a trigger that advances to the next step in a tutorial when a + * certain condition is met. + */ +export type TutorialStepTrigger = + | CommandTutorialStepTrigger + | CommandTutorialVisualizeTrigger + | HelpTutorialStepTrigger; diff --git a/packages/cursorless-engine/src/tutorial/types/tutorial.types.ts b/packages/cursorless-engine/src/tutorial/types/tutorial.types.ts new file mode 100644 index 000000000..cf729310e --- /dev/null +++ b/packages/cursorless-engine/src/tutorial/types/tutorial.types.ts @@ -0,0 +1,47 @@ +import { TestCaseSnapshot, TutorialStepFragment } from "@cursorless/common"; +import { TutorialStepTrigger } from "./TutorialStepTrigger"; + +/** + * Represents the content of a tutorial. Used internally by the tutorial + * component to control the tutorial. + */ +export interface TutorialContent { + /** + * The title of the tutorial + */ + title: string; + + /** + * The version of the tutorial + */ + version: number; + + /** + * The steps of the current tutorial + */ + steps: Array; +} + +export interface TutorialStep { + /** + * The content of the current step. Each element in the array represents a + * paragraph in the tutorial step. + */ + content: TutorialStepFragment[][]; + + /** + * 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. + */ + initialState?: TestCaseSnapshot; + + /** + * The language id to use when opening the editor for the current step + */ + languageId?: string; + + /** + * When this happens, advance to the next step + */ + trigger?: TutorialStepTrigger; +} diff --git a/packages/cursorless-vscode-e2e/src/endToEndTestSetup.ts b/packages/cursorless-vscode-e2e/src/endToEndTestSetup.ts index 4cfc633ec..85eb4ff20 100644 --- a/packages/cursorless-vscode-e2e/src/endToEndTestSetup.ts +++ b/packages/cursorless-vscode-e2e/src/endToEndTestSetup.ts @@ -16,9 +16,17 @@ let retryCount = -1; */ let previousTestTitle = ""; -export function endToEndTestSetup(suite: Mocha.Suite) { - suite.timeout("100s"); - suite.retries(5); +interface EndToEndTestSetupOpts { + retries?: number; + timeout?: string | number; +} + +export function endToEndTestSetup( + suite: Mocha.Suite, + { retries = 5, timeout = "100s" }: EndToEndTestSetupOpts = {}, +) { + suite.timeout(timeout); + suite.retries(retries); let ide: IDE; let injectIde: (ide: IDE) => void; diff --git a/packages/cursorless-vscode-e2e/src/suite/tutorial/tutorial.vscode.test.ts b/packages/cursorless-vscode-e2e/src/suite/tutorial/tutorial.vscode.test.ts new file mode 100644 index 000000000..6cb1b2d6c --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/tutorial/tutorial.vscode.test.ts @@ -0,0 +1,229 @@ +import { + LATEST_VERSION, + SpyIDE, + TestCaseFixtureLegacy, + asyncSafety, + getSnapshotForComparison, + sleep, +} from "@cursorless/common"; +import { getRecordedTestsDirPath, loadFixture } from "@cursorless/node-common"; +import { + getCursorlessApi, + runCursorlessCommand, +} from "@cursorless/vscode-common"; +import assert from "node:assert"; +import path from "path"; +import sinon from "sinon"; +import { commands } from "vscode"; +import { endToEndTestSetup } from "../../endToEndTestSetup"; +import { isEqual, uniqWith } from "lodash-es"; + +suite("tutorial", async function () { + // Retry doesn't make sense because we need to capture initial load events of + // the webview. + const { getSpy } = endToEndTestSetup(this, { retries: 0 }); + + test( + "basic", + asyncSafety(() => runBasicTutorialTest(getSpy()!)), + ); +}); + +const BASICS_TUTORIAL_ID = "unit-1-basics"; + +async function runBasicTutorialTest(spyIde: SpyIDE) { + const cursorlessApi = await getCursorlessApi(); + const { hatTokenMap, takeSnapshot, getTutorialWebviewEventLog, vscodeApi } = + cursorlessApi.testHelpers!; + const commandsRun: string[] = []; + sinon.replace( + vscodeApi.commands, + "executeCommand", + (command: string, ...args: any[]): Thenable => { + commandsRun.push(command); + return commands.executeCommand(command, ...args); + }, + ); + const tutorialDirectory = path.join( + getRecordedTestsDirPath(), + "tutorial", + BASICS_TUTORIAL_ID, + ); + + const fixtures = await Promise.all( + ["takeCap.yml", "takeBlueSun.yml", "takeHarpPastDrum.yml"].map((name) => + loadFixture(path.join(tutorialDirectory, name)), + ), + ); + + const checkStepSetup = async (fixture: TestCaseFixtureLegacy) => { + assert.deepStrictEqual( + await getSnapshotForComparison( + fixture.initialState, + await hatTokenMap.getReadableMap(false), + spyIde, + takeSnapshot, + ), + fixture.initialState, + "Unexpected final state", + ); + }; + + // Test starting tutorial + await commands.executeCommand( + "cursorless.tutorial.start", + BASICS_TUTORIAL_ID, + ); + await checkStepSetup(fixtures[0]); + + // Allow for debounce + await sleep(350); + + // We allow duplicate messages because they're idempotent. Not sure why some + // platforms get the init message twice but it doesn't matter. + const result = uniqWith(getTutorialWebviewEventLog(), isEqual); + assert.deepStrictEqual( + result, + [ + // This is the initial message that the webview sends to the extension. + // Seeing this means that the javascript in the webview successfully loaded. + { + type: "messageReceived", + data: { + type: "getInitialState", + }, + }, + + // This is the response from the extension to the webview's initial message. + { + type: "messageSent", + data: { + type: "doingTutorial", + hasErrors: false, + id: "unit-1-basics", + stepNumber: 0, + stepContent: [ + [ + { + type: "string", + value: "Say ", + }, + { + type: "command", + value: "take cap", + }, + ], + ], + stepCount: 11, + title: "Introduction", + preConditionsMet: true, + }, + }, + ], + JSON.stringify(result, null, 2), + ); + + // Check that we focus the tutorial webview when the user starts the tutorial + assert(commandsRun.includes("cursorless.tutorial.focus")); + + // Check that it doesn't auto-advance for incorrect command + await runNoOpCursorlessCommand(); + await checkStepSetup(fixtures[0]); + + // Test that we detect when prerequisites are no longer met + // "chuck file" + await runCursorlessCommand({ + version: LATEST_VERSION, + action: { + name: "remove", + target: { + type: "primitive", + modifiers: [ + { type: "containingScope", scopeType: { type: "document" } }, + ], + }, + }, + usePrePhraseSnapshot: false, + }); + + // Allow for debounce + await sleep(150); + + // We allow duplicate messages because they're idempotent. Not sure why some + // platforms get the init message twice but it doesn't matter. + const log = uniqWith(getTutorialWebviewEventLog(), isEqual); + assert.equal(log.length, 3, JSON.stringify(log, null, 2)); + const lastMessage = log[log.length - 1]; + assert( + lastMessage.type === "messageSent" && + lastMessage.data.preConditionsMet === false, + ); + + // Test resuming tutorial + await commands.executeCommand("cursorless.tutorial.resume"); + await checkStepSetup(fixtures[0]); + + // Test automatic advancing + await runCursorlessCommand(fixtures[0].command); + await checkStepSetup(fixtures[1]); + + // Test restarting tutorial + await commands.executeCommand("cursorless.tutorial.restart"); + await checkStepSetup(fixtures[0]); + + // Test manual advancing + await commands.executeCommand("cursorless.tutorial.next"); + await commands.executeCommand("cursorless.tutorial.next"); + await checkStepSetup(fixtures[2]); + + // Test manual retreating + await commands.executeCommand("cursorless.tutorial.previous"); + await checkStepSetup(fixtures[1]); + + // Test listing tutorials + await commands.executeCommand("cursorless.tutorial.list"); + assert.deepStrictEqual(getTutorialWebviewEventLog().slice(-2), [ + { + type: "messageSent", + data: { + type: "pickingTutorial", + tutorials: [ + { + id: "unit-1-basics", + title: "Introduction", + version: 0, + stepCount: 11, + currentStep: 1, + }, + { + id: "unit-2-basic-coding", + title: "Basic coding", + version: 0, + stepCount: 11, + currentStep: 0, + }, + ], + }, + }, + { type: "viewShown", preserveFocus: true }, + ]); +} + +// This is a cursorless command that does nothing. It's useful for testing +// that the tutorial doesn't auto-advance when the user does something that +// isn't part of the tutorial. +const runNoOpCursorlessCommand = () => + runCursorlessCommand({ + version: LATEST_VERSION, + action: { + name: "setSelection", + target: { + type: "primitive", + mark: { + type: "cursor", + }, + modifiers: [{ type: "toRawSelection" }], + }, + }, + usePrePhraseSnapshot: false, + }); diff --git a/packages/cursorless-vscode-tutorial-webview/README.md b/packages/cursorless-vscode-tutorial-webview/README.md new file mode 100644 index 000000000..e4d3fabb0 --- /dev/null +++ b/packages/cursorless-vscode-tutorial-webview/README.md @@ -0,0 +1,12 @@ +# Cursorless VSCode Tutorial Webview + +This package holds the Javascript and CSS for the webview that is displayed when +the user opens any tutorial in VSCode. It is rendered in the sidebar. + +## Development + +To enable hot reloading, run the following command: + +```bash +pnpm watch +``` diff --git a/packages/cursorless-vscode-tutorial-webview/package.json b/packages/cursorless-vscode-tutorial-webview/package.json new file mode 100644 index 000000000..d99811044 --- /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": { + "compile:tsc": "tsc --build", + "compile": "pnpm compile:tsc", + "watch:tsc": "pnpm compile:tsc --watch", + "watch:esbuild": "pnpm build:esbuild --watch", + "watch:tailwind": "pnpm build:tailwind --watch", + "watch": "pnpm run --filter @cursorless/cursorless-vscode-tutorial-webview --parallel '/^watch:.*/'", + "build:esbuild": "esbuild ./src/index.tsx --sourcemap --format=cjs --bundle --outfile=./out/index.js", + "build:tailwind": "tailwindcss -i ./src/index.css -o ./out/index.css", + "build": "pnpm build:esbuild --minify && pnpm build:tailwind --minify", + "build:dev": "pnpm build:esbuild && pnpm build:tailwind", + "clean": "rm -rf ./out tsconfig.tsbuildinfo ./dist ./build" + }, + "keywords": [], + "author": "", + "license": "MIT", + "type": "module", + "devDependencies": { + "@types/react": "18.2.71", + "@types/react-dom": "18.2.22", + "@types/vscode-webview": "1.57.5", + "tailwindcss": "3.4.1" + }, + "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 000000000..f2076c210 --- /dev/null +++ b/packages/cursorless-vscode-tutorial-webview/src/App.tsx @@ -0,0 +1,101 @@ +import { TutorialState } from "@cursorless/common"; +import { useEffect, useState, type FunctionComponent } from "react"; +import { WebviewApi } from "vscode-webview"; +import { TutorialStep } from "./TutorialStep"; +import { Command } from "./Command"; + +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 <>; + } + + switch (state.type) { + case "pickingTutorial": + return ( +
+

+ To start a tutorial, say , + or click one of the following tutorials: +

+
    + {state.tutorials.map((tutorial) => ( +
  1. + +
  2. + ))} +
+
+ ); + + case "doingTutorial": + return state.hasErrors ? ( +
+

+ Error +

+

+ {state.requiresTalonUpdate ? ( + <> + Please{" "} + + update cursorless-talon + + + ) : ( + "" + )} +

+
+ ) : ( + + ); + } +}; + +const TutorialProgressIndicator: FunctionComponent<{ + currentStep: number; + stepCount: number; +}> = ({ currentStep, stepCount }) => { + if (currentStep === 0) { + return null; + } + if (currentStep === stepCount - 1) { + return ✅; + } + return 🕗; +}; diff --git a/packages/cursorless-vscode-tutorial-webview/src/CloseIcon.tsx b/packages/cursorless-vscode-tutorial-webview/src/CloseIcon.tsx new file mode 100644 index 000000000..08cc85338 --- /dev/null +++ b/packages/cursorless-vscode-tutorial-webview/src/CloseIcon.tsx @@ -0,0 +1,21 @@ +import { type FunctionComponent } from "react"; + +export const CloseIcon: FunctionComponent = () => { + // From https://github.com/microsoft/vscode-codicons/blob/eaa030691d720b9c5c0efa93d9be9e2e45d7262b/src/icons/close.svg + // FIXME: Use codicons the way it's intended; see https://github.com/microsoft/vscode-codicons + return ( + + + + ); +}; diff --git a/packages/cursorless-vscode-tutorial-webview/src/Command.tsx b/packages/cursorless-vscode-tutorial-webview/src/Command.tsx new file mode 100644 index 000000000..139d81cc0 --- /dev/null +++ b/packages/cursorless-vscode-tutorial-webview/src/Command.tsx @@ -0,0 +1,9 @@ +import { type FunctionComponent } from "react"; + +interface CommandProps { + spokenForm: string; +} + +export const Command: FunctionComponent = ({ spokenForm }) => { + return {`"${spokenForm}"`}; +}; diff --git a/packages/cursorless-vscode-tutorial-webview/src/ProgressBar.tsx b/packages/cursorless-vscode-tutorial-webview/src/ProgressBar.tsx new file mode 100644 index 000000000..48f553826 --- /dev/null +++ b/packages/cursorless-vscode-tutorial-webview/src/ProgressBar.tsx @@ -0,0 +1,26 @@ +import { type FunctionComponent } from "react"; + +interface ProgressBarProps { + currentStep: number; + stepCount: number; +} + +/** + * A progress bar that shows the current step in a tutorial. + * + * From https://flowbite.com/docs/components/progress/ + */ +export const ProgressBar: FunctionComponent = ({ + currentStep, + stepCount, +}) => { + const progress = ((currentStep + 1) / stepCount) * 100; + return ( +
+
+
+ ); +}; 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 000000000..f3ee6ff59 --- /dev/null +++ b/packages/cursorless-vscode-tutorial-webview/src/TutorialStep.tsx @@ -0,0 +1,66 @@ +import { ActiveTutorialNoErrorsState } from "@cursorless/common"; +import { type FunctionComponent } from "react"; +import { Command } from "./Command"; +import { WebviewApi } from "vscode-webview"; +import { CloseIcon } from "./CloseIcon"; +import { ProgressBar } from "./ProgressBar"; + +interface TutorialStepProps { + state: ActiveTutorialNoErrorsState; + vscode: WebviewApi; +} + +export const TutorialStep: FunctionComponent = ({ + state, + vscode, +}) => { + return ( +
+
+ + +
+ {state.preConditionsMet ? ( + state.stepContent.map((paragraph, i) => ( +
+ {paragraph.map((fragment, j) => { + switch (fragment.type) { + case "string": + return {fragment.value}; + case "command": + return ; + case "term": + return "{fragment.value}"; + default: { + // Ensure we handle all cases + const _unused: never = fragment; + } + } + })} +
+ )) + ) : ( + <> +
Whoops! Looks like you've stepped off the beaten path.
+
+ Feel free to keep playing, then say{" "} + to resume the tutorial. +
+ + )} +
+ ); +}; 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 000000000..b5c61c956 --- /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 000000000..c52cfae5b --- /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 000000000..2da991098 --- /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 000000000..121770ed6 --- /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 79a406eb6..8f19fa4d2 100644 --- a/packages/cursorless-vscode/package.json +++ b/packages/cursorless-vscode/package.json @@ -56,6 +56,11 @@ "contributes": { "views": { "cursorless": [ + { + "type": "webview", + "id": "cursorless.tutorial", + "name": "Tutorial" + }, { "id": "cursorless.scopes", "name": "Scopes" @@ -111,6 +116,36 @@ "command": "cursorless.analyzeCommandHistory", "title": "Cursorless: Analyze collected command history" }, + { + "command": "cursorless.tutorial.start", + "title": "Cursorless: Start a tutorial", + "enablement": "false" + }, + { + "command": "cursorless.tutorial.next", + "title": "Cursorless: Tutorial next" + }, + { + "command": "cursorless.tutorial.previous", + "title": "Cursorless: Tutorial previous" + }, + { + "command": "cursorless.tutorial.restart", + "title": "Cursorless: Tutorial restart" + }, + { + "command": "cursorless.tutorial.resume", + "title": "Cursorless: Tutorial resume" + }, + { + "command": "cursorless.tutorial.list", + "title": "Cursorless: Tutorial list" + }, + { + "command": "cursorless.docsOpened", + "title": "Cursorless: Used by talon to notify us that the docs have been opened; for use with tutorial", + "enablement": "false" + }, { "command": "cursorless.command", "title": "Cursorless: The core cursorless command", @@ -1192,8 +1227,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/SpyWebviewView.ts b/packages/cursorless-vscode/src/SpyWebviewView.ts new file mode 100644 index 000000000..6b63958c3 --- /dev/null +++ b/packages/cursorless-vscode/src/SpyWebviewView.ts @@ -0,0 +1,62 @@ +import { Disposable } from "@cursorless/common"; +import { cloneDeep } from "lodash-es"; +import { Uri, Webview, WebviewView } from "vscode"; +import { SpyWebViewEvent } from "@cursorless/vscode-common"; + +/** + * Wraps a {@link WebviewView} and provides a way to spy on its events for + * testing. + */ +export class SpyWebviewView { + readonly webview: SpyWebview; + private eventLog: SpyWebViewEvent[] = []; + + constructor(public view: WebviewView) { + this.webview = new SpyWebview(this.eventLog, view.webview); + } + + getEventLog(): SpyWebViewEvent[] { + return cloneDeep(this.eventLog); + } + + show(preserveFocus: boolean): void { + this.view.show(preserveFocus); + this.eventLog.push({ type: "viewShown", preserveFocus }); + } +} + +class SpyWebview { + constructor( + private eventLog: SpyWebViewEvent[], + private view: Webview, + ) { + this.view.onDidReceiveMessage((data) => { + this.eventLog.push({ type: "messageReceived", data }); + }); + } + + set html(value: string) { + this.view.html = value; + } + + set options(value: { enableScripts: boolean; localResourceRoots: Uri[] }) { + this.view.options = value; + } + + onDidReceiveMessage(callback: (data: any) => void): Disposable { + return this.view.onDidReceiveMessage(callback); + } + + postMessage(data: any): Thenable { + this.eventLog.push({ type: "messageSent", data }); + return this.view.postMessage(data); + } + + asWebviewUri(localResource: Uri): Uri { + return this.view.asWebviewUri(localResource); + } + + get cspSource(): string { + return this.view.cspSource; + } +} diff --git a/packages/cursorless-vscode/src/VscodeTutorial.ts b/packages/cursorless-vscode/src/VscodeTutorial.ts new file mode 100644 index 000000000..b0850cabe --- /dev/null +++ b/packages/cursorless-vscode/src/VscodeTutorial.ts @@ -0,0 +1,226 @@ +import { FileSystem, TutorialId, TutorialState } from "@cursorless/common"; +import { Tutorial } from "@cursorless/cursorless-engine"; +import { getCursorlessRepoRoot } from "@cursorless/node-common"; +import { SpyWebViewEvent, VscodeApi } from "@cursorless/vscode-common"; +import path from "node:path"; +import { + CancellationToken, + ExtensionContext, + ExtensionMode, + Uri, + WebviewView, + WebviewViewProvider, + WebviewViewResolveContext, +} from "vscode"; +import { ScopeVisualizer } from "./ScopeVisualizerCommandApi"; +import { SpyWebviewView } from "./SpyWebviewView"; + +const VSCODE_TUTORIAL_WEBVIEW_ID = "cursorless.tutorial"; + +export class VscodeTutorial implements WebviewViewProvider { + private view?: WebviewView | SpyWebviewView; + private localResourceRoot: Uri; + + constructor( + private context: ExtensionContext, + private vscodeApi: VscodeApi, + private tutorial: Tutorial, + scopeVisualizer: ScopeVisualizer, + fileSystem: FileSystem, + ) { + this.onState = this.onState.bind(this); + this.start = this.start.bind(this); + this.docsOpened = this.docsOpened.bind(this); + this.next = this.next.bind(this); + this.previous = this.previous.bind(this); + this.restart = this.restart.bind(this); + this.resume = this.resume.bind(this); + this.list = this.list.bind(this); + + this.localResourceRoot = + context.extensionMode === ExtensionMode.Development + ? Uri.file( + path.join( + getCursorlessRepoRoot(), + "packages", + "cursorless-vscode-tutorial-webview", + "out", + ), + ) + : Uri.joinPath(context.extensionUri, "media"); + + context.subscriptions.push( + vscodeApi.window.registerWebviewViewProvider( + VSCODE_TUTORIAL_WEBVIEW_ID, + this, + ), + tutorial.onState(this.onState), + scopeVisualizer.onDidChangeScopeType((scopeType) => { + this.tutorial.scopeTypeVisualized(scopeType); + }), + ); + + if (context.extensionMode === ExtensionMode.Development) { + context.subscriptions.push( + fileSystem.watchDir(this.localResourceRoot.fsPath, () => { + if (this.view != null) { + this.view.webview.html = this.getHtmlForWebview(); + } + }), + ); + } + } + + public resolveWebviewView( + webviewView: WebviewView, + _context: WebviewViewResolveContext, + _token: CancellationToken, + ) { + if (this.view != null && this.view instanceof SpyWebviewView) { + this.view.view = webviewView; + } else { + this.view = + this.context.extensionMode === ExtensionMode.Test + ? new SpyWebviewView(webviewView) + : webviewView; + } + const { webview } = this.view; + + webview.options = { + enableScripts: true, + localResourceRoots: [this.localResourceRoot], + }; + + webview.html = this.getHtmlForWebview(); + + webview.onDidReceiveMessage((data) => { + switch (data.type) { + case "getInitialState": + webview.postMessage(this.tutorial.state); + break; + case "start": + this.start(data.tutorialId); + break; + case "list": + this.list(); + break; + } + }); + } + + public getEventLog(): SpyWebViewEvent[] { + if (this.view instanceof SpyWebviewView) { + return this.view.getEventLog(); + } + + return []; + } + + public async start(id: TutorialId | number) { + await this.tutorial.start(id); + this.revealTutorial(); + } + + docsOpened() { + this.tutorial.docsOpened(); + this.revealTutorial(); + } + + async next() { + await this.tutorial.next(); + this.revealTutorial(); + } + + async previous() { + await this.tutorial.previous(); + this.revealTutorial(); + } + + async restart() { + await this.tutorial.restart(); + this.revealTutorial(); + } + + async resume() { + await this.tutorial.resume(); + this.revealTutorial(); + } + + async list() { + await this.tutorial.list(); + this.revealTutorial(); + } + + private onState(state: TutorialState) { + this.view?.webview.postMessage(state); + } + + private revealTutorial() { + if (this.view != null) { + this.view.show(true); + } else { + this.vscodeApi.commands.executeCommand("cursorless.tutorial.focus"); + } + } + + private getHtmlForWebview() { + const { webview } = this.view!; + + // 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.localResourceRoot, + this.context.extensionMode === ExtensionMode.Development + ? "index.js" + : "tutorialWebview.js", + ), + ); + + // Do the same for the stylesheet. + const styleMainUri = webview.asWebviewUri( + Uri.joinPath( + this.localResourceRoot, + this.context.extensionMode === ExtensionMode.Development + ? "index.css" + : "tutorialWebview.css", + ), + ); + + // Use a nonce to only allow a specific script to be run. + const nonce = getNonce(); + + return ` + + + + + + + + + + + + Cursorless tutorial + + +
+ + + + `; + } +} + +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/constructTestHelpers.ts b/packages/cursorless-vscode/src/constructTestHelpers.ts index d637548f6..43ce56c2b 100644 --- a/packages/cursorless-vscode/src/constructTestHelpers.ts +++ b/packages/cursorless-vscode/src/constructTestHelpers.ts @@ -23,6 +23,7 @@ import { VscodeFileSystem } from "./ide/vscode/VscodeFileSystem"; import { VscodeIDE } from "./ide/vscode/VscodeIDE"; import { toVscodeEditor } from "./ide/vscode/toVscodeEditor"; import { vscodeApi } from "./vscodeApi"; +import { VscodeTutorial } from "./VscodeTutorial"; export function constructTestHelpers( commandServerApi: FakeCommandServerApi, @@ -34,6 +35,7 @@ export function constructTestHelpers( scopeProvider: ScopeProvider, injectIde: (ide: IDE) => void, runIntegrationTests: () => Promise, + vscodeTutorial: VscodeTutorial, ): VscodeTestHelpers | undefined { return { commandServerApi: commandServerApi!, @@ -83,5 +85,8 @@ export function constructTestHelpers( hatTokenMap, runIntegrationTests, vscodeApi, + getTutorialWebviewEventLog() { + return vscodeTutorial.getEventLog(); + }, }; } diff --git a/packages/cursorless-vscode/src/extension.ts b/packages/cursorless-vscode/src/extension.ts index 27f5d666c..50adc1ef7 100644 --- a/packages/cursorless-vscode/src/extension.ts +++ b/packages/cursorless-vscode/src/extension.ts @@ -1,5 +1,6 @@ import { Disposable, + EnforceUndefined, FakeCommandServerApi, FakeIDE, IDE, @@ -12,12 +13,14 @@ import { } from "@cursorless/common"; import { CommandHistory, + EngineProps, createCursorlessEngine, } from "@cursorless/cursorless-engine"; import { FileSystemCommandHistoryStorage, FileSystemRawTreeSitterQueryProvider, FileSystemTalonSpokenForms, + FileSystemTutorialContentProvider, getFixturePath, } from "@cursorless/node-common"; import { @@ -59,6 +62,7 @@ import { registerCommands } from "./registerCommands"; import { revisualizeOnCustomRegexChange } from "./revisualizeOnCustomRegexChange"; import { storedTargetHighlighter } from "./storedTargetHighlighter"; import { vscodeApi } from "./vscodeApi"; +import { VscodeTutorial } from "./VscodeTutorial"; /** * Extension entrypoint called by VSCode on Cursorless startup. @@ -103,6 +107,19 @@ export async function activate( ); context.subscriptions.push(treeSitterQueryProvider); + const engineProps: EnforceUndefined = { + ide: normalizedIde, + hats, + treeSitterQueryProvider, + treeSitter, + commandServerApi, + talonSpokenForms, + tutorialContentProvider: new FileSystemTutorialContentProvider( + normalizedIde.assetsRoot, + ), + snippets, + }; + const { commandApi, storedTargets, @@ -112,15 +129,8 @@ export async function activate( runIntegrationTests, addCommandRunnerDecorator, customSpokenFormGenerator, - } = await createCursorlessEngine({ - ide: normalizedIde, - hats, - treeSitterQueryProvider, - treeSitter, - commandServerApi, - talonSpokenForms, - snippets, - }); + tutorial, + } = await createCursorlessEngine(engineProps); const commandHistoryStorage = new FileSystemCommandHistoryStorage( fileSystem.cursorlessCommandHistoryDirPath, @@ -161,6 +171,14 @@ export async function activate( context.subscriptions.push(storedTargetHighlighter(vscodeIDE, storedTargets)); + const vscodeTutorial = new VscodeTutorial( + context, + vscodeApi, + tutorial, + scopeVisualizer, + fileSystem, + ); + registerCommands( context, vscodeIDE, @@ -171,6 +189,7 @@ export async function activate( scopeVisualizer, keyboardCommands, hats, + vscodeTutorial, storedTargets, ); @@ -189,6 +208,7 @@ export async function activate( scopeProvider, injectIde, runIntegrationTests, + vscodeTutorial, ) : undefined, diff --git a/packages/cursorless-vscode/src/registerCommands.ts b/packages/cursorless-vscode/src/registerCommands.ts index 37b2b9a39..6aefe9756 100644 --- a/packages/cursorless-vscode/src/registerCommands.ts +++ b/packages/cursorless-vscode/src/registerCommands.ts @@ -18,6 +18,7 @@ import type { } from "@cursorless/test-case-recorder"; 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"; @@ -34,6 +35,7 @@ export function registerCommands( scopeVisualizer: ScopeVisualizer, keyboardCommands: KeyboardCommands, hats: VscodeHats, + tutorial: VscodeTutorial, storedTargets: StoredTargetMap, ): void { const runCommandWrapper = async (run: () => Promise) => { @@ -119,9 +121,17 @@ export function registerCommands( ["cursorless.keyboard.modal.modeOn"]: keyboardCommands.modal.modeOn, ["cursorless.keyboard.modal.modeOff"]: keyboardCommands.modal.modeOff, ["cursorless.keyboard.modal.modeToggle"]: keyboardCommands.modal.modeToggle, - ["cursorless.keyboard.undoTarget"]: () => storedTargets.undo("keyboard"), ["cursorless.keyboard.redoTarget"]: () => storedTargets.redo("keyboard"), + + // Tutorial commands + ["cursorless.tutorial.start"]: tutorial.start, + ["cursorless.tutorial.next"]: tutorial.next, + ["cursorless.tutorial.previous"]: tutorial.previous, + ["cursorless.tutorial.restart"]: tutorial.restart, + ["cursorless.tutorial.resume"]: tutorial.resume, + ["cursorless.tutorial.list"]: tutorial.list, + ["cursorless.docsOpened"]: tutorial.docsOpened, }; extensionContext.subscriptions.push( diff --git a/packages/cursorless-vscode/src/scripts/populateDist/assets.ts b/packages/cursorless-vscode/src/scripts/populateDist/assets.ts index 8f0b1cbde..20bb8ea20 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: "../../data/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/packages/cursorless-vscode/src/vscodeApi.ts b/packages/cursorless-vscode/src/vscodeApi.ts index e1f8f7b65..72c9a1de1 100644 --- a/packages/cursorless-vscode/src/vscodeApi.ts +++ b/packages/cursorless-vscode/src/vscodeApi.ts @@ -1,5 +1,5 @@ import { VscodeApi } from "@cursorless/vscode-common"; -import { env, window, workspace } from "vscode"; +import { commands, env, window, workspace } from "vscode"; /** * A very thin wrapper around the VSCode API that allows us to mock it for @@ -12,6 +12,7 @@ export const vscodeApi: VscodeApi = { workspace, window, env, + commands, editor: { setDecorations(editor, ...args) { return editor.setDecorations(...args); diff --git a/packages/node-common/src/FileSystemTutorialContentProvider.ts b/packages/node-common/src/FileSystemTutorialContentProvider.ts new file mode 100644 index 000000000..d24d1842e --- /dev/null +++ b/packages/node-common/src/FileSystemTutorialContentProvider.ts @@ -0,0 +1,49 @@ +import { + RawTutorialContent, + TutorialContentProvider, + TutorialId, +} from "@cursorless/common"; +import { readFile, readdir } from "node:fs/promises"; +import path from "path"; +import { loadFixture } from "./loadFixture"; + +export class FileSystemTutorialContentProvider + implements TutorialContentProvider +{ + private tutorialRootDir: string; + + constructor(assetsRoot: string) { + this.tutorialRootDir = path.join(assetsRoot, "tutorial"); + } + + async loadRawTutorials() { + const tutorialDirs = await readdir(this.tutorialRootDir, { + withFileTypes: true, + }); + + return await Promise.all( + tutorialDirs + .filter((dirent) => dirent.isDirectory()) + .map((dirent) => this.loadTutorialScript(dirent.name as TutorialId)), + ); + } + + private async loadTutorialScript( + tutorialId: string, + ): Promise { + const buffer = await readFile( + path.join(this.tutorialRootDir, tutorialId, "script.json"), + ); + + return { + id: tutorialId, + ...JSON.parse(buffer.toString()), + }; + } + + async loadFixture(tutorialId: TutorialId, fixtureName: string) { + return loadFixture( + path.join(this.tutorialRootDir, tutorialId, fixtureName), + ); + } +} diff --git a/packages/node-common/src/index.ts b/packages/node-common/src/index.ts index 59aa0a562..9159de2a7 100644 --- a/packages/node-common/src/index.ts +++ b/packages/node-common/src/index.ts @@ -1,6 +1,7 @@ export * from "./FileSystemCommandHistoryStorage"; export * from "./FileSystemRawTreeSitterQueryProvider"; export * from "./FileSystemTalonSpokenForms"; +export * from "./FileSystemTutorialContentProvider"; export * from "./getCursorlessRepoRoot"; export * from "./getFixturePaths"; export * from "./getScopeTestPathsRecursively"; @@ -8,3 +9,4 @@ export * from "./nodeGetRunMode"; export * from "./runRecordedTest"; export * from "./walkAsync"; export * from "./walkSync"; +export * from "./loadFixture"; diff --git a/packages/vscode-common/src/SpyWebViewEvent.ts b/packages/vscode-common/src/SpyWebViewEvent.ts new file mode 100644 index 000000000..7e737aab2 --- /dev/null +++ b/packages/vscode-common/src/SpyWebViewEvent.ts @@ -0,0 +1,17 @@ +interface MessageSentEvent { + type: "messageSent"; + data: any; +} +interface MessageReceivedEvent { + type: "messageReceived"; + data: any; +} +interface ViewShownEvent { + type: "viewShown"; + preserveFocus: boolean; +} + +export type SpyWebViewEvent = + | MessageSentEvent + | MessageReceivedEvent + | ViewShownEvent; diff --git a/packages/vscode-common/src/TestHelpers.ts b/packages/vscode-common/src/TestHelpers.ts index 11c740f46..ed6ee91f2 100644 --- a/packages/vscode-common/src/TestHelpers.ts +++ b/packages/vscode-common/src/TestHelpers.ts @@ -7,6 +7,7 @@ import type { } from "@cursorless/common"; import * as vscode from "vscode"; import { VscodeApi } from "./VscodeApi"; +import { SpyWebViewEvent } from "./SpyWebViewEvent"; export interface VscodeTestHelpers extends TestHelpers { ide: NormalizedIDE; @@ -22,6 +23,17 @@ export interface VscodeTestHelpers extends TestHelpers { cursorlessTalonStateJsonPath: string; cursorlessCommandHistoryDirPath: string; + /** + * Returns the event log for the VSCode tutorial component. Used to test that + * the VSCode side of the tutorial is sending messages to the webview, and + * that the webview is sending messages back to the VSCode side. Note that + * this log is maintained by the VSCode side, not the webview side, so + * `messageSent` means that the VSCode side sent a message to the webview, and + * `messageReceived` means that the VSCode side received a message from the + * webview. + */ + getTutorialWebviewEventLog(): SpyWebViewEvent[]; + /** * A thin wrapper around the VSCode API that allows us to mock it for testing. */ diff --git a/packages/vscode-common/src/VscodeApi.ts b/packages/vscode-common/src/VscodeApi.ts index 2d17206d7..5e63bd596 100644 --- a/packages/vscode-common/src/VscodeApi.ts +++ b/packages/vscode-common/src/VscodeApi.ts @@ -1,4 +1,4 @@ -import { workspace, window, TextEditor, env } from "vscode"; +import { workspace, window, TextEditor, env, commands } from "vscode"; /** * Subset of VSCode api that we need to be able to mock for testing @@ -7,6 +7,7 @@ export interface VscodeApi { workspace: typeof workspace; window: typeof window; env: typeof env; + commands: typeof commands; /** * Wrapper around editor api for easy mocking. Provides various diff --git a/packages/vscode-common/src/index.ts b/packages/vscode-common/src/index.ts index ca15cb6da..0994c2177 100644 --- a/packages/vscode-common/src/index.ts +++ b/packages/vscode-common/src/index.ts @@ -6,3 +6,4 @@ export * from "./vscodeUtil"; export * from "./runCommand"; export * from "./VscodeApi"; export * from "./ScopeVisualizerColorConfig"; +export * from "./SpyWebViewEvent"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7c0fd5d2..b34810548 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -590,6 +590,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.2.71 + version: 18.2.71 + '@types/react-dom': + specifier: 18.2.22 + version: 18.2.22 + '@types/vscode-webview': + specifier: 1.57.5 + version: 1.57.5 + tailwindcss: + specifier: 3.4.1 + version: 3.4.1(ts-node@10.9.2(@types/node@18.18.2)(typescript@5.4.3)) + packages/meta-updater: dependencies: '@cursorless/common': @@ -2973,6 +2998,9 @@ packages: '@types/vinyl@2.0.11': resolution: {integrity: sha512-vPXzCLmRp74e9LsP8oltnWKTH+jBwt86WgRUb4Pc9Lf3pkMVGyvIo2gm9bODeGfCay2DBB/hAWDuvf07JcK4rw==} + '@types/vscode-webview@1.57.5': + resolution: {integrity: sha512-iBAUYNYkz+uk1kdsq05fEcoh8gJmwT3lqqFPN7MGyjQ3HVloViMdo7ZJ8DFIP8WOK74PjOEilosqAyxV2iUFUw==} + '@types/vscode@1.75.1': resolution: {integrity: sha512-emg7wdsTFzdi+elvoyoA+Q8keEautdQHyY5LNmHVM4PTpY8JgOTVADrGVyXGepJ6dVW2OS5/xnLUWh+nZxvdiA==} @@ -12853,6 +12881,8 @@ snapshots: '@types/expect': 1.20.4 '@types/node': 18.18.2 + '@types/vscode-webview@1.57.5': {} + '@types/vscode@1.75.1': {} '@types/webpack@5.28.5(esbuild@0.20.2)(webpack-cli@5.1.4(@webpack-cli/generators@3.0.7)(webpack-dev-server@5.0.4)(webpack@5.91.0))': diff --git a/tsconfig.json b/tsconfig.json index 4c59680a1..223ad0f0d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -29,6 +29,9 @@ { "path": "./packages/cursorless-vscode-e2e" }, + { + "path": "./packages/cursorless-vscode-tutorial-webview" + }, { "path": "./packages/meta-updater" },