diff --git a/docs/README.md b/docs/README.md index 816f00ad..000a2c78 100644 --- a/docs/README.md +++ b/docs/README.md @@ -42,6 +42,7 @@ Note: If you'd like to customize any of the spoken forms, please see the [docume - [Scroll](#scroll) - [Insert/Use/Repeat](#insertuserepeat) - [Wrap](#wrap) + - [\[experimental\] Wrap with snippet](#experimental-wrap-with-snippet) - [Show definition/reference/quick fix](#show-definitionreferencequick-fix) - [Fold/unfold](#foldunfold) - [Extract](#extract) @@ -382,6 +383,10 @@ eg: `square wrap blue air` Wraps the token containing letter 'a' with a blue hat in square brackets. +#### \[experimental\] Wrap with snippet + +See [experimental documentation](experimental.md#wrapper-snippets). + ### Show definition/reference/quick fix - `"def show"` diff --git a/docs/experimental.md b/docs/experimental.md new file mode 100644 index 00000000..9162e7c5 --- /dev/null +++ b/docs/experimental.md @@ -0,0 +1,97 @@ +# Experimental features + +Here we document features which are currently considered experimental. They are generally functional and well tested, but the API is subject change. + +## Wrapper snippets + +![Wrapper snippet demo](images/tryWrapFine.gif) + +In addition to wrapping with paired delimiters (eg `"square wrap"`, `"round wrap"`, etc), we experimentally support wrapping with snippets. Cursorless ships with a few built-in snippets, but users can also use their own snippets. + +### Enabling wrapper snippets + +Add the following line to the end of your `settings.talon` (or any other `.talon` file that will be active when vscode is focused): + +``` +tag(): user.cursorless_experimental_snippets +``` + +### Using wrapper snippets + +#### Command syntax + +The command syntax is as follows: + +``` +" wrap " +``` + +#### Examples + +- `"try wrap air"`: Wrap the statement containing the marked `a` in a try-catch statement +- `"try wrap air past bat"`: Wrap the sequence of statements from the marked `a` to the marked `b` in a try-catch statement + +#### Default scope types + +Each snippet wrapper has a default scope type. When you refer to a target, by default it will expand to the given scope type. This way, for example, when you say `"try wrap air"`, it will refer to the statement containing `a` rather than just the token. + +### Built-in wrapper snippets + +| Default spoken form | Snippet | Default target scope type | +| ------------------- | --------------------------------------------- | ------------------------- | +| `"if wrap"` | If statement | Statement | +| `"else wrap"` | If-else statement; target goes in else branch | Statement | +| `"if else wrap"` | If-else statement; target goes in if branch | Statement | +| `"try wrap"` | Try-catch statement | Statement | + +### Customizing spoken forms + +As usual, the spoken forms for these wrapper snippets can be [customized by csv](customization.md). The csvs are in the file `cursorless-settings/experimental/wrapper_snippets.csv`. + +### Adding your own snippets + +To define your own wrapper snippets, proceed as follows: + +#### Define snippets in vscode + +1. Set the `cursorless.experimental.snippetsDir` setting to a directory in which you'd like to create your snippets. +2. Add snippets to the directory in files ending in `.cursorless-snippets`. See the [documentation](https://github.com/pokey/cursorless-vscode/tree/main/docs/experimental/snippets.md) for the cursorless snippet format. + +#### 2. Add snippet to spoken forms csv + +For each snippet that you'd like to be able to use as a wrapper snippet, add a line to the `cursorless-settings/experimental/wrapper_snippets.csv` csv overrides file. The first column is the desired spoken form, and the second column is of the form `.`, where `name` is the name of the snippet (ie the key in your snippet json file), and `variable` is one of the placeholder variables in your snippet where the target should go. + +### Customizing built-in snippets + +To customize a built-in snippet, just define a custom snippet (as above), but +use the same name as the cursorless core snippet you'd like to change, and give +definitions along with scopes where you'd like your override to be active. Here +is an example: + +```json +{ + "tryCatchStatement": { + "definitions": [ + { + "scope": { + "langIds": [ + "typescript", + "typescriptreact", + "javascript", + "javascriptreact" + ] + }, + "body": [ + "try {", + "\t$body", + "} catch (err) {", + "\t$exceptBody", + "}" + ] + } + ] + } +} +``` + +The above will change the definition of the try-catch statement in typescript. diff --git a/docs/images/tryWrapFine.gif b/docs/images/tryWrapFine.gif new file mode 100644 index 00000000..d02b9e13 Binary files /dev/null and b/docs/images/tryWrapFine.gif differ diff --git a/src/actions/wrap.py b/src/actions/wrap.py index 7a9d5990..75de7e22 100644 --- a/src/actions/wrap.py +++ b/src/actions/wrap.py @@ -1,14 +1,73 @@ +from typing import Union from ..paired_delimiter import paired_delimiters_map -from talon import Module +from talon import Module, actions, app, Context +from ..csv_overrides import init_csv_and_watch_changes mod = Module() +mod.tag( + "cursorless_experimental_snippets", + desc="tag for enabling experimental snippet support", +) mod.list("cursorless_wrap_action", desc="Cursorless wrap action") +mod.list("cursorless_wrapper_snippet", desc="Cursorless wrapper snippet") +experimental_snippets_ctx = Context() +experimental_snippets_ctx.matches = r""" +tag: user.cursorless_experimental_snippets +""" -@mod.capture(rule=("{user.cursorless_paired_delimiter}")) -def cursorless_wrapper(m) -> list[str]: - paired_delimiter_info = paired_delimiters_map[m.cursorless_paired_delimiter] - return [paired_delimiter_info.left, paired_delimiter_info.right] + +# NOTE: Please do not change these dicts. Use the CSVs for customization. +# See https://github.com/pokey/cursorless-talon/blob/main/docs/customization.md +wrapper_snippets = { + "else": "ifElseStatement.alternative", + "if else": "ifElseStatement.consequence", + "if": "ifStatement.consequence", + "try": "tryCatchStatement.body", +} + + +@mod.capture( + rule=( + "({user.cursorless_paired_delimiter} | {user.cursorless_wrapper_snippet}) {user.cursorless_wrap_action}" + ) +) +def cursorless_wrapper(m) -> Union[list[str], str]: + try: + paired_delimiter_info = paired_delimiters_map[m.cursorless_paired_delimiter] + return { + "action": "wrapWithPairedDelimiter", + "extra_args": [paired_delimiter_info.left, paired_delimiter_info.right], + } + except AttributeError: + return { + "action": "wrapWithSnippet", + "extra_args": [m.cursorless_wrapper_snippet], + } + + +@mod.action_class +class Actions: + def cursorless_wrap(cursorless_wrapper: dict, targets: dict): + """Perform cursorless wrap action""" + actions.user.cursorless_single_target_command_with_arg_list( + cursorless_wrapper["action"], targets, cursorless_wrapper["extra_args"] + ) + + +def on_ready(): + init_csv_and_watch_changes( + "experimental/wrapper_snippets", + { + "wrapper_snippet": wrapper_snippets, + }, + allow_unknown_values=True, + default_list_name="wrapper_snippet", + ctx=experimental_snippets_ctx, + ) + + +app.register("ready", on_ready) \ No newline at end of file diff --git a/src/csv_overrides.py b/src/csv_overrides.py index ed004b45..bdf445dc 100644 --- a/src/csv_overrides.py +++ b/src/csv_overrides.py @@ -1,3 +1,4 @@ +from typing import Optional from .conventions import get_cursorless_list_name from talon import Context, Module, actions, fs, app, settings from datetime import datetime @@ -5,7 +6,6 @@ mod = Module() -ctx = Context() cursorless_settings_directory = mod.setting( "cursorless_settings_directory", type=str, @@ -17,7 +17,10 @@ def init_csv_and_watch_changes( filename: str, default_values: dict[str, dict], - extra_acceptable_values: list[str] = None, + extra_ignored_values: list[str] = None, + allow_unknown_values: bool = False, + default_list_name: Optional[str] = None, + ctx: Context = Context(), ): """ Initialize a cursorless settings csv, creating it if necessary, and watch @@ -37,37 +40,67 @@ def init_csv_and_watch_changes( `cursorles-settings` dir default_values (dict[str, dict]): The default values for the lists to be customized in the given csv - extra_acceptable_values list[str]: Don't throw an exception if any of - these appear as values + extra_ignored_values list[str]: Don't throw an exception if any of + these appear as values; just ignore them and don't add them to any list + allow_unknown_values bool: If unknown values appear, just put them in the list + default_list_name Optional[str]: If unknown values are allowed, put any + unknown values in this list """ - if extra_acceptable_values is None: - extra_acceptable_values = [] + if extra_ignored_values is None: + extra_ignored_values = [] - dir_path, file_path = get_file_paths(filename) + file_path = get_full_path(filename) super_default_values = get_super_values(default_values) - dir_path.mkdir(parents=True, exist_ok=True) + file_path.parent.mkdir(parents=True, exist_ok=True) def on_watch(path, flags): if file_path.match(path): current_values, has_errors = read_file( - file_path, super_default_values.values(), extra_acceptable_values + file_path, + super_default_values.values(), + extra_ignored_values, + allow_unknown_values, + ) + update_dicts( + default_values, + current_values, + extra_ignored_values, + allow_unknown_values, + default_list_name, + ctx, ) - update_dicts(default_values, current_values, extra_acceptable_values) - fs.watch(dir_path, on_watch) + fs.watch(file_path.parent, on_watch) if file_path.is_file(): current_values = update_file( - file_path, super_default_values, extra_acceptable_values + file_path, + super_default_values, + extra_ignored_values, + allow_unknown_values, + ) + update_dicts( + default_values, + current_values, + extra_ignored_values, + allow_unknown_values, + default_list_name, + ctx, ) - update_dicts(default_values, current_values, extra_acceptable_values) else: create_file(file_path, super_default_values) - update_dicts(default_values, super_default_values, extra_acceptable_values) + update_dicts( + default_values, + super_default_values, + extra_ignored_values, + allow_unknown_values, + default_list_name, + ctx, + ) def unsubscribe(): - fs.unwatch(dir_path, on_watch) + fs.unwatch(file_path.parent, on_watch) return unsubscribe @@ -79,7 +112,10 @@ def is_removed(value: str): def update_dicts( default_values: dict[str, dict], current_values: dict, - extra_acceptable_values: list[str], + extra_ignored_values: list[str], + allow_unknown_values: bool, + default_list_name: Optional[str], + ctx: Context, ): # Create map with all default values results_map = {} @@ -92,8 +128,14 @@ def update_dicts( try: results_map[value]["key"] = key except KeyError: - if value in extra_acceptable_values: + if value in extra_ignored_values: pass + elif allow_unknown_values: + results_map[value] = { + "key": key, + "value": value, + "list": default_list_name, + } else: raise @@ -110,9 +152,14 @@ def update_dicts( ctx.lists[get_cursorless_list_name(list_name)] = dict -def update_file(path: Path, default_values: dict, extra_acceptable_values: list[str]): +def update_file( + path: Path, + default_values: dict, + extra_ignored_values: list[str], + allow_unknown_values: bool, +): current_values, has_errors = read_file( - path, default_values.values(), extra_acceptable_values + path, default_values.values(), extra_ignored_values, allow_unknown_values ) current_identifiers = current_values.values() @@ -178,7 +225,10 @@ def csv_error(path: Path, index: int, message: str, value: str): def read_file( - path: Path, default_identifiers: list[str], extra_acceptable_values: list[str] + path: Path, + default_identifiers: list[str], + extra_ignored_values: list[str], + allow_unknown_values: bool, ): with open(path) as f: lines = list(f) @@ -210,7 +260,11 @@ def read_file( seen_header = True continue - if value not in default_identifiers and value not in extra_acceptable_values: + if ( + value not in default_identifiers + and value not in extra_ignored_values + and not allow_unknown_values + ): has_errors = True csv_error(path, i, "Unknown identifier", value) continue @@ -229,17 +283,17 @@ def read_file( return result, has_errors -def get_file_paths(filename: str): +def get_full_path(filename: str): if not filename.endswith(".csv"): filename = f"{filename}.csv" + user_dir = actions.path.talon_user() settings_directory = Path(cursorless_settings_directory.get()) if not settings_directory.is_absolute(): settings_directory = user_dir / settings_directory - csv_path = Path(settings_directory, filename) - return settings_directory, csv_path + return (settings_directory / filename).resolve() def get_super_values(values: dict[str, dict]): diff --git a/src/cursorless.talon b/src/cursorless.talon index 146b5d50..0b0a7c75 100644 --- a/src/cursorless.talon +++ b/src/cursorless.talon @@ -13,8 +13,8 @@ app: vscode {user.cursorless_reformat_action} at : user.cursorless_reformat(cursorless_target, formatters) - {user.cursorless_wrap_action} : - user.cursorless_single_target_command_with_arg_list(cursorless_wrap_action, cursorless_target, cursorless_wrapper) + : + user.cursorless_wrap(cursorless_wrapper, cursorless_target) cursorless help: user.cursorless_cheat_sheet_toggle() cursorless instructions: user.cursorless_open_instructions() \ No newline at end of file