diff --git a/docs/customization.md b/docs/customization.md index 522b4ca7..275c02fa 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -33,7 +33,7 @@ reload, as cursorless uses these lines to track disabled spoken forms. Simply modify the spoken form in the first column of any of the csvs in the directory above to change the spoken you'd like to use. The new spoken form will be usable immediately. -Multiple spoken forms can be used for the same action using the pipe operator +Multiple spoken forms can be used for the same action using the pipe operator `remove|delete` ### New features @@ -46,3 +46,46 @@ If you'd like to remove an action, scope type, etc, you can simply set the spoken form in the first column to any thing starting with `-`. Please don't delete any lines, as that will trigger cursorless to automatically add the spoken form back on talon restart. + +## \[Experimental\] Cursorless custom VSCode actions + +You can use Cursorless to run any built-in VSCode command on a specific target. + +Just add your custom commands to: `experimental/actions_custom.csv`. For example, if you wanted to be able to say `"push down "` to move the line(s) containing target `` downwards, you could do the following: + +```csv +Spoken form, VSCode command +push down, editor.action.moveLinesDownAction +``` + +Now when you say eg "push down air and bat", cursorless will first select the two tokens with a gray hat over the `a` and `b`, then issue the VSCode command `editor.action.moveLinesDownAction`, and then restore your original selection. + +## Cursorless public API + +Cursorless exposes a couple talon actions and captures that you can use to define your own custom command grammar leveraging cursorless targets. + +### Public Talon captures + +- `` + Represents a cursorless target, such as `"air"`, `"this"`, `"air past bat"`, `"air and bat"`, `"funk air past token bat and class cap"`, etc + +### Public Talon actions + +- `user.cursorless_command(action_id: str, target: cursorless_target)` + Perform a Cursorless command on the given target + eg: `user.cursorless_command("setSelection", cursorless_target)` +- `user.cursorless_vscode_command(command_id: str, target: cursorless_target)` + Performs a VSCode command on the given target + eg: `user.cursorless_vscode_command("editor.action.addCommentLine", cursorless_target)` + +### Example of combining capture and action + +```talon +add dock string : + user.cursorless_command("editNewLineAfter", cursorless_target) + "\"\"\"\"\"\"" + key(left:3) + +push down: + user.cursorless_vscode_command("editor.action.moveLinesDownAction", cursorless_target) +``` diff --git a/src/actions/actions.py b/src/actions/actions.py index c177976c..b0ca19d3 100644 --- a/src/actions/actions.py +++ b/src/actions/actions.py @@ -1,130 +1,81 @@ -from talon import Module, actions, app -from dataclasses import dataclass +from talon import Module, app, actions from ..csv_overrides import init_csv_and_watch_changes -from .homophones import run_homophones_action -from .find import run_find_action -from .call import run_call_action +from .actions_simple import simple_action_defaults +from .actions_callback import callback_action_defaults, callback_action_map +from .actions_makeshift import ( + makeshift_action_defaults, + makeshift_action_map, +) +from .actions_custom import custom_action_defaults mod = Module() -@dataclass -class MakeshiftAction: - term: str - identifier: str - vscode_command_id: str - pre_command_sleep: float = 0 - post_command_sleep: float = 0 - - -# NOTE: Please do not change these dicts. Use the CSVs for customization. -# See https://github.com/pokey/cursorless-talon/blob/main/docs/customization.md -makeshift_actions = [ - MakeshiftAction("define", "revealDefinition", "editor.action.revealDefinition"), - MakeshiftAction( - "type deaf", "revealTypeDefinition", "editor.action.goToTypeDefinition" - ), - MakeshiftAction("hover", "showHover", "editor.action.showHover"), - MakeshiftAction("inspect", "showDebugHover", "editor.debug.action.showDebugHover"), - MakeshiftAction( - "quick fix", "showQuickFix", "editor.action.quickFix", pre_command_sleep=0.3 - ), - MakeshiftAction("reference", "showReferences", "references-view.find"), - MakeshiftAction("rename", "rename", "editor.action.rename", post_command_sleep=0.1), -] - -makeshift_action_map = {action.identifier: action for action in makeshift_actions} - - -@dataclass -class CallbackAction: - term: str - identifier: str - callback: callable - - -# NOTE: Please do not change these dicts. Use the CSVs for customization. -# See https://github.com/pokey/cursorless-talon/blob/main/docs/customization.md -callbacks = [ - CallbackAction("call", "callAsFunction", run_call_action), - CallbackAction("scout", "findInDocument", run_find_action), - CallbackAction("phones", "nextHomophone", run_homophones_action), -] - -callbacks_map = {callback.identifier: callback.callback for callback in callbacks} - - -mod.list("cursorless_simple_action", desc="Supported actions for cursorless navigation") - - -# NOTE: Please do not change these dicts. Use the CSVs for customization. -# See https://github.com/pokey/cursorless-talon/blob/main/docs/customization.md -simple_actions = { - "bottom": "scrollToBottom", - "breakpoint": "toggleLineBreakpoint", - "carve": "cutToClipboard", - "center": "scrollToCenter", - "chuck": "remove", - "change": "clearAndSetSelection", - "clone up": "insertCopyBefore", - "clone": "insertCopyAfter", - "comment": "toggleLineComment", - "copy": "copyToClipboard", - "crown": "scrollToTop", - "dedent": "outdentLine", - "drink": "editNewLineBefore", - "drop": "insertEmptyLineBefore", - "extract": "extractVariable", - "float": "insertEmptyLineAfter", - "fold": "foldRegion", - "give": "deselect", - "indent": "indentLine", - "paste to": "pasteFromClipboard", - "post": "setSelectionAfter", - "pour": "editNewLineAfter", - "pre": "setSelectionBefore", - "puff": "insertEmptyLinesAround", - "reverse": "reverseTargets", - "scout all": "findInWorkspace", - "sort": "sortTargets", - "take": "setSelection", - "unfold": "unfoldRegion", - **{action.term: action.identifier for action in makeshift_actions}, - **{callback.term: callback.identifier for callback in callbacks}, -} +@mod.capture( + rule=( + "{user.cursorless_simple_action} |" + "{user.cursorless_makeshift_action} |" + "{user.cursorless_callback_action} |" + "{user.cursorless_custom_action}" + ) +) +def cursorless_action_or_vscode_command(m) -> dict: + try: + value = m.cursorless_custom_action + type = "vscode_command" + except AttributeError: + value = m[0] + type = "cursorless_action" + return { + "value": value, + "type": type, + } @mod.action_class class Actions: - def cursorless_simple_action(action: str, targets: dict): - """Perform cursorless simple action""" - if action in callbacks_map: - return callbacks_map[action](targets) - elif action in makeshift_action_map: - return run_makeshift_action(action, targets) + def cursorless_command(action_id: str, target: dict): + """Perform cursorless command on target""" + if action_id in callback_action_map: + return callback_action_map[action_id](target) + elif action_id in makeshift_action_map: + command, arguments = makeshift_action_map[action_id] + return vscode_command(command, target, arguments) else: - return actions.user.cursorless_single_target_command(action, targets) + return actions.user.cursorless_single_target_command(action_id, target) + + def cursorless_vscode_command(command_id: str, target: dict): + """Perform vscode command on cursorless target""" + return vscode_command(command_id, target) + def cursorless_action_or_vscode_command(instruction: dict, target: dict): + """Perform cursorless action or vscode command on target (internal use only)""" + type = instruction["type"] + value = instruction["value"] + if type == "cursorless_action": + return actions.user.cursorless_command(value, target) + elif type == "vscode_command": + return actions.user.cursorless_vscode_command(value, target) -def run_makeshift_action(action: str, targets: dict): - """Execute makeshift action""" - makeshift_action = makeshift_action_map[action] - actions.user.cursorless_single_target_command("setSelection", targets) - actions.sleep(makeshift_action.pre_command_sleep) - actions.user.vscode(makeshift_action.vscode_command_id) - actions.sleep(makeshift_action.post_command_sleep) + +def vscode_command(command_id: str, target: dict, arguments: dict = {}): + return actions.user.cursorless_single_target_command( + "executeCommand", target, command_id, arguments + ) -# NOTE: Please do not change these dicts. Use the CSVs for customization. -# See https://github.com/pokey/cursorless-talon/blob/main/docs/customization.md default_values = { - "simple_action": simple_actions, + "simple_action": simple_action_defaults, + "callback_action": callback_action_defaults, + "makeshift_action": makeshift_action_defaults, + "custom_action": custom_action_defaults, "swap_action": {"swap": "swapTargets"}, "move_bring_action": {"bring": "replaceWithTarget", "move": "moveToTarget"}, "wrap_action": {"wrap": "wrapWithPairedDelimiter", "repack": "rewrap"}, "reformat_action": {"format": "applyFormatter"}, } + ACTION_LIST_NAMES = default_values.keys() diff --git a/src/actions/actions_callback.py b/src/actions/actions_callback.py new file mode 100644 index 00000000..de07c637 --- /dev/null +++ b/src/actions/actions_callback.py @@ -0,0 +1,32 @@ +from talon import Module +from dataclasses import dataclass +from .homophones import run_homophones_action +from .find import run_find_action +from .call import run_call_action + + +@dataclass +class CallbackAction: + term: str + identifier: str + callback: callable + + +# NOTE: Please do not change these dicts. Use the CSVs for customization. +# See https://github.com/pokey/cursorless-talon/blob/main/docs/customization.md +callbacks = [ + CallbackAction("call", "callAsFunction", run_call_action), + CallbackAction("scout", "findInDocument", run_find_action), + CallbackAction("phones", "nextHomophone", run_homophones_action), +] + +callback_action_defaults = { + callback.term: callback.identifier for callback in callbacks +} +callback_action_map = {callback.identifier: callback.callback for callback in callbacks} + +mod = Module() +mod.list( + "cursorless_callback_action", + desc="Supported callback actions for cursorless navigation", +) diff --git a/src/actions/actions_custom.py b/src/actions/actions_custom.py new file mode 100644 index 00000000..2361da0d --- /dev/null +++ b/src/actions/actions_custom.py @@ -0,0 +1,24 @@ +from talon import Module, app +from ..csv_overrides import init_csv_and_watch_changes, SPOKEN_FORM_HEADER + +custom_action_defaults = {} + + +mod = Module() +mod.list( + "cursorless_custom_action", + desc="Supported custom actions for cursorless navigation", +) + + +def on_ready(): + init_csv_and_watch_changes( + "experimental/actions_custom", + custom_action_defaults, + headers=[SPOKEN_FORM_HEADER, "VSCode command"], + allow_unknown_values=True, + default_list_name="custom_action", + ) + + +app.register("ready", on_ready) diff --git a/src/actions/actions_makeshift.py b/src/actions/actions_makeshift.py new file mode 100644 index 00000000..f38e8458 --- /dev/null +++ b/src/actions/actions_makeshift.py @@ -0,0 +1,61 @@ +from talon import Module +from dataclasses import dataclass + + +@dataclass +class MakeshiftAction: + term: str + identifier: str + vscode_command_id: str + vscode_command_args: list = None + restore_selection: bool = False + pre_command_sleep: int = None + post_command_sleep: int = None + + +# NOTE: Please do not change these dicts. Use the CSVs for customization. +# See https://github.com/pokey/cursorless-talon/blob/main/docs/customization.md +makeshift_actions = [ + MakeshiftAction("define", "revealDefinition", "editor.action.revealDefinition"), + MakeshiftAction( + "type deaf", "revealTypeDefinition", "editor.action.goToTypeDefinition" + ), + MakeshiftAction("hover", "showHover", "editor.action.showHover"), + MakeshiftAction("inspect", "showDebugHover", "editor.debug.action.showDebugHover"), + MakeshiftAction( + "quick fix", "showQuickFix", "editor.action.quickFix", restore_selection=True + ), + MakeshiftAction( + "reference", "showReferences", "references-view.find", restore_selection=True + ), + MakeshiftAction("rename", "rename", "editor.action.rename", restore_selection=True), +] + +makeshift_action_defaults = { + action.term: action.identifier for action in makeshift_actions +} + +mod = Module() +mod.list( + "cursorless_makeshift_action", + desc="Supported makeshift actions for cursorless navigation", +) + + +def get_parameters(action: MakeshiftAction): + command = action.vscode_command_id + arguments = { + "restoreSelection": action.restore_selection, + } + if action.vscode_command_args: + arguments["commandArgs"] = action.vscode_command_args + if action.pre_command_sleep: + arguments["preCommandSleep"] = action.pre_command_sleep + if action.post_command_sleep: + arguments["postCommandSleep"] = action.post_command_sleep + return command, arguments + + +makeshift_action_map = { + action.identifier: get_parameters(action) for action in makeshift_actions +} diff --git a/src/actions/actions_simple.py b/src/actions/actions_simple.py new file mode 100644 index 00000000..80c08572 --- /dev/null +++ b/src/actions/actions_simple.py @@ -0,0 +1,41 @@ +from talon import Module + +# NOTE: Please do not change these dicts. Use the CSVs for customization. +# See https://github.com/pokey/cursorless-talon/blob/main/docs/customization.md +simple_action_defaults = { + "bottom": "scrollToBottom", + "breakpoint": "toggleLineBreakpoint", + "carve": "cutToClipboard", + "center": "scrollToCenter", + "chuck": "remove", + "change": "clearAndSetSelection", + "clone up": "insertCopyBefore", + "clone": "insertCopyAfter", + "comment": "toggleLineComment", + "copy": "copyToClipboard", + "crown": "scrollToTop", + "dedent": "outdentLine", + "drink": "editNewLineBefore", + "drop": "insertEmptyLineBefore", + "extract": "extractVariable", + "float": "insertEmptyLineAfter", + "fold": "foldRegion", + "give": "deselect", + "indent": "indentLine", + "paste to": "pasteFromClipboard", + "post": "setSelectionAfter", + "pour": "editNewLineAfter", + "pre": "setSelectionBefore", + "puff": "insertEmptyLinesAround", + "reverse": "reverseTargets", + "scout all": "findInWorkspace", + "sort": "sortTargets", + "take": "setSelection", + "unfold": "unfoldRegion", +} + +mod = Module() +mod.list( + "cursorless_simple_action", + desc="Supported simple actions for cursorless navigation", +) diff --git a/src/command.py b/src/command.py index d49eff6d..0e07bab1 100644 --- a/src/command.py +++ b/src/command.py @@ -1,5 +1,5 @@ from talon import actions, Module, speech_system -from typing import Any, List +from typing import Any mod = Module() diff --git a/src/csv_overrides.py b/src/csv_overrides.py index e44b3d1e..8067fda0 100644 --- a/src/csv_overrides.py +++ b/src/csv_overrides.py @@ -1,8 +1,11 @@ +from talon import Context, Module, actions, fs, app from typing import Optional -from .conventions import get_cursorless_list_name -from talon import Context, Module, actions, fs, app, settings from datetime import datetime from pathlib import Path +from .conventions import get_cursorless_list_name + +SPOKEN_FORM_HEADER = "Spoken form" +CURSORLESS_IDENTIFIER_HEADER = "Cursorless identifier" mod = Module() @@ -20,6 +23,7 @@ def init_csv_and_watch_changes( extra_ignored_values: list[str] = None, allow_unknown_values: bool = False, default_list_name: Optional[str] = None, + headers: list[str] = [SPOKEN_FORM_HEADER, CURSORLESS_IDENTIFIER_HEADER], ctx: Context = Context(), ): """ @@ -58,6 +62,7 @@ def on_watch(path, flags): if file_path.match(path): current_values, has_errors = read_file( file_path, + headers, super_default_values.values(), extra_ignored_values, allow_unknown_values, @@ -76,6 +81,7 @@ def on_watch(path, flags): if file_path.is_file(): current_values = update_file( file_path, + headers, super_default_values, extra_ignored_values, allow_unknown_values, @@ -89,7 +95,7 @@ def on_watch(path, flags): ctx, ) else: - create_file(file_path, super_default_values) + create_file(file_path, headers, super_default_values) update_dicts( default_values, super_default_values, @@ -140,7 +146,7 @@ def update_dicts( raise # Convert result map back to result list - results = {key: {} for key in default_values} + results = {res["list"]: {} for res in results_map.values()} for obj in results_map.values(): value = obj["value"] key = obj["key"] @@ -155,12 +161,17 @@ def update_dicts( def update_file( path: Path, + headers: list[str], default_values: dict, extra_ignored_values: list[str], allow_unknown_values: bool, ): current_values, has_errors = read_file( - path, default_values.values(), extra_ignored_values, allow_unknown_values + path, + headers, + default_values.values(), + extra_ignored_values, + allow_unknown_values, ) current_identifiers = current_values.values() @@ -196,18 +207,14 @@ def update_file( return current_values -def create_line(key: str, value: str): - return f"{key}, {value}" - - -SPOKEN_FORM_HEADER = "Spoken form" -CURSORLESS_IDENTIFIER_HEADER = "Cursorless identifier" -header_row = create_line(SPOKEN_FORM_HEADER, CURSORLESS_IDENTIFIER_HEADER) +def create_line(*cells: str): + return ", ".join(cells) -def create_file(path: Path, default_values: dict): +def create_file(path: Path, headers: list[str], default_values: dict): lines = [create_line(key, default_values[key]) for key in sorted(default_values)] - lines.insert(0, header_row) + lines.insert(0, create_line(*headers)) + lines.append("") path.write_text("\n".join(lines)) @@ -226,6 +233,7 @@ def csv_error(path: Path, index: int, message: str, value: str): def read_file( path: Path, + headers: list[str], default_identifiers: list[str], extra_ignored_values: list[str], allow_unknown_values: bool, @@ -236,15 +244,25 @@ def read_file( result = {} used_identifiers = [] has_errors = False - seen_header = False + seen_headers = False + expected_headers = create_line(*headers) + for i, raw_line in enumerate(lines): line = raw_line.strip() - if len(line) == 0 or line.startswith("#"): + if not line or line.startswith("#"): + continue + + if not seen_headers: + seen_headers = True + if line != expected_headers: + has_errors = True + csv_error(path, i, "Malformed header", line) + print(f"Expected '{expected_headers}'") continue parts = line.split(",") - if len(parts) != 2: + if len(parts) != len(headers): has_errors = True csv_error(path, i, "Malformed csv entry", line) continue @@ -252,14 +270,6 @@ def read_file( key = parts[0].strip() value = parts[1].strip() - if not seen_header: - if key != SPOKEN_FORM_HEADER or value != CURSORLESS_IDENTIFIER_HEADER: - has_errors = True - csv_error(path, i, "Malformed header", line) - print(f"Expected '{header_row}'") - seen_header = True - continue - if ( value not in default_identifiers and value not in extra_ignored_values diff --git a/src/cursorless.talon b/src/cursorless.talon index 6023a8de..dc02a39d 100644 --- a/src/cursorless.talon +++ b/src/cursorless.talon @@ -1,8 +1,8 @@ app: vscode - -{user.cursorless_simple_action} : - user.cursorless_simple_action(cursorless_simple_action, cursorless_target) + : + user.cursorless_action_or_vscode_command(cursorless_action_or_vscode_command, cursorless_target) {user.cursorless_swap_action} : user.cursorless_multiple_target_command(cursorless_swap_action, cursorless_swap_targets)