From 57ed77bb9feb8bc149864ea12d9c80f88b001e45 Mon Sep 17 00:00:00 2001 From: Pokey Rule <755842+pokey@users.noreply.github.com> Date: Sat, 13 Jul 2024 15:59:22 +0100 Subject: [PATCH] Cursorless tutorial --- .vscode/tasks.json | 22 + .../src/cheatsheet/cheat_sheet.py | 1 + cursorless-talon/src/cursorless.py | 68 ++- cursorless-talon/src/cursorless.talon | 11 + .../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 + packages/common/src/cursorlessCommandIds.ts | 16 + packages/common/src/ide/types/State.ts | 22 +- packages/common/src/index.ts | 1 + packages/common/src/types/tutorial.types.ts | 75 +++ .../src/api/CursorlessEngineApi.ts | 2 + .../cursorless-engine/src/api/Tutorial.ts | 128 +++++ .../src/core/ActionComponentHandler.ts | 42 ++ .../src/core/CursorlessCommandHandler.ts | 53 ++ .../src/core/GraphemeComponentHandler.ts | 24 + .../src/core/StepComponent.ts | 13 + .../src/core/TutorialError.ts | 12 + .../src/core/TutorialImpl.ts | 484 ++++++++++++++++++ .../src/core/TutorialScriptParser.ts | 129 +++++ .../src/core/getScopeTypeSpokenForm.ts | 19 + .../src/core/loadTutorialScript.ts | 17 + .../src/core/parseSpecialComponent.ts | 36 ++ .../src/core/parseVisualizeComponent.ts | 23 + .../src/core/specialTerms.ts | 3 + .../cursorless-engine/src/cursorlessEngine.ts | 22 +- .../CustomSpokenFormGeneratorImpl.ts | 4 + packages/cursorless-engine/src/index.ts | 1 + .../suite/tutorial/tutorial.vscode.test.ts | 220 ++++++++ .../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 | 58 +++ .../cursorless-vscode/src/VscodeTutorial.ts | 227 ++++++++ .../src/constructTestHelpers.ts | 5 + packages/cursorless-vscode/src/extension.ts | 12 + .../cursorless-vscode/src/registerCommands.ts | 12 +- .../src/scripts/populateDist/assets.ts | 12 + packages/cursorless-vscode/src/vscodeApi.ts | 3 +- packages/node-common/src/index.ts | 1 + 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 + 74 files changed, 3016 insertions(+), 12 deletions(-) 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/types/tutorial.types.ts create mode 100644 packages/cursorless-engine/src/api/Tutorial.ts create mode 100644 packages/cursorless-engine/src/core/ActionComponentHandler.ts create mode 100644 packages/cursorless-engine/src/core/CursorlessCommandHandler.ts create mode 100644 packages/cursorless-engine/src/core/GraphemeComponentHandler.ts create mode 100644 packages/cursorless-engine/src/core/StepComponent.ts create mode 100644 packages/cursorless-engine/src/core/TutorialError.ts create mode 100644 packages/cursorless-engine/src/core/TutorialImpl.ts create mode 100644 packages/cursorless-engine/src/core/TutorialScriptParser.ts create mode 100644 packages/cursorless-engine/src/core/getScopeTypeSpokenForm.ts create mode 100644 packages/cursorless-engine/src/core/loadTutorialScript.ts create mode 100644 packages/cursorless-engine/src/core/parseSpecialComponent.ts create mode 100644 packages/cursorless-engine/src/core/parseVisualizeComponent.ts create mode 100644 packages/cursorless-engine/src/core/specialTerms.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/vscode-common/src/SpyWebViewEvent.ts diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 412dd4c42fd..5fee2d1c39f 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 5d4b9385766..d3b89b62289 100644 --- a/cursorless-talon/src/cheatsheet/cheat_sheet.py +++ b/cursorless-talon/src/cheatsheet/cheat_sheet.py @@ -37,6 +37,7 @@ def private_cursorless_cheat_sheet_update_json(): def private_cursorless_open_instructions(): """Open web page with cursorless instructions""" + actions.user.private_cursorless_notify_docs_opened() webbrowser.open(instructions_url) diff --git a/cursorless-talon/src/cursorless.py b/cursorless-talon/src/cursorless.py index 9617f515933..c337d85bd32 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 @@ "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 @@ def private_cursorless_show_settings_in_ide(): 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 a748282ab68..86cce666ada 100644 --- a/cursorless-talon/src/cursorless.talon +++ b/cursorless-talon/src/cursorless.talon @@ -37,3 +37,14 @@ 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: 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 00000000000..8d1e7a8e8a0 --- /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 00000000000..dff637f9121 --- /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 00000000000..d9d8e75d34c --- /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 00000000000..4d316dd7b18 --- /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 00000000000..18c028280f6 --- /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 00000000000..a8fd1fea108 --- /dev/null +++ b/data/fixtures/recorded/tutorial/unit-1-basics/script.json @@ -0,0 +1,17 @@ +{ + "title": "Introduction", + "version": 0, + "steps": [ + "Say {step: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 {step:takeBlueSun.yml}", + "Selecting a single token is great, but oftentimes we need something bigger.\nSay {step: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: {step:takeNearAndSun.yml}", + "But let's show that cursorless can live up to its name: we can say {step: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: {step:chuckLineOdd.yml}", + "We can also use {scopeType:line} to refer to the line containing our cursor: {step:takeLine.yml}", + "You now know how to select and delete; let's give you a couple more actions to play with: say {action:pre} to place the cursor before a target, as in {step:preUrge.yml}", + "Say {action:post} to place the cursor after a target: {step:postAir.yml}", + "Say {action:change} to delete a word and move your cursor to where it used to be: {step: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 00000000000..33cfe5aa8a4 --- /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 00000000000..f038c821578 --- /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 00000000000..5ac706056c6 --- /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 00000000000..46481b52a95 --- /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 00000000000..b64a0d31140 --- /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 00000000000..f91b7b3d8b9 --- /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 00000000000..8e7da2f51bc --- /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 00000000000..de28729d20f --- /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 00000000000..f3bd7350236 --- /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 00000000000..5d7482ce838 --- /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 00000000000..9cb6944012d --- /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 00000000000..1cfdd1c7333 --- /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: {step: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. In the following command, we just say {scopeType:string} once, but cursorless infers that both targets are strings: {step: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: {step:pourUrge.yml}", + "Now let's try applying a cursorless action to the current line: {step:dedentThis.yml}", + "Code reuse is a fact of life as a programmer. Cursorless makes this easy with the {action:bring} command: {step:bringStateUrge.yml}", + "{action:bring} also works with two targets just like {action:swap}: {step:bringBlueCapToValueRisk.yml}", + "Cursorless tries its best to use its knowledge of programming languages to leave you with syntactically valid code. Note how it cleans up the comma here: {step:chuckArgueBlueVest.yml}", + "We introduced a lot of different scopes today. If you're anything like us, you've already forgotten them all. The important thing to remember is that you can always say {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 00000000000..3c07f875492 --- /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 00000000000..75d0c0b759e --- /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 00000000000..ac66cd3ef80 --- /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 00000000000..636809337d2 --- /dev/null +++ b/data/playground/tutorial/unit-2-basic-coding.py @@ -0,0 +1,13 @@ +def print_color(color, invert=False): + if invert: + print(invert_color(color)) + else: + print(color) + + +def invert_color(color): + if color == "black": + return "white" + + +print_color("black") diff --git a/packages/common/src/cursorlessCommandIds.ts b/packages/common/src/cursorlessCommandIds.ts index 5b4cf36f719..84180f1bc56 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 319629470c9..c9acc6b5e9a 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/index.ts b/packages/common/src/index.ts index dc36e39b8bc..2f67fed2907 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -53,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 00000000000..5faf5d9e87e --- /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 1c17cf26614..d99908df8a3 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 00000000000..8122e455292 --- /dev/null +++ b/packages/cursorless-engine/src/api/Tutorial.ts @@ -0,0 +1,128 @@ +import { + CommandComplete, + Disposable, + ScopeType, + TestCaseSnapshot, + TutorialId, + TutorialState, + TutorialStepFragment, +} from "@cursorless/common"; + +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 RawTutorialContent { + /** + * The title of the tutorial + */ + title: string; + + /** + * The version of the tutorial + */ + version: number; + + /** + * The steps of the current tutorial + */ + steps: string[]; +} + +/** + * 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"; +} + +export type TutorialStepTrigger = + | CommandTutorialStepTrigger + | CommandTutorialVisualizeTrigger + | HelpTutorialStepTrigger; + +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; +} + +export interface TutorialSetupStepArg { + /** + * The id of the current tutorial + */ + tutorialId: string; + + /** + * The yaml file for the current step + */ + fixturePath: string; +} + +export interface Tutorial { + start(id: TutorialId | number): Promise; + onState(callback: (state: TutorialState) => void): Disposable; + docsOpened(): void; + scopeTypeVisualized(scopeType: ScopeType | undefined): void; + next(): Promise; + previous(): Promise; + restart(): Promise; + resume(): Promise; + list(): Promise; + readonly state: TutorialState; +} diff --git a/packages/cursorless-engine/src/core/ActionComponentHandler.ts b/packages/cursorless-engine/src/core/ActionComponentHandler.ts new file mode 100644 index 00000000000..07c1d3028f7 --- /dev/null +++ b/packages/cursorless-engine/src/core/ActionComponentHandler.ts @@ -0,0 +1,42 @@ +import { ActionType } from "@cursorless/common"; +import { invertBy } from "lodash-es"; +import { CustomSpokenFormGeneratorImpl } from "../generateSpokenForm/CustomSpokenFormGeneratorImpl"; +import { defaultSpokenFormMap } from "../spokenForms/defaultSpokenFormMap"; +import { StepComponent, StepComponentParser } from "./StepComponent"; + +export class ActionComponentHandler implements StepComponentParser { + private actionMap: Record = invertBy( + defaultSpokenFormMap.action, + (val) => val.spokenForms[0], + ) as Record; + + constructor( + private customSpokenFormGenerator: CustomSpokenFormGeneratorImpl, + ) {} + + async parse(arg: string): Promise { + return { + content: { + type: "term", + value: this.getActionSpokenForm(this.parseActionId(arg)), + }, + }; + } + + private getActionSpokenForm(actionId: ActionType) { + const spokenForm = + this.customSpokenFormGenerator.actionIdToSpokenForm(actionId); + + return spokenForm.spokenForms[0]; + } + + private parseActionId(arg: string): ActionType { + const actionIds = this.actionMap[arg]; + + if (actionIds == null || actionIds.length === 0) { + throw new Error(`Unknown action: ${arg}`); + } + + return actionIds[0]; + } +} diff --git a/packages/cursorless-engine/src/core/CursorlessCommandHandler.ts b/packages/cursorless-engine/src/core/CursorlessCommandHandler.ts new file mode 100644 index 00000000000..13018d873b0 --- /dev/null +++ b/packages/cursorless-engine/src/core/CursorlessCommandHandler.ts @@ -0,0 +1,53 @@ +import { CommandComplete, TutorialId } from "@cursorless/common"; +import { loadFixture } from "@cursorless/node-common"; +import path from "path"; +import { CustomSpokenFormGeneratorImpl } from "../generateSpokenForm/CustomSpokenFormGeneratorImpl"; +import { StepComponent, StepComponentParser } from "./StepComponent"; +import { TutorialError } from "./TutorialError"; +import { canonicalizeAndValidateCommand } from "./commandVersionUpgrades/canonicalizeAndValidateCommand"; + +export class CursorlessCommandHandler implements StepComponentParser { + constructor( + private tutorialRootDir: string, + private tutorialId: TutorialId, + private customSpokenFormGenerator: CustomSpokenFormGeneratorImpl, + ) {} + + async parse(arg: string): Promise { + const fixture = await loadFixture( + path.join(this.tutorialRootDir, 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), + }, + }; + } + + /** + * Handle the argument of a "{step:cloneStateInk.yml}"" + */ + private getCommandSpokenForm(command: CommandComplete) { + // command to be said for moving to the next step + 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/core/GraphemeComponentHandler.ts b/packages/cursorless-engine/src/core/GraphemeComponentHandler.ts new file mode 100644 index 00000000000..25aa224271c --- /dev/null +++ b/packages/cursorless-engine/src/core/GraphemeComponentHandler.ts @@ -0,0 +1,24 @@ +import { CustomSpokenFormGeneratorImpl } from "../generateSpokenForm/CustomSpokenFormGeneratorImpl"; +import { StepComponent, StepComponentParser } from "./StepComponent"; + +export class GraphemeComponentHandler 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/core/StepComponent.ts b/packages/cursorless-engine/src/core/StepComponent.ts new file mode 100644 index 00000000000..501f2482a0c --- /dev/null +++ b/packages/cursorless-engine/src/core/StepComponent.ts @@ -0,0 +1,13 @@ +import { TestCaseSnapshot, TutorialStepFragment } from "@cursorless/common"; +import { TutorialStepTrigger } from "../api/Tutorial"; + +export interface StepComponent { + initialState?: TestCaseSnapshot; + languageId?: string; + trigger?: TutorialStepTrigger; + content: TutorialStepFragment; +} + +export interface StepComponentParser { + parse(arg: string): Promise; +} diff --git a/packages/cursorless-engine/src/core/TutorialError.ts b/packages/cursorless-engine/src/core/TutorialError.ts new file mode 100644 index 00000000000..a7042bc0117 --- /dev/null +++ b/packages/cursorless-engine/src/core/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/core/TutorialImpl.ts b/packages/cursorless-engine/src/core/TutorialImpl.ts new file mode 100644 index 00000000000..7ba6191d093 --- /dev/null +++ b/packages/cursorless-engine/src/core/TutorialImpl.ts @@ -0,0 +1,484 @@ +import { + CommandComplete, + Disposable, + HatTokenMap, + Notifier, + ReadOnlyHatMap, + ScopeType, + TextEditor, + TutorialId, + TutorialInfo, + TutorialState, + plainObjectToRange, + plainObjectToSelection, + serializedMarksToTokenHats, + toCharacterRange, +} from "@cursorless/common"; +import { produce } from "immer"; +import { isEqual } from "lodash-es"; +import { readdir } from "node:fs/promises"; +import path from "path"; +import { CommandRunner } from "../CommandRunner"; +import { CommandRunnerDecorator } from "../api/CursorlessEngineApi"; +import { Tutorial, TutorialContent, TutorialStep } from "../api/Tutorial"; +import { CustomSpokenFormGeneratorImpl } from "../generateSpokenForm/CustomSpokenFormGeneratorImpl"; +import { ide } from "../singletons/ide.singleton"; +import { Debouncer } from "./Debouncer"; +import { TutorialError } from "./TutorialError"; +import { TutorialScriptParser } from "./TutorialScriptParser"; +import { loadTutorialScript } from "./loadTutorialScript"; + +const HIGHLIGHT_COLOR = "highlight0"; + +export class TutorialImpl implements Tutorial, CommandRunnerDecorator { + private tutorialRootDir: string; + private editor?: TextEditor; + private state_: TutorialState = { type: "loading" }; + private notifier: Notifier<[TutorialState]> = new Notifier(); + private currentTutorial: TutorialContent | undefined; + private disposables: Disposable[] = []; + private tutorials!: TutorialInfo[]; + + constructor( + private hatTokenMap: HatTokenMap, + private customSpokenFormGenerator: CustomSpokenFormGeneratorImpl, + ) { + this.setupStep = this.setupStep.bind(this); + this.reparseCurrentTutorial = this.reparseCurrentTutorial.bind(this); + const debouncer = new Debouncer(() => this.checkPreconditions(), 100); + + this.tutorialRootDir = path.join(ide().assetsRoot, "tutorial"); + + this.loadTutorialList(); + + 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 loadTutorialList() { + const tutorialDirs = await readdir(this.tutorialRootDir, { + withFileTypes: true, + }); + + const tutorialProgress = ide().globalState.get("tutorialProgress"); + + this.tutorials = await Promise.all( + tutorialDirs + .filter((dirent) => dirent.isDirectory()) + .map(async (dirent) => { + const tutorialId = dirent.name as TutorialId; + const rawContent = await loadTutorialScript( + this.tutorialRootDir, + tutorialId, + ); + + return { + id: tutorialId, + title: rawContent.title, + version: rawContent.version, + stepCount: rawContent.steps.length, + currentStep: tutorialProgress[tutorialId]?.currentStep ?? 0, + }; + }), + ); + + this.setState({ + type: "pickingTutorial", + tutorials: this.tutorials, + }); + } + + dispose() { + for (const disposable of this.disposables) { + disposable.dispose(); + } + } + + wrapCommandRunner( + _readableHatMap: ReadOnlyHatMap, + commandRunner: CommandRunner, + ): CommandRunner { + if (this.state_.type !== "doingTutorial") { + return commandRunner; + } + + const currentStep = this.currentTutorial?.steps[this.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 this.next(); + } + + return returnValue; + }, + }; + } + + public onState(callback: (state: TutorialState) => void): Disposable { + return this.notifier.registerListener(callback); + } + + private async reparseCurrentTutorial() { + if (this.currentTutorial == null || this.state_.type !== "doingTutorial") { + return; + } + + const tutorialId = this.state_.id; + + const rawContent = await loadTutorialScript( + this.tutorialRootDir, + tutorialId, + ); + + const parser = new TutorialScriptParser( + this.tutorialRootDir, + tutorialId, + this.customSpokenFormGenerator, + ); + + try { + const hadErrors = this.state_.hasErrors; + + this.currentTutorial.steps = await Promise.all( + rawContent.steps.map(parser.parseTutorialStep), + ); + + this.setState({ + ...this.state_, + hasErrors: false, + stepContent: this.currentTutorial.steps[this.state_.stepNumber].content, + stepCount: this.currentTutorial.steps.length, + }); + + if (hadErrors) { + await this.setupStep(); + } + } catch (err) { + this.currentTutorial.steps = []; + this.setState({ + ...this.state_, + hasErrors: true, + requiresTalonUpdate: + err instanceof TutorialError && err.requiresTalonUpdate, + }); + } + } + + async start(tutorialId: TutorialId | number): Promise { + if (typeof tutorialId === "number") { + tutorialId = this.tutorials[tutorialId].id; + } + + const rawContent = await loadTutorialScript( + this.tutorialRootDir, + tutorialId, + ); + + const parser = new TutorialScriptParser( + this.tutorialRootDir, + tutorialId, + this.customSpokenFormGenerator, + ); + + try { + this.currentTutorial = { + 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 >= this.currentTutorial.steps.length - 1) { + stepNumber = 0; + } + + this.setState({ + type: "doingTutorial", + hasErrors: false, + id: tutorialId, + stepNumber, + stepContent: this.currentTutorial.steps[stepNumber].content, + stepCount: this.currentTutorial.steps.length, + title: this.currentTutorial.title, + preConditionsMet: true, + }); + } catch (err) { + this.currentTutorial = { + title: rawContent.title, + steps: [], + version: rawContent.version, + }; + this.setState({ + type: "doingTutorial", + hasErrors: true, + id: tutorialId, + stepNumber: 0, + title: this.currentTutorial.title, + preConditionsMet: true, + requiresTalonUpdate: + err instanceof TutorialError && err.requiresTalonUpdate, + }); + } + + await this.setupStep(); + } + + docsOpened() { + if (this.state_.type === "doingTutorial") { + const currentStep = this.currentTutorial!.steps[this.state_.stepNumber]; + if (currentStep.trigger?.type === "help") { + this.next(); + } + } + } + + private async changeStep( + getStep: (current: number) => number, + ): Promise { + if (this.state_.type === "doingTutorial" && !this.state_.hasErrors) { + const newStepNumber = getStep(this.state_.stepNumber); + + if (newStepNumber === this.state_.stepCount || newStepNumber < 0) { + await this.loadTutorialList(); + } else { + 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() { + await this.loadTutorialList(); + + 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_; + } + + /** + * Handle the "cursorless.tutorial.setupStep" command + * @see packages/cursorless-vscode-e2e/src/suite/recorded.vscode.test.ts + */ + private async setupStep(retry = true) { + if (this.state_.type !== "doingTutorial") { + if (this.editor != null) { + ide().setHighlightRanges(HIGHLIGHT_COLOR, this.editor, []); + this.editor = undefined; + } + return; + } + + const { initialState: snapshot, languageId = "plaintext" } = + this.currentTutorial!.steps[this.state_.stepNumber]; + + if (snapshot == null) { + if (this.editor != null) { + ide().setHighlightRanges(HIGHLIGHT_COLOR, this.editor, []); + } + return; + } + + try { + if (this.editor == null) { + this.editor = await ide().openUntitledTextDocument({ + content: snapshot.documentContents, + language: languageId, + }); + retry = false; + } + + const editableEditor = ide().getEditableTextEditor(this.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 this.hatTokenMap.allocateHats( + serializedMarksToTokenHats(snapshot.marks, this.editor), + ); + + ide().setHighlightRanges( + HIGHLIGHT_COLOR, + this.editor, + Object.values(snapshot.marks ?? {}).map((range) => + toCharacterRange(plainObjectToRange(range)), + ), + ); + + await editableEditor.focus(); + } catch (err) { + if (retry) { + this.editor = undefined; + await this.setupStep(false); + } else { + throw err; + } + } + } + + private async checkPreconditions() { + if (this.state_.type === "doingTutorial") { + const currentStep = this.currentTutorial!.steps[this.state_.stepNumber]; + + const preConditionsMet = await this.arePreconditionsMet(currentStep); + if (preConditionsMet !== this.state_.preConditionsMet) { + this.setState({ + ...this.state_, + preConditionsMet, + }); + } + } + } + + private async arePreconditionsMet({ + initialState: snapshot, + languageId, + }: TutorialStep): Promise { + if (snapshot == null) { + return true; + } + + if (ide().activeTextEditor !== this.editor) { + return false; + } + + if (this.editor == null || this.editor.document.languageId !== languageId) { + return false; + } + + if (this.editor.document.getText() !== snapshot.documentContents) { + return false; + } + + if ( + !isEqual( + this.editor.selections, + snapshot.selections.map(plainObjectToSelection), + ) + ) { + return false; + } + + const readableHatMap = await this.hatTokenMap.getReadableMap(false); + for (const mark of serializedMarksToTokenHats( + snapshot.marks, + this.editor, + )) { + if ( + !readableHatMap + .getToken(mark.hatStyle, mark.grapheme) + ?.range.isRangeEqual(mark.hatRange) + ) { + return false; + } + } + + return true; + } +} diff --git a/packages/cursorless-engine/src/core/TutorialScriptParser.ts b/packages/cursorless-engine/src/core/TutorialScriptParser.ts new file mode 100644 index 00000000000..cc43734d018 --- /dev/null +++ b/packages/cursorless-engine/src/core/TutorialScriptParser.ts @@ -0,0 +1,129 @@ +import { + TestCaseSnapshot, + TutorialId, + TutorialStepFragment, +} from "@cursorless/common"; +import { TutorialStep, TutorialStepTrigger } from "../api/Tutorial"; +import { parseScopeType } from "../customCommandGrammar/parseCommand"; +import { CustomSpokenFormGeneratorImpl } from "../generateSpokenForm/CustomSpokenFormGeneratorImpl"; +import { StepComponent } from "./StepComponent"; +import { CursorlessCommandHandler } from "./CursorlessCommandHandler"; +import { ActionComponentHandler } from "./ActionComponentHandler"; +import { parseSpecialComponent } from "./parseSpecialComponent"; +import { GraphemeComponentHandler } from "./GraphemeComponentHandler"; +import { getScopeTypeSpokenForm } from "./getScopeTypeSpokenForm"; +import { parseVisualizeComponent } from "./parseVisualizeComponent"; +import { specialTerms } from "./specialTerms"; + +// this is trying to catch occurrences of things like "{step:cloneStateInk.yml}" +const re = /{(\w+):([^}]+)}/g; + +export class TutorialScriptParser { + private componentStepParsers: Record< + string, + (arg: string) => Promise + >; + + constructor( + tutorialRootDir: string, + tutorialId: TutorialId, + customSpokenFormGenerator: CustomSpokenFormGeneratorImpl, + ) { + this.parseTutorialStep = this.parseTutorialStep.bind(this); + + const cursorlessCommandHandler = new CursorlessCommandHandler( + tutorialRootDir, + tutorialId, + customSpokenFormGenerator, + ); + + const actionHandler = new ActionComponentHandler(customSpokenFormGenerator); + + const graphemeHandler = new GraphemeComponentHandler( + customSpokenFormGenerator, + ); + + this.componentStepParsers = { + step: (arg) => cursorlessCommandHandler.parse(arg), + special: parseSpecialComponent, + action: (arg) => actionHandler.parse(arg), + grapheme: (arg) => graphemeHandler.parse(arg), + + term: async (arg) => ({ + content: { + type: "term", + value: specialTerms[arg as keyof typeof specialTerms], + }, + }), + + scopeType: async (arg) => ({ + content: { + type: "term", + value: getScopeTypeSpokenForm( + customSpokenFormGenerator, + parseScopeType(arg), + ), + }, + }), + + visualize: (arg) => + parseVisualizeComponent(customSpokenFormGenerator, arg), + }; + } + + async parseTutorialStep(rawContent: string): Promise { + let trigger: TutorialStepTrigger | undefined = undefined; + let initialState: TestCaseSnapshot | undefined = undefined; + let languageId: string | undefined = undefined; + const content: TutorialStepFragment[][] = []; + + for (const line of rawContent.split("\n")) { + const lineContent: TutorialStepFragment[] = []; + let currentIndex = 0; + re.lastIndex = 0; + + for (const { + 0: { length }, + 1: type, + 2: arg, + index, + } of line.matchAll(re)) { + if (index > currentIndex) { + lineContent.push({ + type: "string", + value: line.slice(currentIndex, index), + }); + } + + currentIndex = index + length; + + const result = await this.componentStepParsers[type](arg); + + if (result == null) { + throw new Error(`Unknown component type: ${type}`); + } + + lineContent.push(result.content); + trigger ??= result.trigger; + languageId ??= result.languageId; + initialState ??= result.initialState; + } + + if (currentIndex < line.length) { + lineContent.push({ + type: "string", + value: line.slice(currentIndex), + }); + } + + content.push(lineContent); + } + + return { + content, + trigger, + initialState, + languageId, + }; + } +} diff --git a/packages/cursorless-engine/src/core/getScopeTypeSpokenForm.ts b/packages/cursorless-engine/src/core/getScopeTypeSpokenForm.ts new file mode 100644 index 00000000000..624f61530e3 --- /dev/null +++ b/packages/cursorless-engine/src/core/getScopeTypeSpokenForm.ts @@ -0,0 +1,19 @@ +import { ScopeType } from "@cursorless/common"; +import { CustomSpokenFormGeneratorImpl } from "../generateSpokenForm/CustomSpokenFormGeneratorImpl"; +import { TutorialError } from "./TutorialError"; + +export function getScopeTypeSpokenForm( + 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/core/loadTutorialScript.ts b/packages/cursorless-engine/src/core/loadTutorialScript.ts new file mode 100644 index 00000000000..6ab7729af8f --- /dev/null +++ b/packages/cursorless-engine/src/core/loadTutorialScript.ts @@ -0,0 +1,17 @@ +import { readFile } from "node:fs/promises"; +import path from "path"; +import { RawTutorialContent } from "../api/Tutorial"; + +/** + * Load the "script.json" script for the current tutorial + */ +export async function loadTutorialScript( + tutorialRootDir: string, + tutorialName: string, +): Promise { + const buffer = await readFile( + path.join(tutorialRootDir, tutorialName, "script.json"), + ); + + return JSON.parse(buffer.toString()); +} diff --git a/packages/cursorless-engine/src/core/parseSpecialComponent.ts b/packages/cursorless-engine/src/core/parseSpecialComponent.ts new file mode 100644 index 00000000000..cdf6a64e813 --- /dev/null +++ b/packages/cursorless-engine/src/core/parseSpecialComponent.ts @@ -0,0 +1,36 @@ +import { TutorialStepTrigger } from "../api/Tutorial"; +import { StepComponent } from "./StepComponent"; + +const SPECIAL_COMMANDS = { + help: "cursorless help", + next: "tutorial next", + visualizeNothing: "visualize nothing", +}; + +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/core/parseVisualizeComponent.ts b/packages/cursorless-engine/src/core/parseVisualizeComponent.ts new file mode 100644 index 00000000000..bd9e702ffc5 --- /dev/null +++ b/packages/cursorless-engine/src/core/parseVisualizeComponent.ts @@ -0,0 +1,23 @@ +import { parseScopeType } from "../customCommandGrammar/parseCommand"; +import { CustomSpokenFormGeneratorImpl } from "../generateSpokenForm/CustomSpokenFormGeneratorImpl"; +import { StepComponent } from "./StepComponent"; +import { getScopeTypeSpokenForm } from "./getScopeTypeSpokenForm"; +import { specialTerms } from "./specialTerms"; + +export async function parseVisualizeComponent( + customSpokenFormGenerator: CustomSpokenFormGeneratorImpl, + arg: string, +): Promise { + const scopeType = parseScopeType(arg); + + return { + content: { + type: "command", + value: `${specialTerms.visualize} ${getScopeTypeSpokenForm(customSpokenFormGenerator, scopeType)}`, + }, + trigger: { + type: "visualize", + scopeType, + }, + }; +} diff --git a/packages/cursorless-engine/src/core/specialTerms.ts b/packages/cursorless-engine/src/core/specialTerms.ts new file mode 100644 index 00000000000..9ad5386ad64 --- /dev/null +++ b/packages/cursorless-engine/src/core/specialTerms.ts @@ -0,0 +1,3 @@ +export const specialTerms = { + visualize: "visualize", +}; diff --git a/packages/cursorless-engine/src/cursorlessEngine.ts b/packages/cursorless-engine/src/cursorlessEngine.ts index fa4fc541f2a..853a7998a48 100644 --- a/packages/cursorless-engine/src/cursorlessEngine.ts +++ b/packages/cursorless-engine/src/cursorlessEngine.ts @@ -18,6 +18,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 "./core/TutorialImpl"; import { RangeUpdater } from "./core/updateSelections/RangeUpdater"; import { DisabledCommandServerApi } from "./disabledComponents/DisabledCommandServerApi"; import { DisabledHatTokenMap } from "./disabledComponents/DisabledHatTokenMap"; @@ -94,6 +95,22 @@ export async function createCursorlessEngine({ const commandRunnerDecorators: CommandRunnerDecorator[] = []; + const addCommandRunnerDecorator = (decorator: CommandRunnerDecorator) => { + commandRunnerDecorators.push(decorator); + }; + + const tutorial = new TutorialImpl(hatTokenMap, customSpokenFormGenerator); + addCommandRunnerDecorator(tutorial); + + ide.disposeOnExit( + debug, + hatTokenMap, + keyboardTargetUpdater, + languageDefinitions, + rangeUpdater, + tutorial, + ); + let previousCommand: Command | undefined = undefined; const runCommandClosure = (command: Command) => { @@ -141,9 +158,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/generateSpokenForm/CustomSpokenFormGeneratorImpl.ts b/packages/cursorless-engine/src/generateSpokenForm/CustomSpokenFormGeneratorImpl.ts index ed496e08cc3..b97da0534bf 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 61387aa7943..d1b22b0f8c7 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 "./testUtil/takeSnapshot"; export * from "./core/StoredTargets"; export * from "./cursorlessEngine"; 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 00000000000..d4413ac9f1a --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/tutorial/tutorial.vscode.test.ts @@ -0,0 +1,220 @@ +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"; + +suite("tutorial", async function () { + const { getSpy } = endToEndTestSetup(this); + + 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( + ["takeWhale.yml", "takeBlueSun.yml", "takeEachPastKick.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(150); + + assert.deepStrictEqual(getTutorialWebviewEventLog(), [ + // 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: + "Every cursorless command consists of an action performed on a target. For example, say the command ", + }, + { + type: "command", + value: "take whale", + }, + { + type: "string", + value: " to select the token with a grey hat over the 'w'.", + }, + ], + stepCount: 11, + title: "Introduction", + preConditionsMet: true, + }, + }, + ]); + + // 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); + + const log = getTutorialWebviewEventLog(); + assert(log.length === 3); + 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 00000000000..e4d3fabb04c --- /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 00000000000..d998110445d --- /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 00000000000..f2076c21095 --- /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 00000000000..08cc8533831 --- /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 00000000000..139d81cc062 --- /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 00000000000..48f5538264a --- /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 00000000000..f3ee6ff5957 --- /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 00000000000..b5c61c95671 --- /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 00000000000..c52cfae5bc7 --- /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 00000000000..2da99109805 --- /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 00000000000..121770ed680 --- /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 79a406eb694..8f19fa4d252 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 00000000000..3c1827617ff --- /dev/null +++ b/packages/cursorless-vscode/src/SpyWebviewView.ts @@ -0,0 +1,58 @@ +import { Disposable } from "@cursorless/common"; +import { cloneDeep } from "lodash-es"; +import { Uri, Webview, WebviewView } from "vscode"; +import { SpyWebViewEvent } from "@cursorless/vscode-common"; + +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; + } +} + +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 }); + } +} diff --git a/packages/cursorless-vscode/src/VscodeTutorial.ts b/packages/cursorless-vscode/src/VscodeTutorial.ts new file mode 100644 index 00000000000..480f02bf1de --- /dev/null +++ b/packages/cursorless-vscode/src/VscodeTutorial.ts @@ -0,0 +1,227 @@ +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, + ) { + console.log("resolveWebviewView"); + 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(tutorialId: TutorialId) { + await this.tutorial.start(tutorialId); + 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 86c2ec79c10..47c29e8bbdc 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 27f5d666c31..ebd33e46371 100644 --- a/packages/cursorless-vscode/src/extension.ts +++ b/packages/cursorless-vscode/src/extension.ts @@ -59,6 +59,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. @@ -112,6 +113,7 @@ export async function activate( runIntegrationTests, addCommandRunnerDecorator, customSpokenFormGenerator, + tutorial, } = await createCursorlessEngine({ ide: normalizedIde, hats, @@ -161,6 +163,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 +181,7 @@ export async function activate( scopeVisualizer, keyboardCommands, hats, + vscodeTutorial, storedTargets, ); @@ -189,6 +200,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 37b2b9a3988..6aefe97565f 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 8f0b1cbde83..20bb8ea208f 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 e1f8f7b65cc..72c9a1de18d 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/index.ts b/packages/node-common/src/index.ts index 59aa0a5621e..6eb6c2b07b7 100644 --- a/packages/node-common/src/index.ts +++ b/packages/node-common/src/index.ts @@ -8,3 +8,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 00000000000..7e737aab294 --- /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 11c740f4606..ed6ee91f2e6 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 2d17206d726..5e63bd596b8 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 ca15cb6dade..0994c217779 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 b7c0fd5d2d9..b3481054898 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 4c59680a169..223ad0f0d91 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" },