diff --git a/changelog/2024-04-community-snippets.md b/changelog/2024-04-community-snippets.md new file mode 100644 index 0000000000..7613c7e5c9 --- /dev/null +++ b/changelog/2024-04-community-snippets.md @@ -0,0 +1,6 @@ +--- +tags: [enhancement] +pullRequest: 1998 +--- + +- Add support for using community snippets for wrapping / cursorless insertion instead of snippets defined in Cursorless. See [Using community snippets](../docs/user/experimental/snippets.md#using-community-snippets) for more information. diff --git a/cursorless-talon-dev/src/spoken_form_test.py b/cursorless-talon-dev/src/spoken_form_test.py index 055df9d0ff..525f6eae74 100644 --- a/cursorless-talon-dev/src/spoken_form_test.py +++ b/cursorless-talon-dev/src/spoken_form_test.py @@ -33,6 +33,8 @@ mockedGetValue = "" +community_snippets_tag_name = "user.cursorless_use_community_snippets" + @ctx.action_class("user") class UserActions: @@ -82,6 +84,20 @@ def private_cursorless_spoken_form_test_mode(enable: bool): "Cursorless spoken form tests are done. Talon microphone is re-enabled." ) + def private_cursorless_use_community_snippets(enable: bool): + """Enable/disable cursorless community snippets in test mode""" + if enable: + tags = set(ctx.tags) + tags.add(community_snippets_tag_name) + ctx.tags = list(tags) + else: + tags = set(ctx.tags) + tags.remove(community_snippets_tag_name) + ctx.tags = list(tags) + # Note: Test harness hangs if we don't print anything because it's + # waiting for stdout + print(f"Set community snippet enablement to {enable}") + def private_cursorless_spoken_form_test( phrase: str, mockedGetValue_: Optional[str] ): diff --git a/cursorless-talon/src/cursorless.talon b/cursorless-talon/src/cursorless.talon index f12bab8a3e..f1ab5aa206 100644 --- a/cursorless-talon/src/cursorless.talon +++ b/cursorless-talon/src/cursorless.talon @@ -24,15 +24,6 @@ tag: user.cursorless <user.cursorless_wrapper_paired_delimiter> {user.cursorless_wrap_action} <user.cursorless_target>: user.private_cursorless_wrap_with_paired_delimiter(cursorless_wrap_action, cursorless_target, cursorless_wrapper_paired_delimiter) -{user.cursorless_insert_snippet_action} <user.cursorless_insertion_snippet>: - user.private_cursorless_insert_snippet(cursorless_insertion_snippet) - -{user.cursorless_insert_snippet_action} {user.cursorless_insertion_snippet_single_phrase} <user.text> [{user.cursorless_phrase_terminator}]: - user.private_cursorless_insert_snippet_with_phrase(cursorless_insertion_snippet_single_phrase, text) - -{user.cursorless_wrapper_snippet} {user.cursorless_wrap_action} <user.cursorless_target>: - user.private_cursorless_wrap_with_snippet(cursorless_wrap_action, cursorless_target, cursorless_wrapper_snippet) - {user.cursorless_show_scope_visualizer} <user.cursorless_scope_type> [{user.cursorless_visualization_type}]: user.private_cursorless_show_scope_visualizer(cursorless_scope_type, cursorless_visualization_type or "content") {user.cursorless_hide_scope_visualizer}: diff --git a/cursorless-talon/src/snippet_cursorless.talon b/cursorless-talon/src/snippet_cursorless.talon new file mode 100644 index 0000000000..ffc28e20b4 --- /dev/null +++ b/cursorless-talon/src/snippet_cursorless.talon @@ -0,0 +1,14 @@ +mode: command +mode: user.cursorless_spoken_form_test +tag: user.cursorless +and not tag: user.cursorless_use_community_snippets +- + +{user.cursorless_insert_snippet_action} <user.cursorless_insertion_snippet>: + user.private_cursorless_insert_snippet(cursorless_insertion_snippet) + +{user.cursorless_insert_snippet_action} {user.cursorless_insertion_snippet_single_phrase} <user.text> [{user.cursorless_phrase_terminator}]: + user.private_cursorless_insert_snippet_with_phrase(cursorless_insertion_snippet_single_phrase, text) + +{user.cursorless_wrapper_snippet} {user.cursorless_wrap_action} <user.cursorless_target>: + user.private_cursorless_wrap_with_snippet(cursorless_wrap_action, cursorless_target, cursorless_wrapper_snippet) diff --git a/cursorless-talon/src/snippets.py b/cursorless-talon/src/snippets.py index 5cd9a83214..ae4b766bcb 100644 --- a/cursorless-talon/src/snippets.py +++ b/cursorless-talon/src/snippets.py @@ -16,6 +16,19 @@ class InsertionSnippet: destination: CursorlessDestination +@dataclass +class CommunityInsertionSnippet: + body: str + scopes: list[str] = None + + +@dataclass +class CommunityWrapperSnippet: + body: str + variable_name: str + scope: str = None + + mod = Module() mod.list("cursorless_insert_snippet_action", desc="Cursorless insert snippet action") @@ -27,6 +40,11 @@ class InsertionSnippet: desc="tag for enabling experimental snippet support", ) +mod.tag( + "cursorless_use_community_snippets", + "If active use community snippets instead of Cursorless snippets", +) + mod.list("cursorless_wrapper_snippet", desc="Cursorless wrapper snippet") mod.list( "cursorless_insertion_snippet_no_phrase", @@ -181,3 +199,21 @@ def cursorless_wrap_with_snippet( snippet_arg, target, ) + + def private_cursorless_insert_community_snippet( + name: str, destination: CursorlessDestination + ): + """Cursorless: Insert community snippet <name>""" + snippet: CommunityInsertionSnippet = actions.user.get_insertion_snippet(name) + actions.user.cursorless_insert_snippet( + snippet.body, destination, snippet.scopes + ) + + def private_cursorless_wrap_with_community_snippet( + name: str, target: CursorlessTarget + ): + """Cursorless: Wrap target with community snippet <name>""" + snippet: CommunityWrapperSnippet = actions.user.get_wrapper_snippet(name) + actions.user.cursorless_wrap_with_snippet( + snippet.body, target, snippet.variable_name, snippet.scope + ) diff --git a/cursorless-talon/src/snippets_community.talon b/cursorless-talon/src/snippets_community.talon new file mode 100644 index 0000000000..f94e491f83 --- /dev/null +++ b/cursorless-talon/src/snippets_community.talon @@ -0,0 +1,13 @@ +mode: command +mode: user.cursorless_spoken_form_test +tag: user.cursorless +and tag: user.cursorless_use_community_snippets +- + +# These snippets are defined in community + +{user.cursorless_insert_snippet_action} {user.snippet} <user.cursorless_destination>: + user.private_cursorless_insert_community_snippet(snippet, cursorless_destination) + +{user.snippet_wrapper} {user.cursorless_wrap_action} <user.cursorless_target>: + user.private_cursorless_wrap_with_community_snippet(snippet_wrapper, cursorless_target) diff --git a/docs/user/experimental/snippets.md b/docs/user/experimental/snippets.md index ece8393dbb..391f03d514 100644 --- a/docs/user/experimental/snippets.md +++ b/docs/user/experimental/snippets.md @@ -88,6 +88,16 @@ Note that each snippet can use `insertionScopeTypes` to indicate that it will au | `"snippet funk"` | Function; phrase becomes name | Function | ✅ | | `"snippet link"` | Markdown link; phrase becomes link text | | ✅ | +## Using community snippets + +The community Talon files now support their own snippet format. If you'd like to use these snippets for wrapping / cursorless insertion instead of snippets defined in Cursorless, add following line to your `settings.talon` file: + +```talon +tag(): user.cursorless_use_community_snippets +``` + +Note that this line will also disable any Cursorless snippets defined in your Cursorless customization CSVs. You will need to migrate your Cursorless snippets to the new community snippet format [described in community](https://github.com/talonhub/community/blob/main/core/snippets/README.md). If you'd be interested in a tool to help with this migration, please leave a comment on [cursorless-dev/cursorless#2149](https://github.com/cursorless-dev/cursorless/issues/2149), ideally with a link to your custom snippets for us to look at. + ## Customizing spoken forms As usual, the spoken forms for these snippets can be [customized by csv](../customization.md). The csvs are in the files in `cursorless-settings/experimental` with `snippet` in their name. diff --git a/packages/cursorless-engine/src/test/fixtures/communitySnippets.fixture.ts b/packages/cursorless-engine/src/test/fixtures/communitySnippets.fixture.ts new file mode 100644 index 0000000000..0c4d3f55e5 --- /dev/null +++ b/packages/cursorless-engine/src/test/fixtures/communitySnippets.fixture.ts @@ -0,0 +1,33 @@ +import { ActionDescriptor } from "@cursorless/common"; +import { spokenFormTest } from "./spokenFormTest"; + +const verticalRangeAction: ActionDescriptor = { + name: "insertSnippet", + destination: { + type: "primitive", + insertionMode: "after", + target: { + type: "primitive", + mark: { + character: "a", + symbolColor: "default", + type: "decoratedSymbol", + }, + }, + }, + snippetDescription: { + body: "```\n$0\n```", + type: "custom", + }, +}; + +/** + * These are spoken forms that have more than one way to say them, so we have to + * pick one in our spoken form generator, meaning we can't test the other in our + * Talon tests by relying on our recorded test fixtures alone. + */ +export const communitySnippetsSpokenFormsFixture = [ + spokenFormTest("snippet code after air", verticalRangeAction, undefined, { + useCommunitySnippets: true, + }), +]; diff --git a/packages/cursorless-engine/src/test/fixtures/spokenFormTest.ts b/packages/cursorless-engine/src/test/fixtures/spokenFormTest.ts index 2422955425..0bdcf1fa10 100644 --- a/packages/cursorless-engine/src/test/fixtures/spokenFormTest.ts +++ b/packages/cursorless-engine/src/test/fixtures/spokenFormTest.ts @@ -23,17 +23,24 @@ export interface SpokenFormTest { * {@link spokenForm} is spoken. */ commands: CommandV6[]; + + /** + * If `true`, use community snippets instead of Cursorless snippets + */ + useCommunitySnippets: boolean; } export function spokenFormTest( spokenForm: string, action: ActionDescriptor, mockedGetValue?: unknown, + { useCommunitySnippets = false }: SpokenFormTestOpts = {}, ): SpokenFormTest { return { spokenForm, mockedGetValue: wrapMockedGetValue(mockedGetValue), commands: [command(spokenForm, action)], + useCommunitySnippets, }; } @@ -41,11 +48,13 @@ export function multiActionSpokenFormTest( spokenForm: string, actions: ActionDescriptor[], mockedGetValue?: unknown, + { useCommunitySnippets = false }: SpokenFormTestOpts = {}, ): SpokenFormTest { return { spokenForm, mockedGetValue: wrapMockedGetValue(mockedGetValue), commands: actions.map((action) => command(spokenForm, action)), + useCommunitySnippets, }; } @@ -63,3 +72,7 @@ function command(spokenForm: string, action: ActionDescriptor): CommandV6 { action, }; } + +export interface SpokenFormTestOpts { + useCommunitySnippets?: boolean; +} diff --git a/packages/cursorless-engine/src/test/spokenForms.talon.test.ts b/packages/cursorless-engine/src/test/spokenForms.talon.test.ts index 81226188b4..086300c068 100644 --- a/packages/cursorless-engine/src/test/spokenForms.talon.test.ts +++ b/packages/cursorless-engine/src/test/spokenForms.talon.test.ts @@ -14,6 +14,8 @@ import { TalonRepl } from "../testUtil/TalonRepl"; import { synonymousSpokenFormsFixture } from "./fixtures/synonymousSpokenForms.fixture"; import { talonApiFixture } from "./fixtures/talonApi.fixture"; import { multiActionFixture } from "./fixtures/multiAction.fixture"; +import { communitySnippetsSpokenFormsFixture } from "./fixtures/communitySnippets.fixture"; +import { SpokenFormTestOpts } from "./fixtures/spokenFormTest"; suite("Talon spoken forms", async function () { const repl = new TalonRepl(); @@ -42,8 +44,13 @@ suite("Talon spoken forms", async function () { ...synonymousSpokenFormsFixture, ...talonApiFixture, ...multiActionFixture, - ].forEach(({ spokenForm, commands, mockedGetValue }) => - test(spokenForm, () => runTest(repl, spokenForm, commands, mockedGetValue)), + ...communitySnippetsSpokenFormsFixture, + ].forEach(({ spokenForm, commands, mockedGetValue, useCommunitySnippets }) => + test(spokenForm, () => + runTest(repl, spokenForm, commands, mockedGetValue, { + useCommunitySnippets, + }), + ), ); }); @@ -76,6 +83,7 @@ async function runTest( spokenForm: string, commandsLegacy: Command[], mockedGetValue?: unknown, + { useCommunitySnippets = false }: SpokenFormTestOpts = {}, ) { const commandsExpected = commandsLegacy.map((command) => ({ ...canonicalizeAndValidateCommand(command), @@ -101,24 +109,37 @@ async function runTest( ? "None" : JSON.stringify(JSON.stringify(mockedGetValue)); - const result = await repl.action( - `user.private_cursorless_spoken_form_test("${spokenForm}", ${mockedGetValueString})`, - ); + if (useCommunitySnippets) { + await repl.action(`user.private_cursorless_use_community_snippets(True)`); + } - const commandsActual = (() => { - try { - return JSON.parse(result); - } catch (e) { - throw Error(result); + try { + const result = await repl.action( + `user.private_cursorless_spoken_form_test("${spokenForm}", ${mockedGetValueString})`, + ); + + const commandsActual = (() => { + try { + return JSON.parse(result); + } catch (e) { + throw Error(result); + } + })(); + + assert.deepStrictEqual(commandsActual, commandsExpected); + } finally { + if (useCommunitySnippets) { + await repl.action( + `user.private_cursorless_use_community_snippets(False)`, + ); } - })(); - - assert.deepStrictEqual(commandsActual, commandsExpected); + } } async function setTestMode(repl: TalonRepl, enabled: boolean) { const arg = enabled ? "True" : "False"; await repl.action(`user.private_cursorless_spoken_form_test_mode(${arg})`); + await repl.action(`user.private_cursorless_use_community_snippets(False)`); // If you have warnings in your talon user files, they will be printed to the // repl when you run the above action. We need to eat them so that they don't