diff --git a/data/fixtures/recorded/tutorial/unit-1-basics/script.json b/data/fixtures/recorded/tutorial/unit-1-basics/script.json index b9371257fc..62a58f9120 100644 --- a/data/fixtures/recorded/tutorial/unit-1-basics/script.json +++ b/data/fixtures/recorded/tutorial/unit-1-basics/script.json @@ -4,10 +4,10 @@ "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. Say {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. Let's try that: {step:takeNearAndSun.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. Let's try working with lines: {step:chuckLineOdd.yml}", + "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}", diff --git a/packages/common/src/types/tutorial.types.ts b/packages/common/src/types/tutorial.types.ts index 0e23086f11..5faf5d9e87 100644 --- a/packages/common/src/types/tutorial.types.ts +++ b/packages/common/src/types/tutorial.types.ts @@ -59,7 +59,7 @@ interface ActiveTutorialState extends BaseTutorialInfo { export interface ActiveTutorialNoErrorsState extends ActiveTutorialState { hasErrors: false; - stepContent: TutorialStepFragment[]; + stepContent: TutorialStepFragment[][]; stepCount: number; } diff --git a/packages/cursorless-engine/src/api/Tutorial.ts b/packages/cursorless-engine/src/api/Tutorial.ts index f18020a19f..8122e45529 100644 --- a/packages/cursorless-engine/src/api/Tutorial.ts +++ b/packages/cursorless-engine/src/api/Tutorial.ts @@ -80,9 +80,10 @@ export type TutorialStepTrigger = export interface TutorialStep { /** - * The text content of the current step + * The content of the current step. Each element in the array represents a + * paragraph in the tutorial step. */ - content: TutorialStepFragment[]; + content: TutorialStepFragment[][]; /** * The path to the yaml file that should be used to setup the current step (if diff --git a/packages/cursorless-engine/src/core/ActionComponentHandler.ts b/packages/cursorless-engine/src/core/ActionComponentHandler.ts new file mode 100644 index 0000000000..6c45996f40 --- /dev/null +++ b/packages/cursorless-engine/src/core/ActionComponentHandler.ts @@ -0,0 +1,42 @@ +import { ActionType } from "@cursorless/common"; +import { invertBy } from "lodash"; +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 0000000000..29864dea5b --- /dev/null +++ b/packages/cursorless-engine/src/core/CursorlessCommandHandler.ts @@ -0,0 +1,52 @@ +import { CommandComplete, TutorialId, loadFixture } from "@cursorless/common"; +import path from "path"; +import { CustomSpokenFormGeneratorImpl } from "../generateSpokenForm/CustomSpokenFormGeneratorImpl"; +import { canonicalizeAndValidateCommand } from "./commandVersionUpgrades/canonicalizeAndValidateCommand"; +import { StepComponent, StepComponentParser } from "./StepComponent"; +import { TutorialError } from "./TutorialError"; + +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 0000000000..25aa224271 --- /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 0000000000..501f2482a0 --- /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 0000000000..a7042bc011 --- /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 index f0a1ba87db..7ba6191d09 100644 --- a/packages/cursorless-engine/src/core/TutorialImpl.ts +++ b/packages/cursorless-engine/src/core/TutorialImpl.ts @@ -24,7 +24,8 @@ import { Tutorial, TutorialContent, TutorialStep } from "../api/Tutorial"; import { CustomSpokenFormGeneratorImpl } from "../generateSpokenForm/CustomSpokenFormGeneratorImpl"; import { ide } from "../singletons/ide.singleton"; import { Debouncer } from "./Debouncer"; -import { TutorialError, TutorialScriptParser } from "./TutorialScriptParser"; +import { TutorialError } from "./TutorialError"; +import { TutorialScriptParser } from "./TutorialScriptParser"; import { loadTutorialScript } from "./loadTutorialScript"; const HIGHLIGHT_COLOR = "highlight0"; diff --git a/packages/cursorless-engine/src/core/TutorialScriptParser.ts b/packages/cursorless-engine/src/core/TutorialScriptParser.ts index c290a219fe..cc43734d01 100644 --- a/packages/cursorless-engine/src/core/TutorialScriptParser.ts +++ b/packages/cursorless-engine/src/core/TutorialScriptParser.ts @@ -1,152 +1,122 @@ import { - ActionType, - CommandComplete, - ScopeType, TestCaseSnapshot, TutorialId, TutorialStepFragment, - loadFixture, } from "@cursorless/common"; -import { invertBy } from "lodash"; -import path from "path"; import { TutorialStep, TutorialStepTrigger } from "../api/Tutorial"; import { parseScopeType } from "../customCommandGrammar/parseCommand"; import { CustomSpokenFormGeneratorImpl } from "../generateSpokenForm/CustomSpokenFormGeneratorImpl"; -import { defaultSpokenFormMap } from "../spokenForms/defaultSpokenFormMap"; -import { canonicalizeAndValidateCommand } from "./commandVersionUpgrades/canonicalizeAndValidateCommand"; +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; -const SPECIAL_COMMANDS = { - help: "cursorless help", - next: "tutorial next", - visualizeNothing: "visualize nothing", -}; - -const TERMS = { - visualize: "visualize", -}; - export class TutorialScriptParser { - private actionMap: Record = invertBy( - defaultSpokenFormMap.action, - (val) => val.spokenForms[0], - ) as Record; + private componentStepParsers: Record< + string, + (arg: string) => Promise + >; constructor( - private tutorialRootDir: string, - private tutorialId: TutorialId, - private customSpokenFormGenerator: CustomSpokenFormGeneratorImpl, + 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[] = []; - 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( - path.join(this.tutorialRootDir, this.tutorialId, arg), - ); - const command = canonicalizeAndValidateCommand(fixture.command); - content.push({ - type: "command", - value: this.getCommandSpokenForm(command), + 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), }); - ({ 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": - content.push({ - type: "term", - value: this.getActionSpokenForm(this.parseActionId(arg)), - }); - break; - case "grapheme": - content.push({ - type: "term", - value: this.getGraphemeSpokenForm(arg), - }); - break; - case "term": - content.push({ - type: "term", - value: TERMS[arg as keyof typeof TERMS], - }); - break; - case "scopeType": - content.push({ - type: "term", - value: this.getScopeTypeSpokenForm(parseScopeType(arg)), - }); - break; - case "visualize": { - const scopeType = parseScopeType(arg); - content.push({ - type: "command", - value: `${TERMS.visualize} ${this.getScopeTypeSpokenForm(scopeType)}`, - }); - trigger = { - type: "visualize", - scopeType, - }; - break; + + currentIndex = index + length; + + const result = await this.componentStepParsers[type](arg); + + if (result == null) { + throw new Error(`Unknown component type: ${type}`); } - default: - throw new Error(`Unknown name: ${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), + }); } - } - if (currentIndex < rawContent.length) { - content.push({ - type: "string", - value: rawContent.slice(currentIndex), - }); + content.push(lineContent); } return { @@ -156,76 +126,4 @@ export class TutorialScriptParser { languageId, }; } - - 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]; - } - - /** - * 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]; - } - - /** - * Handle the argument of a "{scopeType:state}" - */ - private getScopeTypeSpokenForm(scopeType: ScopeType) { - const spokenForm = - this.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]; - } - - private getActionSpokenForm(actionId: ActionType) { - const spokenForm = - this.customSpokenFormGenerator.actionIdToSpokenForm(actionId); - - return spokenForm.spokenForms[0]; - } - - private getGraphemeSpokenForm(grapheme: string) { - const spokenForm = - this.customSpokenFormGenerator.graphemeToSpokenForm(grapheme); - - return spokenForm.spokenForms[0]; - } -} - -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/getScopeTypeSpokenForm.ts b/packages/cursorless-engine/src/core/getScopeTypeSpokenForm.ts new file mode 100644 index 0000000000..624f61530e --- /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/parseSpecialComponent.ts b/packages/cursorless-engine/src/core/parseSpecialComponent.ts new file mode 100644 index 0000000000..cdf6a64e81 --- /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 0000000000..bd9e702ffc --- /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 0000000000..9ad5386ad6 --- /dev/null +++ b/packages/cursorless-engine/src/core/specialTerms.ts @@ -0,0 +1,3 @@ +export const specialTerms = { + visualize: "visualize", +}; diff --git a/packages/cursorless-vscode-tutorial-webview/src/TutorialStep.tsx b/packages/cursorless-vscode-tutorial-webview/src/TutorialStep.tsx index f7e0764246..325092e8c7 100644 --- a/packages/cursorless-vscode-tutorial-webview/src/TutorialStep.tsx +++ b/packages/cursorless-vscode-tutorial-webview/src/TutorialStep.tsx @@ -34,16 +34,23 @@ export const TutorialStep: FunctionComponent = ({ {state.preConditionsMet ? ( - state.stepContent.map((fragment, i) => ( - - {fragment.type === "string" ? ( - {fragment.value} - ) : fragment.type === "command" ? ( - - ) : ( - "{fragment.value}" - )} - + 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; + } + } + })} +
)) ) : ( <>