diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 412dd4c42fd..f6b32d1e521 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" }, 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..d7383d09db7 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,61 @@ 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_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 f1ab5aa2065..8d20851face 100644 --- a/cursorless-talon/src/cursorless.talon +++ b/cursorless-talon/src/cursorless.talon @@ -37,3 +37,13 @@ bar {user.cursorless_homophone}: {user.cursorless_homophone} stats: user.private_cursorless_show_command_statistics() + +{user.cursorless_homophone} tutorial: + user.private_cursorless_start_tutorial() + +tutorial next: user.private_cursorless_tutorial_next() +tutorial previous: user.private_cursorless_tutorial_previous() +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/chuckDrum.yml b/data/fixtures/recorded/tutorial/unit-1-basics/chuckDrum.yml new file mode 100644 index 00000000000..e3b02d98f44 --- /dev/null +++ b/data/fixtures/recorded/tutorial/unit-1-basics/chuckDrum.yml @@ -0,0 +1,50 @@ +languageId: plaintext +command: + version: 6 + spokenForm: chuck drum + action: + name: remove + target: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: d} + usePrePhraseSnapshot: false +initialState: + documentContents: |- + ================================================== + ========== ========== + ========== Welcome to Cursorless! ========== + ========== ========== + ========== Let's start using marks ========== + ========== ========== + ========== so we can navigate around ========== + ========== ========== + ========== without lifting a finger! ========== + ========== ========== + ================================================== + selections: + - anchor: {line: 2, character: 14} + active: {line: 2, character: 21} + - anchor: {line: 6, character: 15} + active: {line: 6, character: 17} + marks: + default.d: + start: {line: 6, character: 31} + end: {line: 6, character: 37} +finalState: + documentContents: |- + ================================================== + ========== ========== + ========== Welcome to Cursorless! ========== + ========== ========== + ========== Let's start using marks ========== + ========== ========== + ========== so we can navigate ========== + ========== ========== + ========== without lifting a finger! ========== + ========== ========== + ================================================== + selections: + - anchor: {line: 2, character: 14} + active: {line: 2, character: 21} + - anchor: {line: 6, character: 15} + active: {line: 6, character: 17} diff --git a/data/fixtures/recorded/tutorial/unit-1-basics/chuckLineLook.yml b/data/fixtures/recorded/tutorial/unit-1-basics/chuckLineLook.yml new file mode 100644 index 00000000000..3cbbb5d3e5c --- /dev/null +++ b/data/fixtures/recorded/tutorial/unit-1-basics/chuckLineLook.yml @@ -0,0 +1,52 @@ +languageId: plaintext +command: + version: 6 + spokenForm: chuck line look + action: + name: remove + target: + type: primitive + modifiers: + - type: containingScope + scopeType: {type: line} + mark: {type: decoratedSymbol, symbolColor: default, character: l} + usePrePhraseSnapshot: false +initialState: + documentContents: |- + ================================================== + ========== ========== + ========== Welcome to Cursorless! ========== + ========== ========== + ========== Let's start using marks ========== + ========== ========== + ========== so we can navigate ========== + ========== ========== + ========== without lifting a finger! ========== + ========== ========== + ================================================== + selections: + - anchor: {line: 2, character: 14} + active: {line: 2, character: 21} + - anchor: {line: 6, character: 15} + active: {line: 6, character: 17} + marks: + default.l: + start: {line: 4, character: 13} + end: {line: 4, character: 16} +finalState: + documentContents: |- + ================================================== + ========== ========== + ========== Welcome to Cursorless! ========== + ========== ========== + ========== ========== + ========== so we can navigate ========== + ========== ========== + ========== without lifting a finger! ========== + ========== ========== + ================================================== + selections: + - anchor: {line: 2, character: 14} + active: {line: 2, character: 21} + - anchor: {line: 5, character: 15} + active: {line: 5, character: 17} diff --git a/data/fixtures/recorded/tutorial/unit-1-basics/clearTrap.yml b/data/fixtures/recorded/tutorial/unit-1-basics/clearTrap.yml new file mode 100644 index 00000000000..d4ebd4430fb --- /dev/null +++ b/data/fixtures/recorded/tutorial/unit-1-basics/clearTrap.yml @@ -0,0 +1,44 @@ +languageId: plaintext +command: + version: 6 + spokenForm: change trap + action: + name: clearAndSetSelection + target: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: t} + usePrePhraseSnapshot: false +initialState: + documentContents: |- + ================================================== + ========== ========== + ========== Welcome to Cursorless! ========== + ========== ========== + ========== ========== + ========== so we can navigate ========== + ========== ========== + ========== without lifting a finger! ========== + ========== ========== + ================================================== + selections: + - anchor: {line: 7, character: 27} + active: {line: 7, character: 27} + marks: + default.t: + start: {line: 2, character: 22} + end: {line: 2, character: 24} +finalState: + documentContents: |- + ================================================== + ========== ========== + ========== Welcome Cursorless! ========== + ========== ========== + ========== ========== + ========== so we can navigate ========== + ========== ========== + ========== without lifting a finger! ========== + ========== ========== + ================================================== + selections: + - anchor: {line: 2, character: 22} + active: {line: 2, character: 22} diff --git a/data/fixtures/recorded/tutorial/unit-1-basics/clearWhaleWordYou.yml b/data/fixtures/recorded/tutorial/unit-1-basics/clearWhaleWordYou.yml new file mode 100644 index 00000000000..e195c116354 --- /dev/null +++ b/data/fixtures/recorded/tutorial/unit-1-basics/clearWhaleWordYou.yml @@ -0,0 +1,44 @@ +languageId: plaintext +command: + version: 6 + spokenForm: change whale + action: + name: clearAndSetSelection + target: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: w} + usePrePhraseSnapshot: false +initialState: + documentContents: |- + ================================================== + ========== ========== + ========== Welcome Cursorless! ========== + ========== ========== + ========== ========== + ========== so we can navigate ========== + ========== ========== + ========== without lifting a finger! ========== + ========== ========== + ================================================== + selections: + - anchor: {line: 2, character: 22} + active: {line: 2, character: 22} + marks: + default.w: + start: {line: 5, character: 15} + end: {line: 5, character: 17} +finalState: + documentContents: |- + ================================================== + ========== ========== + ========== Welcome Cursorless! ========== + ========== ========== + ========== ========== + ========== so can navigate ========== + ========== ========== + ========== without lifting a finger! ========== + ========== ========== + ================================================== + selections: + - anchor: {line: 5, character: 15} + active: {line: 5, character: 15} diff --git a/data/fixtures/recorded/tutorial/unit-1-basics/postLook.yml b/data/fixtures/recorded/tutorial/unit-1-basics/postLook.yml new file mode 100644 index 00000000000..5e8f0c83557 --- /dev/null +++ b/data/fixtures/recorded/tutorial/unit-1-basics/postLook.yml @@ -0,0 +1,44 @@ +languageId: plaintext +command: + version: 6 + spokenForm: post look + action: + name: setSelectionAfter + target: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: l} + usePrePhraseSnapshot: false +initialState: + documentContents: |- + ================================================== + ========== ========== + ========== Welcome to Cursorless! ========== + ========== ========== + ========== ========== + ========== so we can navigate ========== + ========== ========== + ========== without lifting a finger! ========== + ========== ========== + ================================================== + selections: + - anchor: {line: 7, character: 12} + active: {line: 7, character: 12} + marks: + default.l: + start: {line: 7, character: 20} + end: {line: 7, character: 27} +finalState: + documentContents: |- + ================================================== + ========== ========== + ========== Welcome to Cursorless! ========== + ========== ========== + ========== ========== + ========== so we can navigate ========== + ========== ========== + ========== without lifting a finger! ========== + ========== ========== + ================================================== + selections: + - anchor: {line: 7, character: 27} + active: {line: 7, character: 27} diff --git a/data/fixtures/recorded/tutorial/unit-1-basics/preInk.yml b/data/fixtures/recorded/tutorial/unit-1-basics/preInk.yml new file mode 100644 index 00000000000..4e93164b93b --- /dev/null +++ b/data/fixtures/recorded/tutorial/unit-1-basics/preInk.yml @@ -0,0 +1,46 @@ +languageId: plaintext +command: + version: 6 + spokenForm: pre sit + action: + name: setSelectionBefore + target: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: i} + usePrePhraseSnapshot: false +initialState: + documentContents: |- + ================================================== + ========== ========== + ========== Welcome to Cursorless! ========== + ========== ========== + ========== ========== + ========== so we can navigate ========== + ========== ========== + ========== without lifting a finger! ========== + ========== ========== + ================================================== + selections: + - anchor: {line: 2, character: 0} + active: {line: 2, character: 50} + - anchor: {line: 5, character: 0} + active: {line: 5, character: 41} + marks: + default.i: + start: {line: 7, character: 12} + end: {line: 7, character: 19} +finalState: + documentContents: |- + ================================================== + ========== ========== + ========== Welcome to Cursorless! ========== + ========== ========== + ========== ========== + ========== so we can navigate ========== + ========== ========== + ========== without lifting a finger! ========== + ========== ========== + ================================================== + selections: + - anchor: {line: 7, character: 12} + active: {line: 7, character: 12} diff --git a/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..5ed24be57d0 --- /dev/null +++ b/data/fixtures/recorded/tutorial/unit-1-basics/script.json @@ -0,0 +1,16 @@ +{ + "title": "Introduction", + "steps": [ + "Every cursorless command consists of an action performed on a target. For example, the command {step:takeWhale.yml} selects the token with a grey hat over the 'w'.", + "When a hat is not gray, we need to use a color to refer to it: {step:takeBlueSun.yml}", + "Selecting a single token is great but oftentimes we need something bigger. Say {step:takeEachPastKick.yml} to select a range.", + "Despite its name, one of the most powerful aspects of cursorless is the ability to use more than one cursor. Let's try that: {step:takeCapAndWhale.yml}", + "But let's show that cursorless can live up to its name: we can say {step:chuckDrum.yml} to delete a word without ever moving our cursor.", + "Tokens are great, but they're just one way to think of a document. Let's try working with lines: {step:chuckLineLook.yml}", + "We can also use {scopeType:line} to refer to the line containing our cursor: {step:takeLine.yml}", + "You now know how to select and delete; let's give you a couple more actions to play with: say {action:pre} to place the cursor before a target, as in {step:preInk.yml}", + "Say {action:post} to place the cursor after a target: {step:postLook.yml}", + "Say {action:change} to delete a word and move your cursor to where it used to be: {step:clearTrap.yml}", + "And that wraps up unit 1 of the cursorless tutorial! Next time, we'll write some code 🙌. Say {special:next} to get back home." + ] +} 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..04689305df9 --- /dev/null +++ b/data/fixtures/recorded/tutorial/unit-1-basics/takeBlueSun.yml @@ -0,0 +1,46 @@ +languageId: plaintext +command: + version: 6 + spokenForm: take blue sun + action: + name: setSelection + target: + type: primitive + mark: {type: decoratedSymbol, symbolColor: blue, character: s} + usePrePhraseSnapshot: false +initialState: + documentContents: |- + ================================================== + ========== ========== + ========== Welcome to Cursorless! ========== + ========== ========== + ========== Let's start using marks ========== + ========== ========== + ========== so we can navigate around ========== + ========== ========== + ========== without lifting a finger! ========== + ========== ========== + ================================================== + selections: + - anchor: {line: 2, character: 14} + active: {line: 2, character: 21} + marks: + blue.s: + start: {line: 6, character: 12} + end: {line: 6, character: 14} +finalState: + documentContents: |- + ================================================== + ========== ========== + ========== Welcome to Cursorless! ========== + ========== ========== + ========== Let's start using marks ========== + ========== ========== + ========== so we can navigate around ========== + ========== ========== + ========== without lifting a finger! ========== + ========== ========== + ================================================== + selections: + - anchor: {line: 6, character: 12} + active: {line: 6, character: 14} diff --git a/data/fixtures/recorded/tutorial/unit-1-basics/takeCapAndWhale.yml b/data/fixtures/recorded/tutorial/unit-1-basics/takeCapAndWhale.yml new file mode 100644 index 00000000000..87307a9be7c --- /dev/null +++ b/data/fixtures/recorded/tutorial/unit-1-basics/takeCapAndWhale.yml @@ -0,0 +1,55 @@ +languageId: plaintext +command: + version: 6 + spokenForm: take cap and whale + action: + name: setSelection + target: + type: list + elements: + - type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: c} + - type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: w} + usePrePhraseSnapshot: false +initialState: + documentContents: |- + ================================================== + ========== ========== + ========== Welcome to Cursorless! ========== + ========== ========== + ========== Let's start using marks ========== + ========== ========== + ========== so we can navigate around ========== + ========== ========== + ========== without lifting a finger! ========== + ========== ========== + ================================================== + selections: + - anchor: {line: 4, character: 13} + active: {line: 4, character: 36} + marks: + default.c: + start: {line: 2, character: 14} + end: {line: 2, character: 21} + default.w: + start: {line: 6, character: 15} + end: {line: 6, character: 17} +finalState: + documentContents: |- + ================================================== + ========== ========== + ========== Welcome to Cursorless! ========== + ========== ========== + ========== Let's start using marks ========== + ========== ========== + ========== so we can navigate around ========== + ========== ========== + ========== without lifting a finger! ========== + ========== ========== + ================================================== + selections: + - anchor: {line: 2, character: 14} + active: {line: 2, character: 21} + - anchor: {line: 6, character: 15} + active: {line: 6, character: 17} diff --git a/data/fixtures/recorded/tutorial/unit-1-basics/takeEachPastKick.yml b/data/fixtures/recorded/tutorial/unit-1-basics/takeEachPastKick.yml new file mode 100644 index 00000000000..455ba3923a7 --- /dev/null +++ b/data/fixtures/recorded/tutorial/unit-1-basics/takeEachPastKick.yml @@ -0,0 +1,56 @@ +languageId: plaintext +command: + version: 6 + spokenForm: take each past crunch + action: + name: setSelection + target: + type: range + anchor: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: e} + active: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: k} + excludeAnchor: false + excludeActive: false + usePrePhraseSnapshot: false +initialState: + documentContents: |- + ================================================== + ========== ========== + ========== Welcome to Cursorless! ========== + ========== ========== + ========== Let's start using marks ========== + ========== ========== + ========== so we can navigate around ========== + ========== ========== + ========== without lifting a finger! ========== + ========== ========== + ================================================== + selections: + - anchor: {line: 6, character: 12} + active: {line: 6, character: 14} + marks: + default.e: + start: {line: 4, character: 13} + end: {line: 4, character: 16} + default.k: + start: {line: 4, character: 31} + end: {line: 4, character: 36} +finalState: + documentContents: |- + ================================================== + ========== ========== + ========== Welcome to Cursorless! ========== + ========== ========== + ========== Let's start using marks ========== + ========== ========== + ========== so we can navigate around ========== + ========== ========== + ========== without lifting a finger! ========== + ========== ========== + ================================================== + selections: + - anchor: {line: 4, character: 13} + active: {line: 4, character: 36} diff --git a/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..65e11df1430 --- /dev/null +++ b/data/fixtures/recorded/tutorial/unit-1-basics/takeLine.yml @@ -0,0 +1,47 @@ +languageId: plaintext +command: + version: 6 + spokenForm: take line + action: + name: setSelection + target: + type: primitive + modifiers: + - type: containingScope + scopeType: {type: line} + usePrePhraseSnapshot: false +initialState: + documentContents: |- + ================================================== + ========== ========== + ========== Welcome to Cursorless! ========== + ========== ========== + ========== ========== + ========== so we can navigate ========== + ========== ========== + ========== without lifting a finger! ========== + ========== ========== + ================================================== + selections: + - anchor: {line: 2, character: 14} + active: {line: 2, character: 21} + - anchor: {line: 5, character: 15} + active: {line: 5, character: 17} + marks: {} +finalState: + documentContents: |- + ================================================== + ========== ========== + ========== Welcome to Cursorless! ========== + ========== ========== + ========== ========== + ========== so we can navigate ========== + ========== ========== + ========== without lifting a finger! ========== + ========== ========== + ================================================== + selections: + - anchor: {line: 2, character: 0} + active: {line: 2, character: 50} + - anchor: {line: 5, character: 0} + active: {line: 5, character: 41} diff --git a/data/fixtures/recorded/tutorial/unit-1-basics/takeWhale.yml b/data/fixtures/recorded/tutorial/unit-1-basics/takeWhale.yml new file mode 100644 index 00000000000..37cdf6c2094 --- /dev/null +++ b/data/fixtures/recorded/tutorial/unit-1-basics/takeWhale.yml @@ -0,0 +1,46 @@ +languageId: plaintext +command: + version: 6 + spokenForm: take whale + action: + name: setSelection + target: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: w} + usePrePhraseSnapshot: false +initialState: + documentContents: |- + ================================================== + ========== ========== + ========== Welcome to Cursorless! ========== + ========== ========== + ========== Let's start using marks ========== + ========== ========== + ========== so we can navigate around ========== + ========== ========== + ========== without lifting a finger! ========== + ========== ========== + ================================================== + selections: + - anchor: {line: 0, character: 0} + active: {line: 0, character: 0} + marks: + default.w: + start: {line: 2, character: 14} + end: {line: 2, character: 21} +finalState: + documentContents: |- + ================================================== + ========== ========== + ========== Welcome to Cursorless! ========== + ========== ========== + ========== Let's start using marks ========== + ========== ========== + ========== so we can navigate around ========== + ========== ========== + ========== without lifting a finger! ========== + ========== ========== + ================================================== + selections: + - anchor: {line: 2, character: 14} + active: {line: 2, character: 21} diff --git a/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..5fb8ad43728 --- /dev/null +++ b/data/fixtures/recorded/tutorial/unit-2-basic-coding/script.json @@ -0,0 +1,16 @@ +{ + "title": "Basic coding", + "steps": [ + "When editing code, we often think in terms of statements, functions, etc. Let's clone a statement: {step:cloneStateInk.yml}", + "{scopeType: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 ecbda32f31c..9ce1407bc6d 100644 --- a/packages/common/src/cursorlessCommandIds.ts +++ b/packages/common/src/cursorlessCommandIds.ts @@ -47,6 +47,12 @@ export const cursorlessCommandIds = [ "cursorless.toggleDecorations", "cursorless.showScopeVisualizer", "cursorless.hideScopeVisualizer", + "cursorless.tutorial.start", + "cursorless.tutorial.next", + "cursorless.tutorial.previous", + "cursorless.tutorial.resume", + "cursorless.tutorial.list", + "cursorless.docsOpened", "cursorless.analyzeCommandHistory", ] as const satisfies readonly `cursorless.${string}`[]; @@ -81,6 +87,14 @@ 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.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.showQuickPick"]: new HiddenCommand( "Pop up a quick pick of all cursorless commands", diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 224b5708aaa..d27fad01954 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -49,7 +49,9 @@ export * from "./types/HatTokenMap"; export * from "./types/ScopeProvider"; export * from "./types/SpokenForm"; export * from "./types/commandHistory"; +export * from "./types/tutorial.types"; export * from "./util/textFormatters"; +export * from "./util/serializedMarksToTokenHats"; export * from "./types/snippet.types"; export * from "./testUtil/fromPlainObject"; export * from "./testUtil/spyToPlainObject"; diff --git a/packages/common/src/types/tutorial.types.ts b/packages/common/src/types/tutorial.types.ts new file mode 100644 index 00000000000..902ea0958c7 --- /dev/null +++ b/packages/common/src/types/tutorial.types.ts @@ -0,0 +1,59 @@ +export type TutorialId = "unit-1-basics" | "unit-2-basic-coding"; + +export interface TutorialInfo { + id: TutorialId; + title: string; +} + +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; + +export interface ActiveTutorialState extends TutorialInfo { + type: "doingTutorial"; + stepNumber: number; + stepContent: TutorialStepFragment[]; + stepCount: number; + preConditionsMet: boolean; +} + +export type TutorialState = + | PickingTutorialState + | ActiveTutorialState + | LoadingState; diff --git a/packages/common/src/util/serializedMarksToTokenHats.ts b/packages/common/src/util/serializedMarksToTokenHats.ts new file mode 100644 index 00000000000..94203c29012 --- /dev/null +++ b/packages/common/src/util/serializedMarksToTokenHats.ts @@ -0,0 +1,36 @@ +import { plainObjectToRange } from "../testUtil/fromPlainObject"; +import { splitKey } from "./splitKey"; +import { SerializedMarks } from "./toPlainObject"; +import { TextEditor } from "../types/TextEditor"; +import { TokenHat } from "../types/HatTokenMap"; + +export function serializedMarksToTokenHats( + marks: SerializedMarks | undefined, + editor: TextEditor, +): TokenHat[] { + if (marks == null) { + return []; + } + + return Object.entries(marks).map(([key, token]) => { + const { hatStyle, character } = splitKey(key); + const range = plainObjectToRange(token); + + return { + hatStyle, + grapheme: character, + token: { + editor, + range, + offsets: { + start: editor.document.offsetAt(range.start), + end: editor.document.offsetAt(range.end), + }, + text: editor.document.getText(range), + }, + + // NB: We don't care about the hat range for this test + hatRange: range, + }; + }); +} diff --git a/packages/cursorless-engine/src/api/CursorlessEngineApi.ts b/packages/cursorless-engine/src/api/CursorlessEngineApi.ts index 63e4b8d591a..1ed93585ecc 100644 --- a/packages/cursorless-engine/src/api/CursorlessEngineApi.ts +++ b/packages/cursorless-engine/src/api/CursorlessEngineApi.ts @@ -9,6 +9,7 @@ import type { import type { CommandRunner } from "../CommandRunner"; import type { Snippets } from "../core/Snippets"; import type { StoredTargetMap } from "../core/StoredTargets"; +import { Tutorial } from "./Tutorial"; export interface CursorlessEngine { commandApi: CommandApi; @@ -17,6 +18,7 @@ export interface CursorlessEngine { storedTargets: StoredTargetMap; hatTokenMap: HatTokenMap; snippets: Snippets; + tutorial: Tutorial; injectIde: (ide: IDE | undefined) => void; runIntegrationTests: () => Promise; addCommandRunnerDecorator: ( diff --git a/packages/cursorless-engine/src/api/Tutorial.ts b/packages/cursorless-engine/src/api/Tutorial.ts new file mode 100644 index 00000000000..168487c7249 --- /dev/null +++ b/packages/cursorless-engine/src/api/Tutorial.ts @@ -0,0 +1,116 @@ +import { + CommandComplete, + Disposable, + ScopeType, + TestCaseSnapshot, + TutorialId, + TutorialState, + TutorialStepFragment, +} from "@cursorless/common"; + +export interface TutorialContent { + /** + * The title of the tutorial + */ + title: string; + + /** + * The steps of the current tutorial + */ + steps: Array; +} + +export interface RawTutorialContent { + /** + * The title of the tutorial + */ + title: string; + + /** + * The steps of the current tutorial + */ + steps: string[]; +} + +/** + * 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 text content of the current 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(): void; + previous(): void; + resume(): void; + list(): void; + readonly state: TutorialState; +} diff --git a/packages/cursorless-engine/src/core/IndividualHatMap.ts b/packages/cursorless-engine/src/core/IndividualHatMap.ts index 5b00390b4ed..334c15b5163 100644 --- a/packages/cursorless-engine/src/core/IndividualHatMap.ts +++ b/packages/cursorless-engine/src/core/IndividualHatMap.ts @@ -58,7 +58,7 @@ export class IndividualHatMap implements ReadOnlyHatMap { } /** - * Overwrites the hat assignemnt for this hat token map. + * Overwrites the hat assignment for this hat token map. * * @param tokenHats The new hat assignments */ diff --git a/packages/cursorless-engine/src/core/TutorialImpl.ts b/packages/cursorless-engine/src/core/TutorialImpl.ts new file mode 100644 index 00000000000..ab4e00d321c --- /dev/null +++ b/packages/cursorless-engine/src/core/TutorialImpl.ts @@ -0,0 +1,402 @@ +import { + CommandComplete, + Disposable, + Notifier, + ReadOnlyHatMap, + ScopeType, + TextEditor, + TutorialId, + TutorialInfo, + TutorialState, + plainObjectToRange, + plainObjectToSelection, + serializedMarksToTokenHats, + toCharacterRange, +} from "@cursorless/common"; +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 { HatTokenMapImpl } from "./HatTokenMapImpl"; +import { TutorialScriptParser } from "./TutorialScriptParser"; +import { loadTutorialScript } from "./loadTutorialScript"; +import { isEqual } from "lodash"; +import { Debouncer } from "./Debouncer"; +import { readdir } from "node:fs/promises"; + +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: HatTokenMapImpl, + 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, + ); + } + + 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, + }); + + this.tutorials = await Promise.all( + tutorialDirs + .filter((dirent) => dirent.isDirectory()) + .map(async (dirent) => { + const tutorialId = dirent.name; + const rawContent = await loadTutorialScript( + this.tutorialRootDir, + tutorialId, + ); + + return { + id: tutorialId as TutorialId, + title: rawContent.title, + }; + }), + ); + + 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)) { + this.next(); + } + + return returnValue; + }, + }; + } + + public onState(callback: (state: TutorialState) => void): Disposable { + return this.notifier.registerListener(callback); + } + + private async reparseCurrentTutorial() { + // TODO: Do we really need this.currentTutorial and this.state_? + if (this.currentTutorial == null || this.state_.type !== "doingTutorial") { + return; + } + + // TODO: Avoid reparsing tutorial? Might be better not to do custom spoken + // forms lookup while parsing + const tutorialId = this.state_.id; + + const rawContent = await loadTutorialScript( + this.tutorialRootDir, + tutorialId, + ); + + const parser = new TutorialScriptParser( + this.tutorialRootDir, + tutorialId, + this.customSpokenFormGenerator, + ); + + this.currentTutorial.steps = await Promise.all( + rawContent.steps.map(parser.parseTutorialStep), + ); + + this.setState({ + ...this.state_, + stepContent: this.currentTutorial.steps[this.state_.stepNumber].content, + stepCount: this.currentTutorial.steps.length, + }); + } + + 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, + ); + + this.currentTutorial = { + title: rawContent.title, + steps: await Promise.all(rawContent.steps.map(parser.parseTutorialStep)), + }; + + this.setState({ + type: "doingTutorial", + id: tutorialId, + stepNumber: 0, + stepContent: this.currentTutorial.steps[0].content, + stepCount: this.currentTutorial.steps.length, + title: this.currentTutorial.title, + preConditionsMet: true, + }); + this.setupStep(); + } + + docsOpened() { + if (this.state_.type === "doingTutorial") { + const currentStep = this.currentTutorial!.steps[this.state_.stepNumber]; + if (currentStep.trigger?.type === "help") { + this.next(); + } + } + } + + private changeStep(getStep: (current: number) => number): void { + if (this.state_.type === "doingTutorial") { + const newStepNumber = getStep(this.state_.stepNumber); + + if (newStepNumber === this.state_.stepCount || newStepNumber < 0) { + // TODO: Store that they've finished the tutorial + this.setState({ type: "pickingTutorial", tutorials: this.tutorials }); + } else { + const nextStep = this.currentTutorial!.steps[newStepNumber]; + + this.setState({ + type: "doingTutorial", + id: this.state_.id, + stepNumber: newStepNumber, + stepContent: nextStep.content, + stepCount: this.state_.stepCount, + title: this.state_.title, + preConditionsMet: true, + }); + } + } + + this.setupStep(); + } + + next() { + this.changeStep((current) => current + 1); + } + + previous() { + this.changeStep((current) => current - 1); + } + + resume() { + this.setupStep(); + } + + list() { + this.setState({ + type: "pickingTutorial", + tutorials: this.tutorials, + }); + this.setupStep(); + } + + private setState(state: TutorialState) { + this.state_ = state; + 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() { + if (this.state_.type !== "doingTutorial") { + if (this.editor != null) { + ide().setHighlightRanges("highlight1", this.editor, []); + this.editor = undefined; + } + return; + } + + const { initialState: snapshot, languageId } = + this.currentTutorial!.steps[this.state_.stepNumber]; + + if (snapshot == null) { + if (this.editor != null) { + ide().setHighlightRanges("highlight1", this.editor, []); + } + return; + } + + // TODO: Detect and handle bad cases: + // Editor has been closed + // We are in a different split + // Language has been changed + // Editor reference we have is in some other way broken + // In these cases, it would be great to somehow get back to the original editor + // If not possible, would be good to open editor on same untitiled doc + // If that's not possible, just open a new editor + if (this.editor == null) { + this.editor = await ide().openUntitledTextDocument({ + content: snapshot.documentContents, + language: languageId ?? "plaintext", + }); + } + + const editableEditor = ide().getEditableTextEditor(this.editor); + + await editableEditor.edit([ + { + range: editableEditor.document.range, + text: snapshot.documentContents, + isReplace: true, + }, + ]); + + // Ensure that the expected cursor/selections are present + editableEditor.setSelections( + snapshot.selections.map(plainObjectToSelection), + ); + + // Ensure that the expected hats are present + // TODO: Make hat forcing more robust + await this.hatTokenMap.allocateHats( + serializedMarksToTokenHats(snapshot.marks, this.editor), + ); + + ide().setHighlightRanges( + "highlight1", + this.editor, + Object.values(snapshot.marks ?? {}).map((range) => + toCharacterRange(plainObjectToRange(range)), + ), + ); + + // TODO: Handle case where editor is in a background tab + await editableEditor.focus(); + } + + 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..7cad259722d --- /dev/null +++ b/packages/cursorless-engine/src/core/TutorialScriptParser.ts @@ -0,0 +1,182 @@ +import { + CommandComplete, + ScopeType, + TestCaseSnapshot, + TutorialId, + TutorialStepFragment, +} from "@cursorless/common"; +import { TutorialStep, TutorialStepTrigger } from "../api/Tutorial"; +import { CustomSpokenFormGeneratorImpl } from "../generateSpokenForm/CustomSpokenFormGeneratorImpl"; +import { loadFixture } from "./loadTutorialScript"; +import { canonicalizeAndValidateCommand } from "./commandVersionUpgrades/canonicalizeAndValidateCommand"; +import { parseScopeType } from "../customCommandGrammar/parseScopeType"; + +// this is trying to catch occurrences of things like "{step:cloneStateInk.yml}" +const re = /{(\w+):([^}]+)}/g; + +const SPECIAL_COMMANDS = { + help: "cursorless help", + next: "tutorial next", + visualizeNothing: "visualize nothing", +}; + +const TERMS = { + visualize: "visualize", +}; + +export class TutorialScriptParser { + constructor( + private tutorialRootDir: string, + private tutorialId: TutorialId, + private customSpokenFormGenerator: CustomSpokenFormGeneratorImpl, + ) { + this.parseTutorialStep = this.parseTutorialStep.bind(this); + } + + async parseTutorialStep(rawContent: string): Promise { + let trigger: TutorialStepTrigger | undefined = undefined; + let initialState: TestCaseSnapshot | undefined = undefined; + let languageId: string | undefined = undefined; + + const content: TutorialStepFragment[] = []; + let currentIndex = 0; + re.lastIndex = 0; + for (const { + 0: { length }, + 1: type, + 2: arg, + index, + } of rawContent.matchAll(re)) { + if (index > currentIndex) { + content.push({ + type: "string", + value: rawContent.slice(currentIndex, index), + }); + } + + currentIndex = index + length; + + switch (type) { + case "step": { + const fixture = await loadFixture( + this.tutorialRootDir, + this.tutorialId, + arg, + ); + const command = canonicalizeAndValidateCommand(fixture.command); + content.push({ + type: "command", + value: await this.processStep(command), + }); + ({ initialState, languageId } = fixture); + trigger = { + type: "command", + command, + }; + break; + } + case "special": + content.push({ + type: "command", + value: SPECIAL_COMMANDS[arg as keyof typeof SPECIAL_COMMANDS], + }); + switch (arg) { + case "help": + trigger = { + type: "help", + }; + break; + case "visualizeNothing": + trigger = { + type: "visualize", + scopeType: undefined, + }; + break; + } + + break; + case "action": + // TODO: don't use hardcoded list of default spoken form for an action (not yet the user customized one) + content.push({ + type: "term", + value: arg, + }); + break; + case "term": { + content.push({ + type: "term", + value: TERMS[arg as keyof typeof TERMS], + }); + break; + } + case "scopeType": + content.push({ + type: "term", + value: await this.processScopeType(parseScopeType(arg)), + }); + break; + case "visualize": { + const scopeType = parseScopeType(arg); + content.push({ + type: "command", + value: `${TERMS.visualize} ${await this.processScopeType(scopeType)}`, + }); + trigger = { + type: "visualize", + scopeType, + }; + break; + } + default: + throw new Error(`Unknown name: ${type}`); + } + } + + if (currentIndex < rawContent.length) { + content.push({ + type: "string", + value: rawContent.slice(currentIndex), + }); + } + + return { + content, + trigger, + initialState, + languageId, + }; + } + + /** + * Handle the argument of a "{step:cloneStateInk.yml}"" + */ + private async processStep(command: CommandComplete) { + // command to be said for moving to the next step + const spokenForm = + this.customSpokenFormGenerator.commandToSpokenForm(command); + + if (spokenForm.type === "error") { + throw new Error( + `Error while processing spoken form for command ${command}: ${spokenForm.reason}`, + ); + } + + return spokenForm.spokenForms[0]; + } + + /** + * Handle the argument of a "{scopeType:state}" + */ + private async processScopeType(scopeType: ScopeType) { + const spokenForm = + this.customSpokenFormGenerator.scopeTypeToSpokenForm(scopeType); + + if (spokenForm.type === "error") { + throw new Error( + `Error while processing spoken form for command: ${spokenForm.reason}`, + ); + } + + 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..17d22152a43 --- /dev/null +++ b/packages/cursorless-engine/src/core/loadTutorialScript.ts @@ -0,0 +1,31 @@ +import { TestCaseFixture } from "@cursorless/common"; +import * as yaml from "js-yaml"; +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()); +} + +export async function loadFixture( + tutorialRootDir: string, + tutorialName: string, + yamlFilename: string, +): Promise { + const buffer = await readFile( + path.join(tutorialRootDir, tutorialName, yamlFilename), + ); + + return yaml.load(buffer.toString()) as TestCaseFixture; +} diff --git a/packages/cursorless-engine/src/cursorlessEngine.ts b/packages/cursorless-engine/src/cursorlessEngine.ts index 71dd1b1d4e9..53b5e47f38e 100644 --- a/packages/cursorless-engine/src/cursorlessEngine.ts +++ b/packages/cursorless-engine/src/cursorlessEngine.ts @@ -14,6 +14,7 @@ import { Debug } from "./core/Debug"; import { HatTokenMapImpl } from "./core/HatTokenMapImpl"; import { Snippets } from "./core/Snippets"; import { StoredTargetMap } from "./core/StoredTargets"; +import { TutorialImpl } from "./core/TutorialImpl"; import { ensureCommandShape } from "./core/commandVersionUpgrades/ensureCommandShape"; import { RangeUpdater } from "./core/updateSelections/RangeUpdater"; import { CustomSpokenFormGeneratorImpl } from "./generateSpokenForm/CustomSpokenFormGeneratorImpl"; @@ -65,10 +66,23 @@ export function createCursorlessEngine( talonSpokenForms, ); - ide.disposeOnExit(rangeUpdater, languageDefinitions, hatTokenMap, debug); - const commandRunnerDecorators: CommandRunnerDecorator[] = []; + const addCommandRunnerDecorator = (decorator: CommandRunnerDecorator) => { + commandRunnerDecorators.push(decorator); + }; + + const tutorial = new TutorialImpl(hatTokenMap, customSpokenFormGenerator); + addCommandRunnerDecorator(tutorial); + + ide.disposeOnExit( + rangeUpdater, + languageDefinitions, + hatTokenMap, + debug, + tutorial, + ); + return { commandApi: { runCommand(command: Command) { @@ -113,9 +127,8 @@ export function createCursorlessEngine( injectIde, runIntegrationTests: () => runIntegrationTests(treeSitter, languageDefinitions), - addCommandRunnerDecorator: (decorator: CommandRunnerDecorator) => { - commandRunnerDecorators.push(decorator); - }, + addCommandRunnerDecorator, + tutorial, }; } diff --git a/packages/cursorless-engine/src/index.ts b/packages/cursorless-engine/src/index.ts index 35b7a95d062..2d7b5e483e8 100644 --- a/packages/cursorless-engine/src/index.ts +++ b/packages/cursorless-engine/src/index.ts @@ -1,5 +1,6 @@ export * from "./testUtil/plainObjectToTarget"; export * from "./core/Cheatsheet"; +export * from "./api/Tutorial"; export * from "./testUtil/takeSnapshot"; export * from "./testCaseRecorder/TestCaseRecorder"; export * from "./core/StoredTargets"; diff --git a/packages/cursorless-vscode-e2e/src/suite/recorded.vscode.test.ts b/packages/cursorless-vscode-e2e/src/suite/recorded.vscode.test.ts index 5a759ce956e..0c69e5e7eb3 100644 --- a/packages/cursorless-vscode-e2e/src/suite/recorded.vscode.test.ts +++ b/packages/cursorless-vscode-e2e/src/suite/recorded.vscode.test.ts @@ -1,30 +1,28 @@ import { - asyncSafety, CommandResponse, DEFAULT_TEXT_EDITOR_OPTIONS_FOR_TEST, ExcludableSnapshotField, - extractTargetedMarks, Fallback, - getRecordedTestPaths, HatStability, - marksToPlainObject, - omitByDeep, - plainObjectToRange, PositionPlainObject, - rangeToPlainObject, ReadOnlyHatMap, SelectionPlainObject, SerializedMarks, + SpyIDE, + TestCaseFixtureLegacy, + asyncSafety, + clientSupportsFallback, + extractTargetedMarks, + getRecordedTestPaths, + marksToPlainObject, + omitByDeep, + rangeToPlainObject, serializeTestFixture, + serializedMarksToTokenHats, shouldUpdateFixtures, splitKey, - SpyIDE, spyIDERecordedValuesToPlainObject, storedTargetKeys, - TestCaseFixtureLegacy, - TextEditor, - TokenHat, - clientSupportsFallback, } from "@cursorless/common"; import { getCursorlessApi, @@ -108,7 +106,10 @@ async function runTest(file: string, spyIde: SpyIDE) { // Ensure that the expected hats are present await hatTokenMap.allocateHats( - getTokenHats(fixture.initialState.marks, spyIde.activeTextEditor!), + serializedMarksToTokenHats( + fixture.initialState.marks, + spyIde.activeTextEditor!, + ), ); const readableHatMap = await hatTokenMap.getReadableMap(usePrePhraseSnapshot); @@ -257,34 +258,3 @@ function checkMarks( assert.deepStrictEqual(rangeToPlainObject(currentToken.range), token); }); } - -function getTokenHats( - marks: SerializedMarks | undefined, - editor: TextEditor, -): TokenHat[] { - if (marks == null) { - return []; - } - - return Object.entries(marks).map(([key, token]) => { - const { hatStyle, character } = splitKey(key); - const range = plainObjectToRange(token); - - return { - hatStyle, - grapheme: character, - token: { - editor, - range, - offsets: { - start: editor.document.offsetAt(range.start), - end: editor.document.offsetAt(range.end), - }, - text: editor.document.getText(range), - }, - - // NB: We don't care about the hat range for this test - hatRange: range, - }; - }); -} diff --git a/packages/cursorless-vscode-tutorial-webview/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..7837c0fa026 --- /dev/null +++ b/packages/cursorless-vscode-tutorial-webview/src/App.tsx @@ -0,0 +1,64 @@ +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 ( +
+

+ Picking a tutorial +

+

+ Say to pick a tutorial, + or click one of them below: +

+
    + {state.tutorials.map((tutorial) => ( +
  1. + +
  2. + ))} +
+
+ ); + + case "doingTutorial": + 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/TutorialStep.tsx b/packages/cursorless-vscode-tutorial-webview/src/TutorialStep.tsx new file mode 100644 index 00000000000..1b59f2a48b4 --- /dev/null +++ b/packages/cursorless-vscode-tutorial-webview/src/TutorialStep.tsx @@ -0,0 +1,37 @@ +import { ActiveTutorialState } from "@cursorless/common"; +import { type FunctionComponent } from "react"; +import { Command } from "./Command"; + +interface TutorialStepProps { + state: ActiveTutorialState; +} + +export const TutorialStep: FunctionComponent = ({ + state, +}) => { + return ( +
+

+ {state.title} +

+ + {state.preConditionsMet ? ( + state.stepContent.map((fragment, i) => ( + + {fragment.type === "string" ? ( + {fragment.value} + ) : fragment.type === "command" ? ( + + ) : ( + "{fragment.value}" + )} + + )) + ) : ( + <> + Please say to resume. + + )} +
+ ); +}; 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 1170d0823dd..8cdc1ed7bcd 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" @@ -103,6 +108,32 @@ "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.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", @@ -1155,8 +1186,8 @@ }, "funding": "https://github.com/sponsors/pokey", "scripts": { - "build": "pnpm run esbuild:prod && pnpm -F cheatsheet-local build:prod && pnpm run populate-dist", - "build:dev": "pnpm generate-grammar && pnpm run esbuild && pnpm -F cheatsheet-local build && pnpm run populate-dist", + "build": "pnpm run esbuild:prod && pnpm -F cheatsheet-local build:prod && pnpm -F cursorless-vscode-tutorial-webview build:prod && pnpm run populate-dist", + "build:dev": "pnpm generate-grammar && pnpm run esbuild && pnpm -F cheatsheet-local build && pnpm -F cursorless-vscode-tutorial-webview build && pnpm run populate-dist", "esbuild:base": "esbuild ./src/extension.ts --conditions=cursorless:bundler --bundle --outfile=dist/extension.cjs --external:vscode --format=cjs --platform=node", "install-local": "bash ./scripts/install-local.sh", "install-from-pr": "bash ./scripts/install-from-pr.sh", diff --git a/packages/cursorless-vscode/src/VscodeTutorial.ts b/packages/cursorless-vscode/src/VscodeTutorial.ts new file mode 100644 index 00000000000..7cbed7b9ea9 --- /dev/null +++ b/packages/cursorless-vscode/src/VscodeTutorial.ts @@ -0,0 +1,198 @@ +import { + FileSystem, + TutorialId, + TutorialState, + getCursorlessRepoRoot, +} from "@cursorless/common"; +import { Tutorial } from "@cursorless/cursorless-engine"; +import { VscodeApi } from "@cursorless/vscode-common"; +import { + CancellationToken, + ExtensionContext, + ExtensionMode, + Uri, + Webview, + WebviewView, + WebviewViewProvider, + WebviewViewResolveContext, + commands, +} from "vscode"; +import { ScopeVisualizer } from "./ScopeVisualizerCommandApi"; +import path from "node:path"; + +const VSCODE_TUTORIAL_WEBVIEW_ID = "cursorless.tutorial"; + +export class VscodeTutorial implements WebviewViewProvider { + private view?: WebviewView; + private localResourceRoot: Uri; + + constructor( + private context: ExtensionContext, + 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.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(this.view!.webview); + } + }), + ); + } + } + + public resolveWebviewView( + webviewView: WebviewView, + _context: WebviewViewResolveContext, + _token: CancellationToken, + ) { + this.view = webviewView; + + webviewView.webview.options = { + enableScripts: true, + localResourceRoots: [this.localResourceRoot], + }; + + webviewView.webview.html = this.getHtmlForWebview(webviewView.webview); + + webviewView.webview.onDidReceiveMessage((data) => { + switch (data.type) { + case "getInitialState": + this.view!.webview.postMessage(this.tutorial.state); + break; + case "start": + this.start(data.tutorialId); + break; + } + }); + } + + public start(tutorialId: TutorialId) { + return this.tutorial.start(tutorialId); + } + + docsOpened() { + this.tutorial.docsOpened(); + } + + next() { + this.tutorial.next(); + } + + previous() { + this.tutorial.previous(); + } + + resume() { + this.tutorial.resume(); + } + + list() { + this.tutorial.list(); + } + + private async onState(state: TutorialState) { + this.view?.webview.postMessage(state); + + if (state.type === "doingTutorial" || state.type === "pickingTutorial") { + if (this.view != null) { + this.view.show(true); + } else { + await commands.executeCommand("cursorless.tutorial.focus"); + } + } + } + + private getHtmlForWebview(webview: Webview) { + // Get the local path to main script run in the webview, then convert it to a uri we can use in the webview. + const scriptUri = webview.asWebviewUri( + Uri.joinPath( + this.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/extension.ts b/packages/cursorless-vscode/src/extension.ts index fcc7e2334d7..2c679d8ea97 100644 --- a/packages/cursorless-vscode/src/extension.ts +++ b/packages/cursorless-vscode/src/extension.ts @@ -50,6 +50,7 @@ import { import { StatusBarItem } from "./StatusBarItem"; import { storedTargetHighlighter } from "./storedTargetHighlighter"; import { vscodeApi } from "./vscodeApi"; +import { VscodeTutorial } from "./VscodeTutorial"; /** * Extension entrypoint called by VSCode on Cursorless startup. @@ -92,6 +93,7 @@ export async function activate( runIntegrationTests, addCommandRunnerDecorator, customSpokenFormGenerator, + tutorial, } = createCursorlessEngine( treeSitter, normalizedIde, @@ -133,6 +135,14 @@ export async function activate( context.subscriptions.push(storedTargetHighlighter(vscodeIDE, storedTargets)); + const vscodeTutorial = new VscodeTutorial( + context, + vscodeApi, + tutorial, + scopeVisualizer, + fileSystem, + ); + registerCommands( context, vscodeIDE, @@ -142,6 +152,7 @@ export async function activate( scopeVisualizer, keyboardCommands, hats, + vscodeTutorial, ); new ReleaseNotes(vscodeApi, context, normalizedIde.messages).maybeShow(); diff --git a/packages/cursorless-vscode/src/registerCommands.ts b/packages/cursorless-vscode/src/registerCommands.ts index c070e296a69..ec4002c8770 100644 --- a/packages/cursorless-vscode/src/registerCommands.ts +++ b/packages/cursorless-vscode/src/registerCommands.ts @@ -13,6 +13,7 @@ import { } from "@cursorless/cursorless-engine"; import * as vscode from "vscode"; import { ScopeVisualizer } from "./ScopeVisualizerCommandApi"; +import { VscodeTutorial } from "./VscodeTutorial"; import { showDocumentation, showQuickPick } from "./commands"; import { VscodeIDE } from "./ide/vscode/VscodeIDE"; import { VscodeHats } from "./ide/vscode/hats/VscodeHats"; @@ -28,6 +29,7 @@ export function registerCommands( scopeVisualizer: ScopeVisualizer, keyboardCommands: KeyboardCommands, hats: VscodeHats, + tutorial: VscodeTutorial, ): void { const commands: Record any> = { // The core Cursorless command @@ -98,6 +100,14 @@ export function registerCommands( ["cursorless.keyboard.modal.modeOn"]: keyboardCommands.modal.modeOn, ["cursorless.keyboard.modal.modeOff"]: keyboardCommands.modal.modeOff, ["cursorless.keyboard.modal.modeToggle"]: keyboardCommands.modal.modeToggle, + + // Tutorial commands + ["cursorless.tutorial.start"]: tutorial.start, + ["cursorless.tutorial.next"]: tutorial.next, + ["cursorless.tutorial.previous"]: tutorial.previous, + ["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/pnpm-lock.yaml b/pnpm-lock.yaml index ef02cb51c8a..b97d606495f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -589,6 +589,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': @@ -2911,6 +2936,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==} @@ -12773,6 +12801,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 e05ff589f5c..185f941dae6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,6 +26,9 @@ { "path": "./packages/cursorless-vscode-e2e" }, + { + "path": "./packages/cursorless-vscode-tutorial-webview" + }, { "path": "./packages/meta-updater" },