├── src ├── vendor │ ├── __init__.py │ ├── jstyleson.py │ └── inflection.py ├── versions.py ├── conventions.py ├── cursorless_global.talon ├── modifiers │ ├── matching_pair_symbol.py │ ├── position.py │ ├── interior.py │ ├── head_tail.py │ ├── simple_scope_modifier.py │ ├── modifiers.py │ ├── scopes.py │ ├── ordinal_scope.py │ └── relative_scope.py ├── marks │ ├── mark.py │ ├── simple_mark.py │ ├── mark_types.py │ ├── literal_mark.py │ ├── lines_number.py │ └── decorated_mark.py ├── actions │ ├── replace.py │ ├── execute_command.py │ ├── paste.py │ ├── call.py │ ├── reformat.py │ ├── swap.py │ ├── bring_move.py │ ├── homophones.py │ ├── get_text.py │ ├── wrap.py │ ├── generate_snippet.py │ └── actions.py ├── targets │ ├── range_type.py │ ├── primitive_target.py │ ├── target.py │ ├── destination.py │ ├── target_types.py │ └── range_target.py ├── terms.py ├── number_small.py ├── cheatsheet │ ├── sections │ │ ├── special_marks.py │ │ ├── destinations.py │ │ ├── scopes.py │ │ ├── get_scope_visualizer.py │ │ ├── compound_targets.py │ │ ├── tutorial.py │ │ ├── actions.py │ │ └── modifiers.py │ ├── get_list.py │ └── cheat_sheet.py ├── scope_visualizer.py ├── apps │ ├── cursorless_vscode.py │ └── vscode_settings.py ├── check_community_repo.py ├── public_api.py ├── cursorless_command_server.py ├── spoken_forms_output.py ├── snippets │ ├── snippets_deprecated.py │ ├── snippets_get.py │ ├── snippet_types.py │ └── snippets.py ├── private_api │ ├── extract_decorated_marks.py │ └── private_api.py ├── paired_delimiter.py ├── spoken_scope_forms.py ├── cursorless.talon ├── cursorless.py ├── command.py ├── get_grapheme_spoken_form_entries.py ├── fallback.py ├── spoken_forms.py ├── spoken_forms.json └── csv_overrides.py ├── .github ├── CODEOWNERS ├── PULL_REQUEST_TEMPLATE.md └── ISSUE_TEMPLATE │ └── config.yml ├── .gitignore ├── docs ├── README.md ├── experimental.md └── customization.md ├── LICENSE └── README.md /src/vendor/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @pokey 2 | -------------------------------------------------------------------------------- /src/versions.py: -------------------------------------------------------------------------------- 1 | COMMAND_VERSION = 7 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.flac 2 | data/ 3 | .vscode/settings.json 4 | .DS_Store 5 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | Cursorless is now monorepo 🙌. The docs now live at https://www.cursorless.org/docs/. 2 | -------------------------------------------------------------------------------- /src/conventions.py: -------------------------------------------------------------------------------- 1 | def get_cursorless_list_name(name: str): 2 | return f"user.cursorless_{name}" 3 | -------------------------------------------------------------------------------- /docs/experimental.md: -------------------------------------------------------------------------------- 1 | Cursorless is now monorepo 🙌. This document now lives at https://www.cursorless.org/docs/user/experimental/ 2 | -------------------------------------------------------------------------------- /docs/customization.md: -------------------------------------------------------------------------------- 1 | Cursorless is now monorepo 🙌. This document now lives at https://www.cursorless.org/docs/user/customization. 2 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Please file pull requests to the cursorless-talon subdirectory in the https://github.com/cursorless-dev/cursorless repo 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Create issue on cursorless-dev/cursorless 4 | url: https://github.com/cursorless-dev/cursorless/issues/new 5 | about: Please file issues on the main Cursorless repository 6 | -------------------------------------------------------------------------------- /src/cursorless_global.talon: -------------------------------------------------------------------------------- 1 | {user.cursorless_homophone} (reference | ref | cheatsheet | cheat sheet): 2 | user.private_cursorless_cheat_sheet_show_html() 3 | {user.cursorless_homophone} (instructions | docks | help) | help {user.cursorless_homophone}: 4 | user.private_cursorless_open_instructions() 5 | -------------------------------------------------------------------------------- /src/modifiers/matching_pair_symbol.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from talon import Module 4 | 5 | mod = Module() 6 | 7 | 8 | @mod.capture(rule="matching") 9 | def cursorless_matching_paired_delimiter(m) -> dict[str, Any]: 10 | return {"modifier": {"type": "matchingPairedDelimiter"}} 11 | -------------------------------------------------------------------------------- /src/modifiers/position.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from talon import Module 4 | 5 | mod = Module() 6 | 7 | mod.list("cursorless_position", desc='Positions such as "before", "after" etc') 8 | 9 | 10 | @mod.capture(rule="{user.cursorless_position}") 11 | def cursorless_position_modifier(m) -> dict[str, Any]: 12 | return {"type": "startOf" if m.cursorless_position == "start" else "endOf"} 13 | -------------------------------------------------------------------------------- /src/modifiers/interior.py: -------------------------------------------------------------------------------- 1 | from talon import Module 2 | 3 | mod = Module() 4 | 5 | mod.list( 6 | "cursorless_interior_modifier", 7 | desc="Cursorless interior modifier", 8 | ) 9 | 10 | 11 | @mod.capture(rule="{user.cursorless_interior_modifier}") 12 | def cursorless_interior_modifier(m) -> dict[str, str]: 13 | """Cursorless interior modifier""" 14 | return { 15 | "type": m.cursorless_interior_modifier, 16 | } 17 | -------------------------------------------------------------------------------- /src/marks/mark.py: -------------------------------------------------------------------------------- 1 | from talon import Module 2 | 3 | from .mark_types import Mark 4 | 5 | mod = Module() 6 | 7 | 8 | @mod.capture( 9 | rule=( 10 | " | " 11 | " | " 12 | " |" 13 | "" # row (ie absolute mod 100), up, down 14 | ) 15 | ) 16 | def cursorless_mark(m) -> Mark: 17 | return m[0] 18 | -------------------------------------------------------------------------------- /src/actions/replace.py: -------------------------------------------------------------------------------- 1 | from talon import actions 2 | 3 | from ..targets.target_types import CursorlessDestination 4 | 5 | 6 | def cursorless_replace_action( 7 | destination: CursorlessDestination, replace_with: list[str] 8 | ): 9 | """Execute Cursorless replace action. Replace targets with texts""" 10 | actions.user.private_cursorless_command_and_wait( 11 | { 12 | "name": "replace", 13 | "replaceWith": replace_with, 14 | "destination": destination, 15 | } 16 | ) 17 | -------------------------------------------------------------------------------- /src/actions/execute_command.py: -------------------------------------------------------------------------------- 1 | from talon import actions 2 | 3 | from ..targets.target_types import CursorlessTarget 4 | 5 | 6 | def cursorless_execute_command_action( 7 | command_id: str, target: CursorlessTarget, command_options: dict = {} 8 | ): 9 | """Execute Cursorless execute command action""" 10 | actions.user.private_cursorless_command_and_wait( 11 | { 12 | "name": "executeCommand", 13 | "commandId": command_id, 14 | "options": command_options, 15 | "target": target, 16 | } 17 | ) 18 | -------------------------------------------------------------------------------- /src/targets/range_type.py: -------------------------------------------------------------------------------- 1 | from talon import Module 2 | 3 | mod = Module() 4 | 5 | mod.list( 6 | "cursorless_range_type", 7 | desc="A range modifier that indicates the specific type of the range", 8 | ) 9 | 10 | # Maps from the id we use in the spoken form csv to the modifier type 11 | # expected by Cursorless extension 12 | range_type_map = { 13 | "verticalRange": "vertical", 14 | } 15 | 16 | 17 | @mod.capture(rule="{user.cursorless_range_type}") 18 | def cursorless_range_type(m) -> str: 19 | """Range type modifier""" 20 | return range_type_map[m.cursorless_range_type] 21 | -------------------------------------------------------------------------------- /src/terms.py: -------------------------------------------------------------------------------- 1 | """ 2 | Stores terms that are used in many different places 3 | """ 4 | 5 | from talon import Context, Module 6 | 7 | mod = Module() 8 | ctx = Context() 9 | 10 | mod.list( 11 | "cursorless_homophone", 12 | "Various alternative pronunciations of 'cursorless' to improve accuracy", 13 | ) 14 | 15 | # FIXME: Remove type ignore once Talon supports list types 16 | # See https://github.com/talonvoice/talon/issues/654 17 | ctx.lists["user.cursorless_homophone"] = [ # pyright: ignore [reportArgumentType] 18 | "cursorless", 19 | "cursor less", 20 | "cursor list", 21 | ] 22 | -------------------------------------------------------------------------------- /src/number_small.py: -------------------------------------------------------------------------------- 1 | """ 2 | DEPRECATED @ 2024-12-21 3 | This file allows us to use a custom `number_small` capture. See #1021 for more info. 4 | """ 5 | 6 | from talon import Module, app, registry 7 | 8 | mod = Module() 9 | 10 | mod.tag("cursorless_custom_number_small", "DEPRECATED!") 11 | 12 | 13 | def on_ready(): 14 | if "user.cursorless_custom_number_small" in registry.tags: 15 | print( 16 | "WARNING tag: 'user.cursorless_custom_number_small' is deprecated and should not be used anymore, as Cursorless now uses community number_small" 17 | ) 18 | 19 | 20 | app.register("ready", on_ready) 21 | -------------------------------------------------------------------------------- /src/actions/paste.py: -------------------------------------------------------------------------------- 1 | from talon import Module, actions 2 | 3 | from ..targets.target_types import CursorlessDestination 4 | 5 | mod = Module() 6 | 7 | mod.list("cursorless_paste_action", desc="Cursorless paste action") 8 | 9 | 10 | @mod.action_class 11 | class Actions: 12 | def private_cursorless_paste( 13 | destination: CursorlessDestination, # pyright: ignore [reportGeneralTypeIssues] 14 | ): 15 | """Execute Cursorless paste action""" 16 | actions.user.private_cursorless_command_and_wait( 17 | { 18 | "name": "pasteFromClipboard", 19 | "destination": destination, 20 | } 21 | ) 22 | -------------------------------------------------------------------------------- /src/marks/simple_mark.py: -------------------------------------------------------------------------------- 1 | from talon import Module 2 | 3 | from .mark_types import SimpleMark 4 | 5 | mod = Module() 6 | 7 | mod.list("cursorless_simple_mark", desc="Cursorless simple marks") 8 | 9 | # Maps from the id we use in the spoken form csv to the modifier type 10 | # expected by Cursorless extension 11 | simple_marks = { 12 | "currentSelection": "cursor", 13 | "previousTarget": "that", 14 | "previousSource": "source", 15 | "nothing": "nothing", 16 | } 17 | 18 | 19 | @mod.capture(rule="{user.cursorless_simple_mark}") 20 | def cursorless_simple_mark(m) -> SimpleMark: 21 | return { 22 | "type": simple_marks[m.cursorless_simple_mark], 23 | } 24 | -------------------------------------------------------------------------------- /src/cheatsheet/sections/special_marks.py: -------------------------------------------------------------------------------- 1 | from ..get_list import ListItemDescriptor, get_lists, get_raw_list, make_dict_readable 2 | 3 | 4 | def get_special_marks() -> list[ListItemDescriptor]: 5 | line_direction_marks = make_dict_readable( 6 | "mark", 7 | { 8 | f"{key} ": value 9 | for key, value in get_raw_list("line_direction").items() 10 | }, 11 | { 12 | "lineNumberRelativeUp": "Line number up from cursor", 13 | "lineNumberRelativeDown": "Line number down from cursor", 14 | }, 15 | ) 16 | 17 | return [ 18 | *get_lists(["simple_mark", "unknown_symbol"], "mark"), 19 | *line_direction_marks, 20 | ] 21 | -------------------------------------------------------------------------------- /src/actions/call.py: -------------------------------------------------------------------------------- 1 | from talon import Module, actions 2 | 3 | from ..targets.target_types import CursorlessTarget, ImplicitTarget 4 | 5 | mod = Module() 6 | mod.list("cursorless_call_action", desc="Cursorless call action") 7 | 8 | 9 | @mod.action_class 10 | class Actions: 11 | def private_cursorless_call( 12 | callee: CursorlessTarget, # pyright: ignore [reportGeneralTypeIssues] 13 | argument: CursorlessTarget = ImplicitTarget(), 14 | ): 15 | """Execute Cursorless call action""" 16 | actions.user.private_cursorless_command_and_wait( 17 | { 18 | "name": "callAsFunction", 19 | "callee": callee, 20 | "argument": argument, 21 | } 22 | ) 23 | -------------------------------------------------------------------------------- /src/targets/primitive_target.py: -------------------------------------------------------------------------------- 1 | from talon import Module 2 | 3 | from .target_types import PrimitiveTarget 4 | 5 | mod = Module() 6 | 7 | 8 | @mod.capture( 9 | rule=( 10 | "+ [] | " 11 | ) 12 | ) 13 | def cursorless_primitive_target(m) -> PrimitiveTarget: 14 | mark = getattr(m, "cursorless_mark", None) 15 | modifiers = getattr(m, "cursorless_modifier_list", None) 16 | 17 | # for grammar performance reasons, the literal modifier is exposed to Talon as a mark, 18 | # but is converted to a modifier in the engine. 19 | if mark is not None and mark["type"] == "literal": 20 | if modifiers is None: 21 | modifiers = [] 22 | modifiers.append(mark["modifier"]) 23 | mark = None 24 | 25 | return PrimitiveTarget(mark, modifiers) 26 | -------------------------------------------------------------------------------- /src/marks/mark_types.py: -------------------------------------------------------------------------------- 1 | from typing import Literal, TypedDict, Union 2 | 3 | 4 | class DecoratedSymbol(TypedDict): 5 | type: Literal["decoratedSymbol"] 6 | symbolColor: str 7 | character: str 8 | 9 | 10 | class LiteralMark(TypedDict): 11 | type: Literal["literal"] 12 | modifier: dict 13 | 14 | 15 | SimpleMark = dict[Literal["type"], str] 16 | 17 | LineNumberType = Literal["modulo100", "relative"] 18 | 19 | 20 | class LineNumberMark(TypedDict): 21 | type: Literal["lineNumber"] 22 | lineNumberType: LineNumberType 23 | lineNumber: int 24 | 25 | 26 | class LineNumberRange(TypedDict): 27 | type: Literal["range"] 28 | anchor: LineNumberMark 29 | active: LineNumberMark 30 | excludeAnchor: bool 31 | excludeActive: bool 32 | 33 | 34 | LineNumber = Union[LineNumberMark, LineNumberRange] 35 | 36 | Mark = Union[DecoratedSymbol, LiteralMark, SimpleMark, LineNumber] 37 | -------------------------------------------------------------------------------- /src/cheatsheet/sections/destinations.py: -------------------------------------------------------------------------------- 1 | from ..get_list import ListItemDescriptor, get_raw_list 2 | 3 | 4 | def get_destinations() -> list[ListItemDescriptor]: 5 | insertion_modes = { 6 | **{p: "to" for p in get_raw_list("insertion_mode_to")}, 7 | **get_raw_list("insertion_mode_before_after"), 8 | } 9 | 10 | descriptions = { 11 | "to": "Replace ", 12 | "before": "Insert before ", 13 | "after": "Insert after ", 14 | } 15 | 16 | return [ 17 | { 18 | "id": f"destination_{id}", 19 | "type": "destination", 20 | "variations": [ 21 | { 22 | "spokenForm": f"{spoken_form} ", 23 | "description": descriptions[id], 24 | } 25 | ], 26 | } 27 | for spoken_form, id in insertion_modes.items() 28 | ] 29 | -------------------------------------------------------------------------------- /src/targets/target.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from talon import Module 4 | 5 | from .target_types import ListTarget, PrimitiveTarget, RangeTarget 6 | 7 | mod = Module() 8 | 9 | 10 | mod.list( 11 | "cursorless_list_connective", 12 | desc="A list joiner", 13 | ) 14 | 15 | 16 | @mod.capture( 17 | rule=(" | ") 18 | ) 19 | def cursorless_primitive_or_range_target(m) -> Union[RangeTarget, PrimitiveTarget]: 20 | return m[0] 21 | 22 | 23 | @mod.capture( 24 | rule=( 25 | " " 26 | "({user.cursorless_list_connective} )*" 27 | ) 28 | ) 29 | def cursorless_target(m) -> Union[ListTarget, RangeTarget, PrimitiveTarget]: 30 | targets = m.cursorless_primitive_or_range_target_list 31 | 32 | if len(targets) == 1: 33 | return targets[0] 34 | 35 | return ListTarget(targets) 36 | -------------------------------------------------------------------------------- /src/actions/reformat.py: -------------------------------------------------------------------------------- 1 | from talon import Module, actions 2 | 3 | from ..targets.target_types import ( 4 | CursorlessExplicitTarget, 5 | PrimitiveDestination, 6 | ) 7 | from .get_text import cursorless_get_text_action 8 | from .replace import cursorless_replace_action 9 | 10 | mod = Module() 11 | 12 | mod.list("cursorless_reformat_action", desc="Cursorless reformat action") 13 | 14 | 15 | @mod.action_class 16 | class Actions: 17 | def cursorless_reformat( 18 | target: CursorlessExplicitTarget, # pyright: ignore [reportGeneralTypeIssues] 19 | formatters: str, 20 | ): 21 | """Execute Cursorless reformat action. Reformat target with formatter""" 22 | texts = cursorless_get_text_action(target, show_decorations=False) 23 | updated_texts = [actions.user.reformat_text(text, formatters) for text in texts] 24 | destination = PrimitiveDestination("to", target) 25 | cursorless_replace_action(destination, updated_texts) 26 | -------------------------------------------------------------------------------- /src/modifiers/head_tail.py: -------------------------------------------------------------------------------- 1 | from talon import Module 2 | 3 | mod = Module() 4 | 5 | mod.list( 6 | "cursorless_head_tail_modifier", 7 | desc="Cursorless head and tail modifiers", 8 | ) 9 | 10 | 11 | @mod.capture( 12 | rule=( 13 | "{user.cursorless_head_tail_modifier} " 14 | "[] " 15 | "[]" 16 | ) 17 | ) 18 | def cursorless_head_tail_modifier(m) -> dict[str, str]: 19 | """Cursorless head and tail modifier""" 20 | modifiers = [] 21 | 22 | try: 23 | modifiers.append(m.cursorless_interior_modifier) 24 | except AttributeError: 25 | pass 26 | 27 | try: 28 | modifiers.append(m.cursorless_head_tail_swallowed_modifier) 29 | except AttributeError: 30 | pass 31 | 32 | result = { 33 | "type": m.cursorless_head_tail_modifier, 34 | } 35 | 36 | if modifiers: 37 | result["modifiers"] = modifiers 38 | 39 | return result 40 | -------------------------------------------------------------------------------- /src/scope_visualizer.py: -------------------------------------------------------------------------------- 1 | from talon import Module, actions 2 | 3 | mod = Module() 4 | mod.list("cursorless_show_scope_visualizer", desc="Show scope visualizer") 5 | mod.list("cursorless_hide_scope_visualizer", desc="Hide scope visualizer") 6 | mod.list( 7 | "cursorless_visualization_type", 8 | desc='Cursorless visualization type, e.g. "removal" or "iteration"', 9 | ) 10 | 11 | 12 | @mod.action_class 13 | class Actions: 14 | def private_cursorless_show_scope_visualizer( 15 | scope_type: dict, # pyright: ignore [reportGeneralTypeIssues] 16 | visualization_type: str, 17 | ): 18 | """Shows scope visualizer""" 19 | actions.user.private_cursorless_run_rpc_command_no_wait( 20 | "cursorless.showScopeVisualizer", scope_type, visualization_type 21 | ) 22 | 23 | def private_cursorless_hide_scope_visualizer(): 24 | """Hides scope visualizer""" 25 | actions.user.private_cursorless_run_rpc_command_no_wait( 26 | "cursorless.hideScopeVisualizer" 27 | ) 28 | -------------------------------------------------------------------------------- /src/apps/cursorless_vscode.py: -------------------------------------------------------------------------------- 1 | from talon import Context, actions 2 | 3 | ctx = Context() 4 | 5 | ctx.matches = r""" 6 | app: vscode 7 | # Disable Cursorless when VS Code is displaying a native OS dialog during which the command server 8 | # hotkey will not work. 9 | not win.title: /^(Open Folder|Open File|Save As|Open Workspace from File|Add Folder to Workspace|Save Workspace)$/i 10 | """ 11 | 12 | ctx.tags = ["user.cursorless"] 13 | 14 | 15 | @ctx.action_class("user") 16 | class Actions: 17 | def private_cursorless_show_settings_in_ide(): 18 | """Show Cursorless-specific settings in ide""" 19 | actions.user.private_cursorless_run_rpc_command_no_wait( 20 | "workbench.action.openSettings", "@ext:pokey.cursorless " 21 | ) 22 | actions.sleep("250ms") 23 | actions.key("right") 24 | 25 | def private_cursorless_show_sidebar(): 26 | """Show Cursorless sidebar""" 27 | actions.user.private_cursorless_run_rpc_command_and_wait( 28 | "workbench.view.extension.cursorless" 29 | ) 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Brandon Virgil Rule 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/check_community_repo.py: -------------------------------------------------------------------------------- 1 | from talon import app, registry 2 | 3 | required_captures = [ 4 | "number_small", 5 | "user.any_alphanumeric_key", 6 | "user.formatters", 7 | "user.ordinals_small", 8 | ] 9 | 10 | required_actions = [ 11 | "code.language", 12 | "user.homophones_get", 13 | "user.insert_snippet_by_name", 14 | "user.reformat_text", 15 | ] 16 | 17 | 18 | def on_ready(): 19 | missing_captures = [ 20 | capture for capture in required_captures if capture not in registry.captures 21 | ] 22 | missing_actions = [ 23 | action for action in required_actions if action not in registry.actions 24 | ] 25 | errors = [] 26 | if missing_captures: 27 | errors.append(f"Missing captures: {', '.join(missing_captures)}") 28 | if missing_actions: 29 | errors.append(f"Missing actions: {', '.join(missing_actions)}") 30 | if errors: 31 | print("Cursorless community requirements:") 32 | print("\n".join(errors)) 33 | app.notify( 34 | "Cursorless: Please install the community repository", 35 | body="https://github.com/talonhub/community", 36 | ) 37 | 38 | 39 | app.register("ready", on_ready) 40 | -------------------------------------------------------------------------------- /src/public_api.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional 2 | 3 | from talon import Module, actions 4 | 5 | from .targets.target_types import ( 6 | CursorlessDestination, 7 | InsertionMode, 8 | ListTarget, 9 | PrimitiveDestination, 10 | PrimitiveTarget, 11 | RangeTarget, 12 | ) 13 | 14 | mod = Module() 15 | 16 | 17 | @mod.action_class 18 | class Actions: 19 | def cursorless_create_destination( 20 | target: ListTarget | RangeTarget | PrimitiveTarget, # pyright: ignore [reportGeneralTypeIssues] 21 | insertion_mode: InsertionMode = "to", 22 | ) -> CursorlessDestination: 23 | """Cursorless: Create destination from target""" 24 | return PrimitiveDestination(insertion_mode, target) 25 | 26 | 27 | @mod.action_class 28 | class CommandActions: 29 | def cursorless_x_custom_command( 30 | content: str, # pyright: ignore [reportGeneralTypeIssues] 31 | arg1: Optional[Any] = None, 32 | arg2: Optional[Any] = None, 33 | arg3: Optional[Any] = None, 34 | ): 35 | """Cursorless: Run custom parsed command""" 36 | actions.user.private_cursorless_command_and_wait( 37 | { 38 | "name": "parsed", 39 | "content": content, 40 | "arguments": [arg for arg in [arg1, arg2, arg3] if arg is not None], 41 | } 42 | ) 43 | -------------------------------------------------------------------------------- /src/targets/destination.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from talon import Module 4 | 5 | from .target_types import ListDestination, PrimitiveDestination 6 | 7 | mod = Module() 8 | 9 | mod.list( 10 | "cursorless_insertion_mode_before_after", 11 | desc="Cursorless insertion mode before/after", 12 | ) 13 | mod.list("cursorless_insertion_mode_to", desc="Cursorless insertion mode to") 14 | 15 | 16 | @mod.capture( 17 | rule="{user.cursorless_insertion_mode_before_after} | {user.cursorless_insertion_mode_to}", 18 | ) 19 | def cursorless_insertion_mode(m) -> str: 20 | try: 21 | return m.cursorless_insertion_mode_before_after 22 | except AttributeError: 23 | return "to" 24 | 25 | 26 | @mod.capture( 27 | rule=( 28 | " " 29 | "({user.cursorless_list_connective} )*" 30 | ) 31 | ) 32 | def cursorless_destination(m) -> Union[ListDestination, PrimitiveDestination]: 33 | destinations = [ 34 | PrimitiveDestination(insertion_mode, target) 35 | for insertion_mode, target in zip( 36 | m.cursorless_insertion_mode_list, m.cursorless_target_list 37 | ) 38 | ] 39 | 40 | if len(destinations) == 1: 41 | return destinations[0] 42 | 43 | return ListDestination(destinations) 44 | -------------------------------------------------------------------------------- /src/actions/swap.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from talon import Module, actions 4 | 5 | from ..targets.target_types import CursorlessTarget, ImplicitTarget 6 | 7 | 8 | @dataclass 9 | class SwapTargets: 10 | target1: CursorlessTarget 11 | target2: CursorlessTarget 12 | 13 | 14 | mod = Module() 15 | 16 | mod.list("cursorless_swap_action", desc="Cursorless swap action") 17 | mod.list( 18 | "cursorless_swap_connective", 19 | desc="The connective used to separate swap targets", 20 | ) 21 | 22 | 23 | @mod.capture( 24 | rule=( 25 | "[] {user.cursorless_swap_connective} " 26 | ) 27 | ) 28 | def cursorless_swap_targets(m) -> SwapTargets: 29 | targets = m.cursorless_target_list 30 | 31 | return SwapTargets( 32 | ImplicitTarget() if len(targets) == 1 else targets[0], 33 | targets[-1], 34 | ) 35 | 36 | 37 | @mod.action_class 38 | class Actions: 39 | def private_cursorless_swap( 40 | targets: SwapTargets, # pyright: ignore [reportGeneralTypeIssues] 41 | ): 42 | """Execute Cursorless swap action""" 43 | actions.user.private_cursorless_command_and_wait( 44 | { 45 | "name": "swapTargets", 46 | "target1": targets.target1, 47 | "target2": targets.target2, 48 | } 49 | ) 50 | -------------------------------------------------------------------------------- /src/actions/bring_move.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from talon import Module, actions 4 | 5 | from ..targets.target_types import ( 6 | CursorlessDestination, 7 | CursorlessTarget, 8 | ImplicitDestination, 9 | ) 10 | 11 | 12 | @dataclass 13 | class BringMoveTargets: 14 | source: CursorlessTarget 15 | destination: CursorlessDestination 16 | 17 | 18 | mod = Module() 19 | 20 | 21 | mod.list("cursorless_bring_move_action", desc="Cursorless bring or move actions") 22 | 23 | 24 | @mod.capture(rule=" []") 25 | def cursorless_bring_move_targets(m) -> BringMoveTargets: 26 | source = m.cursorless_target 27 | 28 | try: 29 | destination = m.cursorless_destination 30 | except AttributeError: 31 | destination = ImplicitDestination() 32 | 33 | return BringMoveTargets(source, destination) 34 | 35 | 36 | @mod.action_class 37 | class Actions: 38 | def private_cursorless_bring_move(action_name: str, targets: BringMoveTargets): # pyright: ignore [reportGeneralTypeIssues] 39 | """Execute Cursorless move/bring action""" 40 | actions.user.private_cursorless_command_and_wait( 41 | { 42 | "name": action_name, 43 | "source": targets.source, 44 | "destination": targets.destination, 45 | } 46 | ) 47 | -------------------------------------------------------------------------------- /src/actions/homophones.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from talon import actions, app 4 | 5 | from ..targets.target_types import ( 6 | CursorlessExplicitTarget, 7 | PrimitiveDestination, 8 | ) 9 | from .get_text import cursorless_get_text_action 10 | from .replace import cursorless_replace_action 11 | 12 | 13 | def cursorless_homophones_action(target: CursorlessExplicitTarget): 14 | """Replaced target with next homophone""" 15 | texts = cursorless_get_text_action(target, show_decorations=False) 16 | try: 17 | updated_texts = list(map(get_next_homophone, texts)) 18 | except LookupError as e: 19 | app.notify(str(e)) 20 | return 21 | destination = PrimitiveDestination("to", target) 22 | cursorless_replace_action(destination, updated_texts) 23 | 24 | 25 | def get_next_homophone(word: str) -> str: 26 | homophones: Optional[list[str]] = actions.user.homophones_get(word) 27 | if not homophones: 28 | raise LookupError(f"Found no homophones for '{word}'") 29 | index = (homophones.index(word.lower()) + 1) % len(homophones) 30 | homophone = homophones[index] 31 | return format_homophone(word, homophone) 32 | 33 | 34 | def format_homophone(word: str, homophone: str) -> str: 35 | if word.isupper(): 36 | return homophone.upper() 37 | if word == word.capitalize(): 38 | return homophone.capitalize() 39 | return homophone 40 | -------------------------------------------------------------------------------- /src/cheatsheet/sections/scopes.py: -------------------------------------------------------------------------------- 1 | from ..get_list import ListItemDescriptor, get_lists, get_spoken_form_from_list 2 | 3 | 4 | def get_scopes() -> list[ListItemDescriptor]: 5 | glyph_spoken_form = get_spoken_form_from_list("glyph_scope_type", "glyph") 6 | 7 | items = get_lists( 8 | ["scope_type"], 9 | "scopeType", 10 | { 11 | "argumentOrParameter": "Argument", 12 | "boundedNonWhitespaceSequence": "Non-whitespace sequence bounded by surrounding pair delimeters", 13 | "boundedParagraph": "Paragraph bounded by surrounding pair delimeters", 14 | }, 15 | ) 16 | 17 | if glyph_spoken_form: 18 | items.append( 19 | { 20 | "id": "glyph", 21 | "type": "scopeType", 22 | "variations": [ 23 | { 24 | "spokenForm": f"{glyph_spoken_form} ", 25 | "description": "Instance of single character ", 26 | }, 27 | ], 28 | } 29 | ) 30 | 31 | items.append( 32 | { 33 | "id": "pair", 34 | "type": "scopeType", 35 | "variations": [ 36 | { 37 | "spokenForm": "", 38 | "description": "Paired delimiters", 39 | }, 40 | ], 41 | }, 42 | ) 43 | 44 | return items 45 | -------------------------------------------------------------------------------- /src/cursorless_command_server.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from talon import Module, actions 4 | 5 | mod = Module() 6 | 7 | 8 | @mod.action_class 9 | class Actions: 10 | def private_cursorless_run_rpc_command_and_wait( 11 | command_id: str, # pyright: ignore [reportGeneralTypeIssues] 12 | arg1: Any = None, 13 | arg2: Any = None, 14 | ): 15 | """Execute command via rpc and wait for command to finish.""" 16 | try: 17 | actions.user.run_rpc_command_and_wait(command_id, arg1, arg2) 18 | except KeyError: 19 | actions.user.vscode_with_plugin_and_wait(command_id, arg1, arg2) 20 | 21 | def private_cursorless_run_rpc_command_no_wait( 22 | command_id: str, # pyright: ignore [reportGeneralTypeIssues] 23 | arg1: Any = None, 24 | arg2: Any = None, 25 | ): 26 | """Execute command via rpc and DON'T wait.""" 27 | try: 28 | actions.user.run_rpc_command(command_id, arg1, arg2) 29 | except KeyError: 30 | actions.user.vscode_with_plugin(command_id, arg1, arg2) 31 | 32 | def private_cursorless_run_rpc_command_get( 33 | command_id: str, # pyright: ignore [reportGeneralTypeIssues] 34 | arg1: Any = None, 35 | arg2: Any = None, 36 | ) -> Any: 37 | """Execute command via rpc and return command output.""" 38 | try: 39 | return actions.user.run_rpc_command_get(command_id, arg1, arg2) 40 | except KeyError: 41 | return actions.user.vscode_get(command_id, arg1, arg2) 42 | -------------------------------------------------------------------------------- /src/spoken_forms_output.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | from typing import TypedDict 4 | 5 | from talon import app 6 | 7 | SPOKEN_FORMS_OUTPUT_PATH = Path.home() / ".cursorless" / "state.json" 8 | STATE_JSON_VERSION_NUMBER = 0 9 | 10 | 11 | class SpokenFormOutputEntry(TypedDict): 12 | type: str 13 | id: str 14 | spokenForms: list[str] 15 | 16 | 17 | class SpokenFormsOutput: 18 | """ 19 | Writes spoken forms to a json file for use by the Cursorless vscode extension 20 | """ 21 | 22 | def init(self): 23 | try: 24 | SPOKEN_FORMS_OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True) 25 | except Exception: 26 | error_message = ( 27 | f"Error creating spoken form dir {SPOKEN_FORMS_OUTPUT_PATH.parent}" 28 | ) 29 | print(error_message) 30 | app.notify(error_message) 31 | 32 | def write(self, spoken_forms: list[SpokenFormOutputEntry]): 33 | with open(SPOKEN_FORMS_OUTPUT_PATH, "w", encoding="UTF-8") as out: 34 | try: 35 | out.write( 36 | json.dumps( 37 | { 38 | "version": STATE_JSON_VERSION_NUMBER, 39 | "spokenForms": spoken_forms, 40 | } 41 | ) 42 | ) 43 | except Exception: 44 | error_message = ( 45 | f"Error writing spoken form json {SPOKEN_FORMS_OUTPUT_PATH}" 46 | ) 47 | print(error_message) 48 | app.notify(error_message) 49 | -------------------------------------------------------------------------------- /src/targets/target_types.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Any, Literal, Optional, Union 3 | 4 | from ..marks.mark_types import Mark 5 | 6 | RangeTargetType = Literal["vertical"] 7 | 8 | 9 | @dataclass 10 | class PrimitiveTarget: 11 | type = "primitive" 12 | mark: Optional[Mark] 13 | modifiers: Optional[list[dict[str, Any]]] 14 | 15 | 16 | @dataclass 17 | class ImplicitTarget: 18 | type = "implicit" 19 | 20 | 21 | @dataclass 22 | class RangeTarget: 23 | type = "range" 24 | anchor: Union[PrimitiveTarget, ImplicitTarget] 25 | active: PrimitiveTarget 26 | excludeAnchor: bool 27 | excludeActive: bool 28 | rangeType: Optional[RangeTargetType] 29 | 30 | 31 | @dataclass 32 | class ListTarget: 33 | type = "list" 34 | elements: list[Union[PrimitiveTarget, RangeTarget]] 35 | 36 | 37 | CursorlessTarget = Union[ 38 | ListTarget, 39 | RangeTarget, 40 | PrimitiveTarget, 41 | ImplicitTarget, 42 | ] 43 | CursorlessExplicitTarget = Union[ 44 | ListTarget, 45 | RangeTarget, 46 | PrimitiveTarget, 47 | ] 48 | 49 | InsertionMode = Literal["to", "before", "after"] 50 | 51 | 52 | @dataclass 53 | class PrimitiveDestination: 54 | type = "primitive" 55 | insertionMode: InsertionMode 56 | target: Union[ListTarget, RangeTarget, PrimitiveTarget] 57 | 58 | 59 | @dataclass 60 | class ImplicitDestination: 61 | type = "implicit" 62 | 63 | 64 | @dataclass 65 | class ListDestination: 66 | type = "list" 67 | destinations: list[PrimitiveDestination] 68 | 69 | 70 | CursorlessDestination = Union[ 71 | ListDestination, 72 | PrimitiveDestination, 73 | ImplicitDestination, 74 | ] 75 | -------------------------------------------------------------------------------- /src/cheatsheet/sections/get_scope_visualizer.py: -------------------------------------------------------------------------------- 1 | from ..get_list import ListItemDescriptor, get_list, get_raw_list, make_readable 2 | 3 | 4 | def get_scope_visualizer() -> list[ListItemDescriptor]: 5 | show_scope_visualizers = list(get_raw_list("show_scope_visualizer").keys()) 6 | show_scope_visualizer = ( 7 | show_scope_visualizers[0] if show_scope_visualizers else None 8 | ) 9 | visualization_types = get_raw_list("visualization_type") 10 | 11 | items = get_list("hide_scope_visualizer", "command") 12 | 13 | if show_scope_visualizer: 14 | items.append( 15 | { 16 | "id": "show_scope_visualizer", 17 | "type": "command", 18 | "variations": [ 19 | { 20 | "spokenForm": f"{show_scope_visualizer} ", 21 | "description": "Visualize ", 22 | }, 23 | *[ 24 | { 25 | "spokenForm": f"{show_scope_visualizer} {spoken_form}", 26 | "description": f"Visualize {make_readable(id).lower()} range", 27 | } 28 | for spoken_form, id in visualization_types.items() 29 | ], 30 | ], 31 | } 32 | ) 33 | 34 | items.append( 35 | { 36 | "id": "show_scope_sidebar", 37 | "type": "command", 38 | "variations": [ 39 | { 40 | "spokenForm": "bar cursorless", 41 | "description": "Show cursorless sidebar", 42 | }, 43 | ], 44 | } 45 | ) 46 | 47 | return items 48 | -------------------------------------------------------------------------------- /src/modifiers/simple_scope_modifier.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from talon import Module, settings 4 | 5 | mod = Module() 6 | 7 | mod.list( 8 | "cursorless_every_scope_modifier", 9 | desc="Cursorless every scope modifiers", 10 | ) 11 | mod.list( 12 | "cursorless_ancestor_scope_modifier", 13 | desc="Cursorless ancestor scope modifiers", 14 | ) 15 | 16 | # This is a private setting and should not be used by non Cursorless developers 17 | mod.setting( 18 | "private_cursorless_use_preferred_scope", 19 | type=bool, 20 | default=False, 21 | desc="Use preferred scope instead of containing scope for all scopes by default (EXPERIMENTAL)", 22 | ) 23 | 24 | 25 | @mod.capture( 26 | rule=( 27 | "[{user.cursorless_every_scope_modifier} | {user.cursorless_ancestor_scope_modifier}+] " 28 | "" 29 | ), 30 | ) 31 | def cursorless_simple_scope_modifier(m) -> dict[str, Any]: 32 | """Containing scope, every scope, etc""" 33 | if hasattr(m, "cursorless_every_scope_modifier"): 34 | return { 35 | "type": "everyScope", 36 | "scopeType": m.cursorless_scope_type, 37 | } 38 | 39 | if hasattr(m, "cursorless_ancestor_scope_modifier"): 40 | return { 41 | "type": "containingScope", 42 | "scopeType": m.cursorless_scope_type, 43 | "ancestorIndex": len(m.cursorless_ancestor_scope_modifier_list), 44 | } 45 | 46 | if settings.get("user.private_cursorless_use_preferred_scope"): 47 | return { 48 | "type": "preferredScope", 49 | "scopeType": m.cursorless_scope_type, 50 | } 51 | 52 | return { 53 | "type": "containingScope", 54 | "scopeType": m.cursorless_scope_type, 55 | } 56 | -------------------------------------------------------------------------------- /src/snippets/snippets_deprecated.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from talon import Module, app, registry 4 | 5 | mod = Module() 6 | 7 | # DEPRECATED @ 2025-02-01 8 | 9 | tags = [ 10 | "cursorless_experimental_snippets", 11 | "cursorless_use_community_snippets", 12 | ] 13 | 14 | lists = [ 15 | "cursorless_insertion_snippet_no_phrase", 16 | "cursorless_insertion_snippet_single_phrase", 17 | "cursorless_wrapper_snippet", 18 | "cursorless_phrase_terminator", 19 | ] 20 | 21 | for tag in tags: 22 | mod.tag(tag, desc="DEPRECATED") 23 | 24 | for list in lists: 25 | mod.list(list, desc="DEPRECATED") 26 | 27 | 28 | @mod.action_class 29 | class Actions: 30 | def cursorless_insert_snippet_by_name(name: str): # pyright: ignore [reportGeneralTypeIssues] 31 | """[DEPRECATED] Cursorless: Insert named snippet """ 32 | raise NotImplementedError( 33 | "Cursorless snippets are deprecated. Please use community snippets. Update to latest cursorless-talon and say 'cursorless migrate snippets'." 34 | ) 35 | 36 | def cursorless_wrap_with_snippet_by_name( 37 | name: str, # pyright: ignore [reportGeneralTypeIssues] 38 | variable_name: str, 39 | target: Any, 40 | ): 41 | """[DEPRECATED] Cursorless: Wrap target with a named snippet """ 42 | raise NotImplementedError( 43 | "Cursorless snippets are deprecated. Please use community snippets. Update to latest cursorless-talon and say 'cursorless migrate snippets'." 44 | ) 45 | 46 | 47 | def on_ready(): 48 | for tag in tags: 49 | name = f"user.{tag}" 50 | if name in registry.tags: 51 | print(f"WARNING tag: '{name}' is deprecated and should not be used anymore") 52 | 53 | 54 | app.register("ready", on_ready) 55 | -------------------------------------------------------------------------------- /src/marks/literal_mark.py: -------------------------------------------------------------------------------- 1 | from talon import Context, Module 2 | 3 | from .mark_types import LiteralMark 4 | 5 | mod = Module() 6 | 7 | mod.list("private_cursorless_literal_mark", desc="Cursorless literal mark") 8 | 9 | # This is a private tag and should not be used by non Cursorless developers 10 | mod.tag( 11 | "private_cursorless_literal_mark_no_prefix", 12 | desc="Tag for enabling literal mark without prefix", 13 | ) 14 | 15 | ctx_no_prefix = Context() 16 | ctx_no_prefix.matches = r""" 17 | tag: user.private_cursorless_literal_mark_no_prefix 18 | """ 19 | 20 | 21 | # NB: is used over for DFA performance reasons 22 | # (we intend to replace this with a dynamic list of document contents eventually) 23 | @mod.capture(rule="{user.private_cursorless_literal_mark} ") 24 | def cursorless_literal_mark(m) -> LiteralMark: 25 | return construct_mark(str(m.phrase)) 26 | 27 | 28 | @ctx_no_prefix.capture("user.cursorless_literal_mark", rule="") 29 | def cursorless_literal_mark_no_prefix(m) -> LiteralMark: 30 | return construct_mark(str(m.phrase)) 31 | 32 | 33 | def construct_mark(text: str) -> LiteralMark: 34 | return { 35 | "type": "literal", 36 | "modifier": { 37 | "type": "preferredScope", 38 | "scopeType": { 39 | "type": "customRegex", 40 | "regex": construct_fuzzy_regex(text), 41 | "flags": "gui", 42 | }, 43 | }, 44 | } 45 | 46 | 47 | def construct_fuzzy_regex(text: str) -> str: 48 | parts = text.split(" ") 49 | # Between each word there can be any number of non-alpha symbols (including escape characters: \t\r\n). No separator at all is also valid -- for example, when searching for a camelCase identifier. 50 | return r"([^a-zA-Z]|\\[trn])*".join(parts) 51 | -------------------------------------------------------------------------------- /src/actions/get_text.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from talon import Module, actions 4 | 5 | from ..targets.target_types import CursorlessTarget 6 | 7 | mod = Module() 8 | 9 | 10 | @mod.action_class 11 | class Actions: 12 | def cursorless_get_text( 13 | target: CursorlessTarget, # pyright: ignore [reportGeneralTypeIssues] 14 | hide_decorations: bool = False, 15 | ) -> str: 16 | """Get target text. If hide_decorations is True, don't show decorations""" 17 | return cursorless_get_text_action( 18 | target, 19 | show_decorations=not hide_decorations, 20 | ensure_single_target=True, 21 | )[0] 22 | 23 | def cursorless_get_text_list( 24 | target: CursorlessTarget, # pyright: ignore [reportGeneralTypeIssues] 25 | hide_decorations: bool = False, 26 | ) -> list[str]: 27 | """Get texts for multiple targets. If hide_decorations is True, don't show decorations""" 28 | return cursorless_get_text_action( 29 | target, 30 | show_decorations=not hide_decorations, 31 | ensure_single_target=False, 32 | ) 33 | 34 | 35 | def cursorless_get_text_action( 36 | target: CursorlessTarget, 37 | *, 38 | show_decorations: Optional[bool] = None, 39 | ensure_single_target: Optional[bool] = None, 40 | ) -> list[str]: 41 | """Get target texts""" 42 | options: dict[str, bool] = {} 43 | 44 | if show_decorations is not None: 45 | options["showDecorations"] = show_decorations 46 | 47 | if ensure_single_target is not None: 48 | options["ensureSingleTarget"] = ensure_single_target 49 | 50 | return actions.user.private_cursorless_command_get( 51 | { 52 | "name": "getText", 53 | "options": options, 54 | "target": target, 55 | } 56 | ) 57 | -------------------------------------------------------------------------------- /src/private_api/extract_decorated_marks.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from ..actions.bring_move import BringMoveTargets 4 | from ..actions.swap import SwapTargets 5 | from ..targets.target_types import ( 6 | ImplicitDestination, 7 | ImplicitTarget, 8 | ListDestination, 9 | ListTarget, 10 | PrimitiveDestination, 11 | PrimitiveTarget, 12 | RangeTarget, 13 | ) 14 | 15 | 16 | def extract_decorated_marks(capture: Any) -> list[Any]: 17 | match capture: 18 | case PrimitiveTarget(mark=mark): 19 | if mark is None or mark["type"] != "decoratedSymbol": 20 | return [] 21 | return [mark] 22 | case ImplicitTarget(): 23 | return [] 24 | case RangeTarget(anchor=anchor, active=active): 25 | return extract_decorated_marks(anchor) + extract_decorated_marks(active) 26 | case ListTarget(elements=elements): 27 | return [ 28 | mark for target in elements for mark in extract_decorated_marks(target) 29 | ] 30 | case PrimitiveDestination(target=target): 31 | return extract_decorated_marks(target) 32 | case ImplicitDestination(): 33 | return [] 34 | case ListDestination(destinations=destinations): 35 | return [ 36 | mark 37 | for destination in destinations 38 | for mark in extract_decorated_marks(destination) 39 | ] 40 | case BringMoveTargets(source=source, destination=destination): 41 | return extract_decorated_marks(source) + extract_decorated_marks( 42 | destination 43 | ) 44 | case SwapTargets(target1=target1, target2=target2): 45 | return extract_decorated_marks(target1) + extract_decorated_marks(target2) 46 | case _: 47 | raise TypeError(f"Unknown capture type: {type(capture)}") 48 | -------------------------------------------------------------------------------- /src/modifiers/modifiers.py: -------------------------------------------------------------------------------- 1 | from talon import Module 2 | 3 | mod = Module() 4 | 5 | mod.list( 6 | "cursorless_simple_modifier", 7 | desc="Simple cursorless modifiers that only need to specify their type", 8 | ) 9 | 10 | 11 | @mod.capture(rule="{user.cursorless_simple_modifier}") 12 | def cursorless_simple_modifier(m) -> dict[str, str]: 13 | """Simple cursorless modifiers that only need to specify their type""" 14 | return { 15 | "type": m.cursorless_simple_modifier, 16 | } 17 | 18 | 19 | # These are the modifiers that will be "swallowed" by the head/tail modifier. 20 | # For example, saying "head funk" will result in a "head" modifier that will 21 | # select past the start of the function. 22 | # Note that we don't include "inside" here, because that requires slightly 23 | # special treatment to ensure that "head inside round" swallows "inside round" 24 | # rather than just "inside". 25 | head_tail_swallowed_modifiers = [ 26 | "", # bounds, just, leading, trailing 27 | "", # funk, state, class, every funk 28 | "", # first past second word 29 | "", # next funk, 3 funks 30 | ] 31 | 32 | modifiers = [ 33 | "", # inside 34 | "", # head, tail 35 | "", # start of, end of 36 | *head_tail_swallowed_modifiers, 37 | ] 38 | 39 | 40 | @mod.capture(rule="|".join(modifiers)) 41 | def cursorless_modifier(m) -> str: 42 | """Cursorless modifier""" 43 | return m[0] 44 | 45 | 46 | @mod.capture(rule="|".join(head_tail_swallowed_modifiers)) 47 | def cursorless_head_tail_swallowed_modifier(m) -> str: 48 | """Cursorless modifier that is swallowed by the head/tail modifier, excluding interior, which requires special treatment""" 49 | return m[0] 50 | -------------------------------------------------------------------------------- /src/paired_delimiter.py: -------------------------------------------------------------------------------- 1 | from talon import Module 2 | 3 | mod = Module() 4 | 5 | mod.list( 6 | "cursorless_wrapper_only_paired_delimiter", 7 | desc="A paired delimiter that can only be used as a wrapper", 8 | ) 9 | mod.list( 10 | "cursorless_selectable_only_paired_delimiter", 11 | desc="A paired delimiter that can only be used as a scope type", 12 | ) 13 | mod.list( 14 | "cursorless_wrapper_selectable_paired_delimiter", 15 | desc="A paired delimiter that can be used as a scope type and as a wrapper", 16 | ) 17 | 18 | mod.list( 19 | "cursorless_selectable_only_paired_delimiter_plural", 20 | desc="Plural form of a paired delimiter that can only be used as a scope type", 21 | ) 22 | mod.list( 23 | "cursorless_wrapper_selectable_paired_delimiter_plural", 24 | desc="Plural form of a paired delimiter that can be used as a scope type and as a wrapper", 25 | ) 26 | 27 | # Maps from the id we use in the spoken form csv to the delimiter strings 28 | paired_delimiters = { 29 | "curlyBrackets": ["{", "}"], 30 | "angleBrackets": ["<", ">"], 31 | "escapedDoubleQuotes": ['\\"', '\\"'], 32 | "escapedSingleQuotes": ["\\'", "\\'"], 33 | "escapedParentheses": ["\\(", "\\)"], 34 | "escapedSquareBrackets": ["\\[", "\\]"], 35 | "doubleQuotes": ['"', '"'], 36 | "parentheses": ["(", ")"], 37 | "backtickQuotes": ["`", "`"], 38 | "whitespace": [" ", " "], 39 | "squareBrackets": ["[", "]"], 40 | "singleQuotes": ["'", "'"], 41 | "any": ["", ""], 42 | } 43 | 44 | 45 | @mod.capture( 46 | rule=( 47 | "{user.cursorless_wrapper_only_paired_delimiter} |" 48 | "{user.cursorless_wrapper_selectable_paired_delimiter}" 49 | ) 50 | ) 51 | def cursorless_wrapper_paired_delimiter(m) -> list[str]: 52 | try: 53 | id = m.cursorless_wrapper_only_paired_delimiter 54 | except AttributeError: 55 | id = m.cursorless_wrapper_selectable_paired_delimiter 56 | return paired_delimiters[id] 57 | -------------------------------------------------------------------------------- /src/snippets/snippets_get.py: -------------------------------------------------------------------------------- 1 | from talon import actions 2 | 3 | from .snippet_types import ( 4 | CustomInsertionSnippet, 5 | CustomWrapperSnippet, 6 | ListInsertionSnippet, 7 | ListWrapperSnippet, 8 | ) 9 | 10 | 11 | def get_insertion_snippet( 12 | name: str, substitutions: dict[str, str] | None = None 13 | ) -> CustomInsertionSnippet: 14 | return CustomInsertionSnippet.create( 15 | actions.user.get_insertion_snippet(name), 16 | substitutions, 17 | ) 18 | 19 | 20 | def get_list_insertion_snippet( 21 | name: str, 22 | substitutions: dict[str, str] | None = None, 23 | ) -> ListInsertionSnippet | CustomInsertionSnippet: 24 | try: 25 | snippets = actions.user.get_insertion_snippets(name) 26 | except Exception as e: 27 | # Raised if the user has an older version of community 28 | if isinstance(e, KeyError): 29 | return get_insertion_snippet(name, substitutions) 30 | raise 31 | 32 | return ListInsertionSnippet( 33 | get_fallback_language(), 34 | substitutions, 35 | [CustomInsertionSnippet.create(s) for s in snippets], 36 | ) 37 | 38 | 39 | def get_wrapper_snippet(name: str) -> CustomWrapperSnippet: 40 | return CustomWrapperSnippet.create(actions.user.get_wrapper_snippet(name)) 41 | 42 | 43 | def get_list_wrapper_snippet(name: str) -> ListWrapperSnippet | CustomWrapperSnippet: 44 | try: 45 | snippets = actions.user.get_wrapper_snippets(name) 46 | except Exception as e: 47 | # Raised if the user has an older version of community 48 | if isinstance(e, KeyError): 49 | return get_wrapper_snippet(name) 50 | raise 51 | 52 | return ListWrapperSnippet( 53 | get_fallback_language(), 54 | [CustomWrapperSnippet.create(s) for s in snippets], 55 | ) 56 | 57 | 58 | def get_fallback_language(): 59 | language = actions.code.language() 60 | if language and isinstance(language, str): 61 | return language 62 | return None 63 | -------------------------------------------------------------------------------- /src/marks/lines_number.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | from dataclasses import dataclass 3 | 4 | from talon import Module 5 | 6 | from ..targets.range_target import RangeConnective 7 | from .mark_types import LineNumber, LineNumberMark, LineNumberType 8 | 9 | mod = Module() 10 | 11 | mod.list("cursorless_line_direction", desc="Supported directions for line modifier") 12 | 13 | 14 | @dataclass 15 | class CustomizableTerm: 16 | cursorlessIdentifier: str 17 | type: LineNumberType 18 | formatter: Callable[[int], int] 19 | 20 | 21 | # NOTE: Please do not change these dicts. Use the CSVs for customization. 22 | # See https://www.cursorless.org/docs/user/customization 23 | directions = [ 24 | CustomizableTerm("lineNumberModulo100", "modulo100", lambda number: number - 1), 25 | CustomizableTerm("lineNumberRelativeUp", "relative", lambda number: -number), 26 | CustomizableTerm("lineNumberRelativeDown", "relative", lambda number: number), 27 | ] 28 | 29 | directions_map = {d.cursorlessIdentifier: d for d in directions} 30 | 31 | 32 | @mod.capture( 33 | rule=( 34 | "{user.cursorless_line_direction} " 35 | "[ ]" 36 | ) 37 | ) 38 | def cursorless_line_number(m) -> LineNumber: 39 | direction = directions_map[m.cursorless_line_direction] 40 | numbers: list[int] = m.number_small_list 41 | anchor = create_line_number_mark(direction.type, direction.formatter(numbers[0])) 42 | if len(numbers) > 1: 43 | active = create_line_number_mark( 44 | direction.type, direction.formatter(numbers[1]) 45 | ) 46 | range_connective: RangeConnective = m.cursorless_range_connective 47 | return { 48 | "type": "range", 49 | "anchor": anchor, 50 | "active": active, 51 | "excludeAnchor": range_connective.excludeAnchor, 52 | "excludeActive": range_connective.excludeActive, 53 | } 54 | return anchor 55 | 56 | 57 | def create_line_number_mark(type: LineNumberType, line_number: int) -> LineNumberMark: 58 | return { 59 | "type": "lineNumber", 60 | "lineNumberType": type, 61 | "lineNumber": line_number, 62 | } 63 | -------------------------------------------------------------------------------- /src/actions/wrap.py: -------------------------------------------------------------------------------- 1 | from talon import Module, actions 2 | 3 | from ..targets.target_types import CursorlessTarget 4 | 5 | mod = Module() 6 | 7 | mod.list("cursorless_wrap_action", desc="Cursorless wrap action") 8 | 9 | 10 | @mod.action_class 11 | class Actions: 12 | def private_cursorless_wrap_with_paired_delimiter( 13 | action_name: str, # pyright: ignore [reportGeneralTypeIssues] 14 | target: CursorlessTarget, 15 | paired_delimiter: list[str], 16 | ): 17 | """Execute Cursorless wrap/rewrap with paired delimiter action""" 18 | if action_name == "rewrap": 19 | action_name = "rewrapWithPairedDelimiter" 20 | 21 | actions.user.private_cursorless_command_and_wait( 22 | { 23 | "name": action_name, 24 | "left": paired_delimiter[0], 25 | "right": paired_delimiter[1], 26 | "target": target, 27 | } 28 | ) 29 | 30 | def private_cursorless_wrap_with_snippet( 31 | action_name: str, # pyright: ignore [reportGeneralTypeIssues] 32 | target: CursorlessTarget, 33 | snippet_location: str, 34 | ): 35 | """Execute Cursorless wrap with snippet action""" 36 | if action_name == "wrapWithPairedDelimiter": 37 | action_name = "wrapWithSnippet" 38 | elif action_name == "rewrap": 39 | raise Exception("Rewrapping with snippet not supported") 40 | 41 | snippet_name, variable_name = parse_snippet_location(snippet_location) 42 | 43 | actions.user.private_cursorless_command_and_wait( 44 | { 45 | "name": action_name, 46 | "snippetDescription": { 47 | "type": "named", 48 | "name": snippet_name, 49 | "variableName": variable_name, 50 | }, 51 | "target": target, 52 | } 53 | ) 54 | 55 | 56 | def parse_snippet_location(snippet_location: str) -> tuple[str, str]: 57 | [snippet_name, variable_name] = snippet_location.split(".") 58 | if snippet_name is None or variable_name is None: 59 | raise Exception("Snippet location missing '.'") 60 | return (snippet_name, variable_name) 61 | -------------------------------------------------------------------------------- /src/targets/range_target.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Optional 3 | 4 | from talon import Module 5 | 6 | from .target_types import ImplicitTarget, PrimitiveTarget, RangeTarget, RangeTargetType 7 | 8 | mod = Module() 9 | 10 | mod.list( 11 | "cursorless_range_connective", 12 | desc="A range joiner that indicates whether to include or exclude anchor and active", 13 | ) 14 | 15 | 16 | @dataclass 17 | class RangeConnective: 18 | excludeAnchor: bool 19 | excludeActive: bool 20 | 21 | 22 | @dataclass 23 | class RangeConnectiveWithType: 24 | connective: RangeConnective 25 | type: Optional[RangeTargetType] 26 | 27 | 28 | @mod.capture(rule="{user.cursorless_range_connective}") 29 | def cursorless_range_connective(m) -> RangeConnective: 30 | return RangeConnective( 31 | m.cursorless_range_connective in ["rangeExclusive", "rangeExcludingStart"], 32 | m.cursorless_range_connective in ["rangeExclusive", "rangeExcludingEnd"], 33 | ) 34 | 35 | 36 | @mod.capture( 37 | rule="[] | " 38 | ) 39 | def cursorless_range_connective_with_type(m) -> RangeConnectiveWithType: 40 | return RangeConnectiveWithType( 41 | getattr(m, "cursorless_range_connective", RangeConnective(False, False)), 42 | getattr(m, "cursorless_range_type", None), 43 | ) 44 | 45 | 46 | @mod.capture( 47 | rule=( 48 | "[] " 49 | ) 50 | ) 51 | def cursorless_range_target(m) -> RangeTarget: 52 | primitive_targets: list[PrimitiveTarget] = m.cursorless_primitive_target_list 53 | range_connective_with_type: RangeConnectiveWithType = ( 54 | m.cursorless_range_connective_with_type 55 | ) 56 | range_connective = range_connective_with_type.connective 57 | 58 | anchor = ImplicitTarget() if len(primitive_targets) == 1 else primitive_targets[0] 59 | 60 | return RangeTarget( 61 | anchor, 62 | primitive_targets[-1], 63 | range_connective.excludeAnchor, 64 | range_connective.excludeActive, 65 | range_connective_with_type.type, 66 | ) 67 | -------------------------------------------------------------------------------- /src/cheatsheet/sections/compound_targets.py: -------------------------------------------------------------------------------- 1 | from ..get_list import ListItemDescriptor, get_raw_list, get_spoken_form_from_list 2 | 3 | FORMATTERS = { 4 | "rangeExclusive": lambda start, end: f"between {start} and {end}", 5 | "rangeInclusive": lambda start, end: f"{start} through {end}", 6 | "rangeExcludingStart": lambda start, end: f"end of {start} through {end}", 7 | "rangeExcludingEnd": lambda start, end: f"{start} until start of {end}", 8 | "verticalRange": lambda start, end: f"{start} vertically through {end}", 9 | } 10 | 11 | 12 | def get_compound_targets() -> list[ListItemDescriptor]: 13 | list_connective_term = get_spoken_form_from_list( 14 | "list_connective", "listConnective" 15 | ) 16 | vertical_range_term = get_spoken_form_from_list("range_type", "verticalRange") 17 | 18 | items: list[ListItemDescriptor] = [] 19 | 20 | if list_connective_term: 21 | items.append( 22 | { 23 | "id": "listConnective", 24 | "type": "compoundTargetConnective", 25 | "variations": [ 26 | { 27 | "spokenForm": f" {list_connective_term} ", 28 | "description": " and ", 29 | }, 30 | ], 31 | } 32 | ) 33 | 34 | items.extend( 35 | [ 36 | get_entry(spoken_form, id) 37 | for spoken_form, id in get_raw_list("range_connective").items() 38 | ] 39 | ) 40 | 41 | if vertical_range_term: 42 | items.append(get_entry(vertical_range_term, "verticalRange")) 43 | 44 | return items 45 | 46 | 47 | def get_entry(spoken_form, id) -> ListItemDescriptor: 48 | formatter = FORMATTERS[id] 49 | 50 | return { 51 | "id": id, 52 | "type": "compoundTargetConnective", 53 | "variations": [ 54 | { 55 | "spokenForm": f" {spoken_form} ", 56 | "description": formatter("", ""), 57 | }, 58 | { 59 | "spokenForm": f"{spoken_form} ", 60 | "description": formatter("selection", ""), 61 | }, 62 | ], 63 | } 64 | -------------------------------------------------------------------------------- /src/spoken_scope_forms.py: -------------------------------------------------------------------------------- 1 | from talon import Context, scope 2 | 3 | from .csv_overrides import csv_get_ctx, csv_get_normalized_ctx 4 | 5 | 6 | def init_scope_spoken_forms(graphemes_talon_list: dict[str, str]): 7 | create_flattened_talon_list(csv_get_ctx(), graphemes_talon_list) 8 | if is_cursorless_test_mode(): 9 | create_flattened_talon_list(csv_get_normalized_ctx(), graphemes_talon_list) 10 | 11 | 12 | def create_flattened_talon_list(ctx: Context, graphemes_talon_list: dict[str, str]): 13 | lists_to_merge = { 14 | "cursorless_scope_type": "simple", 15 | "cursorless_selectable_only_paired_delimiter": "surroundingPair", 16 | "cursorless_wrapper_selectable_paired_delimiter": "surroundingPair", 17 | "cursorless_surrounding_pair_scope_type": "surroundingPair", 18 | } 19 | # If the user have no custom regex scope type, then that list is missing from the context 20 | if "user.cursorless_custom_regex_scope_type" in ctx.lists.keys(): # noqa: SIM118 21 | lists_to_merge["cursorless_custom_regex_scope_type"] = "customRegex" 22 | 23 | scope_types_singular: dict[str, str] = {} 24 | scope_types_plural: dict[str, str] = {} 25 | 26 | for list_name, prefix in lists_to_merge.items(): 27 | for key, value in ctx.lists[f"user.{list_name}"].items(): 28 | scope_types_singular[key] = f"{prefix}.{value}" 29 | for key, value in ctx.lists[f"user.{list_name}_plural"].items(): 30 | scope_types_plural[key] = f"{prefix}.{value}" 31 | 32 | glyph_singular_spoken_forms = ctx.lists["user.cursorless_glyph_scope_type"] 33 | glyph_plural_spoken_forms = ctx.lists["user.cursorless_glyph_scope_type_plural"] 34 | 35 | for grapheme_key, grapheme_value in graphemes_talon_list.items(): 36 | value = f"glyph.{grapheme_value}" 37 | for glyph in glyph_singular_spoken_forms: 38 | key = f"{glyph} {grapheme_key}" 39 | scope_types_singular[key] = value 40 | for glyph in glyph_plural_spoken_forms: 41 | key = f"{glyph} {grapheme_key}" 42 | scope_types_plural[key] = value 43 | 44 | ctx.lists["user.cursorless_scope_type_flattened"] = scope_types_singular 45 | ctx.lists["user.cursorless_scope_type_flattened_plural"] = scope_types_plural 46 | 47 | 48 | def is_cursorless_test_mode(): 49 | return "user.cursorless_spoken_form_test" in scope.get("mode") 50 | -------------------------------------------------------------------------------- /src/private_api/private_api.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional, Union 2 | 3 | from talon import Module, actions 4 | 5 | from ..targets.target_types import ( 6 | CursorlessTarget, 7 | ListTarget, 8 | PrimitiveTarget, 9 | RangeTarget, 10 | ) 11 | from .extract_decorated_marks import extract_decorated_marks 12 | 13 | mod = Module() 14 | 15 | 16 | @mod.action_class 17 | class MiscActions: 18 | def cursorless_private_extract_decorated_marks(capture: Any) -> list[dict]: 19 | """Cursorless private api: Extract all decorated marks from a Talon capture""" 20 | return extract_decorated_marks(capture) 21 | 22 | 23 | @mod.action_class 24 | class TargetBuilderActions: 25 | """Cursorless private api low-level target builder actions""" 26 | 27 | def cursorless_private_build_primitive_target( 28 | modifiers: list[dict], # pyright: ignore [reportGeneralTypeIssues] 29 | mark: Optional[dict], 30 | ) -> PrimitiveTarget: 31 | """Cursorless private api low-level target builder: Create a primitive target""" 32 | return PrimitiveTarget(mark, modifiers) 33 | 34 | def cursorless_private_build_list_target( 35 | elements: list[Union[PrimitiveTarget, RangeTarget]], # pyright: ignore [reportGeneralTypeIssues] 36 | ) -> Union[PrimitiveTarget, RangeTarget, ListTarget]: 37 | """Cursorless private api low-level target builder: Create a list target""" 38 | if len(elements) == 1: 39 | return elements[0] 40 | 41 | return ListTarget(elements) 42 | 43 | 44 | @mod.action_class 45 | class TargetActions: 46 | def cursorless_private_target_nothing() -> PrimitiveTarget: 47 | """Cursorless private api: Creates the "nothing" target""" 48 | return PrimitiveTarget({"type": "nothing"}, []) 49 | 50 | 51 | @mod.action_class 52 | class ActionActions: 53 | def cursorless_private_action_highlight( 54 | target: CursorlessTarget, # pyright: ignore [reportGeneralTypeIssues] 55 | highlightId: Optional[str] = None, 56 | ) -> None: 57 | """Cursorless private api: Highlights a target""" 58 | payload = { 59 | "name": "highlight", 60 | "target": target, 61 | } 62 | 63 | if highlightId is not None: 64 | payload["highlightId"] = highlightId 65 | 66 | actions.user.private_cursorless_command_and_wait( 67 | payload, 68 | ) 69 | -------------------------------------------------------------------------------- /src/actions/generate_snippet.py: -------------------------------------------------------------------------------- 1 | import glob 2 | from pathlib import Path 3 | 4 | from talon import Module, actions, registry, settings 5 | 6 | from ..targets.target_types import CursorlessExplicitTarget 7 | 8 | mod = Module() 9 | 10 | 11 | @mod.action_class 12 | class Actions: 13 | def private_cursorless_migrate_snippets(): 14 | """Migrate snippets from Cursorless to community format""" 15 | actions.user.private_cursorless_run_rpc_command_no_wait( 16 | "cursorless.migrateSnippets", 17 | str(get_directory_path()), 18 | { 19 | "insertion": registry.lists[ 20 | "user.cursorless_insertion_snippet_no_phrase" 21 | ][-1], 22 | "insertionWithPhrase": registry.lists[ 23 | "user.cursorless_insertion_snippet_single_phrase" 24 | ][-1], 25 | "wrapper": registry.lists["user.cursorless_wrapper_snippet"][-1], 26 | }, 27 | ) 28 | 29 | def private_cursorless_generate_snippet_action(target: CursorlessExplicitTarget): # pyright: ignore [reportGeneralTypeIssues] 30 | """Generate a snippet from the given target""" 31 | actions.user.private_cursorless_command_no_wait( 32 | { 33 | "name": "generateSnippet", 34 | "target": target, 35 | "directory": str(get_directory_path()), 36 | } 37 | ) 38 | 39 | 40 | def get_directory_path() -> Path: 41 | settings_dir = get_setting_dir() 42 | if settings_dir is not None: 43 | return settings_dir 44 | return get_community_snippets_dir() 45 | 46 | 47 | def get_setting_dir() -> Path | None: 48 | try: 49 | setting_dir = settings.get("user.snippets_dir") 50 | if not setting_dir: 51 | return None 52 | 53 | dir = Path(str(setting_dir)) 54 | 55 | if not dir.is_absolute(): 56 | user_dir = Path(actions.path.talon_user()) 57 | dir = user_dir / dir 58 | 59 | return dir.resolve() 60 | except Exception: 61 | return None 62 | 63 | 64 | def get_community_snippets_dir() -> Path: 65 | files = glob.iglob( 66 | f"{actions.path.talon_user()}/**/snippets/snippets/*.snippet", 67 | recursive=True, 68 | ) 69 | for file in files: 70 | return Path(file).parent 71 | raise ValueError("Could not find community snippets directory") 72 | -------------------------------------------------------------------------------- /src/modifiers/scopes.py: -------------------------------------------------------------------------------- 1 | from talon import Module 2 | 3 | mod = Module() 4 | 5 | mod.list("cursorless_scope_type", desc="Supported scope types") 6 | mod.list("cursorless_scope_type_plural", desc="Supported plural scope types") 7 | 8 | mod.list( 9 | "cursorless_glyph_scope_type", 10 | desc="Cursorless glyph scope type", 11 | ) 12 | mod.list( 13 | "cursorless_glyph_scope_type_plural", 14 | desc="Plural version of Cursorless glyph scope type", 15 | ) 16 | 17 | mod.list( 18 | "cursorless_surrounding_pair_scope_type", 19 | desc="Scope types that can function as surrounding pairs", 20 | ) 21 | mod.list( 22 | "cursorless_surrounding_pair_scope_type_plural", 23 | desc="Plural form of scope types that can function as surrounding pairs", 24 | ) 25 | 26 | mod.list( 27 | "cursorless_custom_regex_scope_type", 28 | desc="Supported custom regular expression scope types", 29 | ) 30 | mod.list( 31 | "cursorless_custom_regex_scope_type_plural", 32 | desc="Supported plural custom regular expression scope types", 33 | ) 34 | 35 | mod.list( 36 | "cursorless_scope_type_flattened", 37 | desc="All supported scope types flattened", 38 | ) 39 | mod.list( 40 | "cursorless_scope_type_flattened_plural", 41 | desc="All supported plural scope types flattened", 42 | ) 43 | 44 | 45 | @mod.capture(rule="{user.cursorless_scope_type_flattened}") 46 | def cursorless_scope_type(m) -> dict[str, str]: 47 | """Cursorless scope type singular""" 48 | return creates_scope_type(m.cursorless_scope_type_flattened) 49 | 50 | 51 | @mod.capture(rule="{user.cursorless_scope_type_flattened_plural}") 52 | def cursorless_scope_type_plural(m) -> dict[str, str]: 53 | """Cursorless scope type plural""" 54 | return creates_scope_type(m.cursorless_scope_type_flattened_plural) 55 | 56 | 57 | def creates_scope_type(id: str) -> dict[str, str]: 58 | grouping, value = id.split(".", 1) 59 | match grouping: 60 | case "simple": 61 | return { 62 | "type": value, 63 | } 64 | case "surroundingPair": 65 | return { 66 | "type": "surroundingPair", 67 | "delimiter": value, 68 | } 69 | case "customRegex": 70 | return { 71 | "type": "customRegex", 72 | "regex": value, 73 | } 74 | case "glyph": 75 | return { 76 | "type": "glyph", 77 | "character": value, 78 | } 79 | case _: 80 | raise ValueError(f"Unsupported scope type grouping: {grouping}") 81 | -------------------------------------------------------------------------------- /src/cheatsheet/sections/tutorial.py: -------------------------------------------------------------------------------- 1 | from ..get_list import ListItemDescriptor 2 | 3 | 4 | def get_tutorial_entries() -> list[ListItemDescriptor]: 5 | return [ 6 | { 7 | "id": "start_tutorial", 8 | "type": "command", 9 | "variations": [ 10 | { 11 | "spokenForm": "cursorless tutorial", 12 | "description": "Start the introductory Cursorless tutorial", 13 | }, 14 | ], 15 | }, 16 | { 17 | "id": "tutorial_next", 18 | "type": "command", 19 | "variations": [ 20 | { 21 | "spokenForm": "tutorial next", 22 | "description": "Advance to next step in tutorial", 23 | }, 24 | ], 25 | }, 26 | { 27 | "id": "tutorial_previous", 28 | "type": "command", 29 | "variations": [ 30 | { 31 | "spokenForm": "tutorial previous", 32 | "description": "Go back to previous step in tutorial", 33 | }, 34 | ], 35 | }, 36 | { 37 | "id": "tutorial_restart", 38 | "type": "command", 39 | "variations": [ 40 | { 41 | "spokenForm": "tutorial restart", 42 | "description": "Restart the tutorial", 43 | }, 44 | ], 45 | }, 46 | { 47 | "id": "tutorial_resume", 48 | "type": "command", 49 | "variations": [ 50 | { 51 | "spokenForm": "tutorial resume", 52 | "description": "Resume the tutorial", 53 | }, 54 | ], 55 | }, 56 | { 57 | "id": "tutorial_list", 58 | "type": "command", 59 | "variations": [ 60 | { 61 | "spokenForm": "tutorial list", 62 | "description": "List all available tutorials", 63 | }, 64 | ], 65 | }, 66 | { 67 | "id": "tutorial_close", 68 | "type": "command", 69 | "variations": [ 70 | { 71 | "spokenForm": "tutorial close", 72 | "description": "Close the tutorial", 73 | }, 74 | ], 75 | }, 76 | { 77 | "id": "tutorial_start_by_number", 78 | "type": "command", 79 | "variations": [ 80 | { 81 | "spokenForm": "tutorial ", 82 | "description": "Start a specific tutorial by number", 83 | }, 84 | ], 85 | }, 86 | ] 87 | -------------------------------------------------------------------------------- /src/cursorless.talon: -------------------------------------------------------------------------------- 1 | mode: command 2 | mode: user.cursorless_spoken_form_test 3 | tag: user.cursorless 4 | - 5 | 6 | : 7 | user.private_cursorless_action_or_ide_command(cursorless_action_or_ide_command, cursorless_target) 8 | 9 | {user.cursorless_bring_move_action} : 10 | user.private_cursorless_bring_move(cursorless_bring_move_action, cursorless_bring_move_targets) 11 | 12 | {user.cursorless_swap_action} : 13 | user.private_cursorless_swap(cursorless_swap_targets) 14 | 15 | {user.cursorless_paste_action} : 16 | user.private_cursorless_paste(cursorless_destination) 17 | 18 | {user.cursorless_reformat_action} at : 19 | user.cursorless_reformat(cursorless_target, formatters) 20 | 21 | {user.cursorless_call_action} on : 22 | user.private_cursorless_call(cursorless_target_1, cursorless_target_2) 23 | 24 | {user.cursorless_wrap_action} : 25 | user.private_cursorless_wrap_with_paired_delimiter(cursorless_wrap_action, cursorless_target, cursorless_wrapper_paired_delimiter) 26 | 27 | {user.cursorless_insert_snippet_action} {user.snippet} : 28 | user.private_cursorless_insert_community_snippet(snippet, cursorless_destination) 29 | 30 | {user.snippet_wrapper} {user.cursorless_wrap_action} : 31 | user.private_cursorless_wrap_with_community_snippet(snippet_wrapper, cursorless_target) 32 | 33 | {user.cursorless_show_scope_visualizer} [{user.cursorless_visualization_type}]: 34 | user.private_cursorless_show_scope_visualizer(cursorless_scope_type, cursorless_visualization_type or "content") 35 | {user.cursorless_hide_scope_visualizer}: 36 | user.private_cursorless_hide_scope_visualizer() 37 | 38 | {user.cursorless_homophone} settings: 39 | user.private_cursorless_show_settings_in_ide() 40 | 41 | bar {user.cursorless_homophone}: 42 | user.private_cursorless_show_sidebar() 43 | 44 | {user.cursorless_homophone} stats: 45 | user.private_cursorless_show_command_statistics() 46 | 47 | {user.cursorless_homophone} tutorial: 48 | user.private_cursorless_start_tutorial() 49 | tutorial next: user.private_cursorless_tutorial_next() 50 | tutorial (previous | last): user.private_cursorless_tutorial_previous() 51 | tutorial restart: user.private_cursorless_tutorial_restart() 52 | tutorial resume: user.private_cursorless_tutorial_resume() 53 | tutorial (list | close): user.private_cursorless_tutorial_list() 54 | tutorial : 55 | user.private_cursorless_tutorial_start_by_number(number_small) 56 | 57 | {user.cursorless_homophone} migrate snippets: 58 | user.private_cursorless_migrate_snippets() 59 | -------------------------------------------------------------------------------- /src/cheatsheet/get_list.py: -------------------------------------------------------------------------------- 1 | import re 2 | import typing 3 | from collections.abc import Mapping, Sequence 4 | from typing import Optional, TypedDict 5 | 6 | from talon import registry 7 | 8 | from ..conventions import get_cursorless_list_name 9 | 10 | 11 | class Variation(TypedDict): 12 | spokenForm: str 13 | description: str 14 | 15 | 16 | class ListItemDescriptor(TypedDict): 17 | id: str 18 | type: str 19 | variations: list[Variation] 20 | 21 | 22 | def get_list( 23 | name: str, type: str, descriptions: Optional[Mapping[str, str]] = None 24 | ) -> list[ListItemDescriptor]: 25 | if descriptions is None: 26 | descriptions = {} 27 | 28 | items = get_raw_list(name) 29 | 30 | return make_dict_readable(type, items, descriptions) 31 | 32 | 33 | def get_lists( 34 | names: Sequence[str], type: str, descriptions: Optional[Mapping[str, str]] = None 35 | ) -> list[ListItemDescriptor]: 36 | return [item for name in names for item in get_list(name, type, descriptions)] 37 | 38 | 39 | def get_raw_list(name: str) -> Mapping[str, str]: 40 | cursorless_list_name = get_cursorless_list_name(name) 41 | return typing.cast(dict[str, str], registry.lists[cursorless_list_name][0]).copy() 42 | 43 | 44 | def get_spoken_form_from_list(list_name: str, value: str) -> str | None: 45 | """Get the spoken form of a value from a list. 46 | 47 | Args: 48 | list_name (str): The name of the list. 49 | value (str): The value to look up. 50 | 51 | Returns: 52 | str: The spoken form of the value if found, otherwise None. 53 | """ 54 | return next( 55 | ( 56 | spoken_form 57 | for spoken_form, v in get_raw_list(list_name).items() 58 | if v == value 59 | ), 60 | None, 61 | ) 62 | 63 | 64 | def make_dict_readable( 65 | type: str, dict: Mapping[str, str], descriptions: Mapping[str, str] 66 | ) -> list[ListItemDescriptor]: 67 | return [ 68 | { 69 | "id": value, 70 | "type": type, 71 | "variations": [ 72 | { 73 | "spokenForm": key, 74 | "description": descriptions.get(value, make_readable(value)), 75 | } 76 | ], 77 | } 78 | for key, value in dict.items() 79 | ] 80 | 81 | 82 | def make_readable(text: str) -> str: 83 | text, is_private = ( 84 | (text[8:], True) if text.startswith("private.") else (text, False) 85 | ) 86 | text = text.replace(".", " ") 87 | text = de_camel(text).lower().capitalize() 88 | return f"{text} (PRIVATE)" if is_private else text 89 | 90 | 91 | def de_camel(text: str) -> str: 92 | """Replacing camelCase boundaries with blank space""" 93 | return re.sub( 94 | r"(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|(?<=[a-zA-Z])(?=[0-9])|(?<=[0-9])(?=[a-zA-Z])", 95 | " ", 96 | text, 97 | ) 98 | -------------------------------------------------------------------------------- /src/cursorless.py: -------------------------------------------------------------------------------- 1 | from talon import Context, Module, actions 2 | 3 | mod = Module() 4 | 5 | mod.tag( 6 | "cursorless", 7 | "Application supporting cursorless commands", 8 | ) 9 | 10 | ctx = Context() 11 | ctx.matches = r""" 12 | tag: user.cursorless 13 | """ 14 | 15 | 16 | @mod.action_class 17 | class Actions: 18 | def private_cursorless_show_settings_in_ide(): 19 | """Show Cursorless-specific settings in ide""" 20 | 21 | def private_cursorless_show_sidebar(): 22 | """Show Cursorless-specific settings in ide""" 23 | 24 | def private_cursorless_notify_docs_opened(): 25 | """Notify the ide that the docs were opened in case the tutorial is waiting for that event""" 26 | actions.skip() 27 | 28 | def private_cursorless_show_command_statistics(): 29 | """Show Cursorless command statistics""" 30 | actions.user.private_cursorless_run_rpc_command_no_wait( 31 | "cursorless.analyzeCommandHistory" 32 | ) 33 | 34 | def private_cursorless_start_tutorial(): 35 | """Start the introductory Cursorless tutorial""" 36 | actions.user.private_cursorless_run_rpc_command_no_wait( 37 | "cursorless.tutorial.start", "tutorial-1-basics" 38 | ) 39 | 40 | def private_cursorless_tutorial_next(): 41 | """Cursorless tutorial: next""" 42 | actions.user.private_cursorless_run_rpc_command_no_wait( 43 | "cursorless.tutorial.next" 44 | ) 45 | 46 | def private_cursorless_tutorial_previous(): 47 | """Cursorless tutorial: previous""" 48 | actions.user.private_cursorless_run_rpc_command_no_wait( 49 | "cursorless.tutorial.previous" 50 | ) 51 | 52 | def private_cursorless_tutorial_restart(): 53 | """Cursorless tutorial: restart""" 54 | actions.user.private_cursorless_run_rpc_command_no_wait( 55 | "cursorless.tutorial.restart" 56 | ) 57 | 58 | def private_cursorless_tutorial_resume(): 59 | """Cursorless tutorial: resume""" 60 | actions.user.private_cursorless_run_rpc_command_no_wait( 61 | "cursorless.tutorial.resume" 62 | ) 63 | 64 | def private_cursorless_tutorial_list(): 65 | """Cursorless tutorial: list all available tutorials""" 66 | actions.user.private_cursorless_run_rpc_command_no_wait( 67 | "cursorless.tutorial.list" 68 | ) 69 | 70 | def private_cursorless_tutorial_start_by_number(number: int): # pyright: ignore [reportGeneralTypeIssues] 71 | """Start Cursorless tutorial by number""" 72 | actions.user.private_cursorless_run_rpc_command_no_wait( 73 | "cursorless.tutorial.start", number - 1 74 | ) 75 | 76 | 77 | @ctx.action_class("user") 78 | class CursorlessActions: 79 | def private_cursorless_notify_docs_opened(): 80 | actions.user.private_cursorless_run_rpc_command_no_wait( 81 | "cursorless.documentationOpened" 82 | ) 83 | -------------------------------------------------------------------------------- /src/modifiers/ordinal_scope.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from talon import Module 4 | 5 | from ..targets.range_target import RangeConnective 6 | 7 | mod = Module() 8 | 9 | mod.list("cursorless_first_modifier", desc="Cursorless first modifiers") 10 | mod.list("cursorless_last_modifier", desc="Cursorless last modifiers") 11 | 12 | 13 | @mod.capture( 14 | rule=" | [] {user.cursorless_last_modifier}" 15 | ) 16 | def cursorless_ordinal_or_last(m) -> int: 17 | """An ordinal or the word 'last'""" 18 | if m[-1] == "last": 19 | return -getattr(m, "ordinals_small", 1) 20 | return m.ordinals_small - 1 21 | 22 | 23 | @mod.capture( 24 | rule=" [ ] " 25 | ) 26 | def cursorless_ordinal_range(m) -> dict[str, Any]: 27 | """Ordinal range""" 28 | anchor = create_ordinal_scope_modifier( 29 | m.cursorless_scope_type, m.cursorless_ordinal_or_last_list[0] 30 | ) 31 | if len(m.cursorless_ordinal_or_last_list) > 1: 32 | active = create_ordinal_scope_modifier( 33 | m.cursorless_scope_type, m.cursorless_ordinal_or_last_list[1] 34 | ) 35 | range_connective: RangeConnective = m.cursorless_range_connective 36 | return { 37 | "type": "range", 38 | "anchor": anchor, 39 | "active": active, 40 | "excludeAnchor": range_connective.excludeAnchor, 41 | "excludeActive": range_connective.excludeActive, 42 | } 43 | return anchor 44 | 45 | 46 | @mod.capture( 47 | rule=( 48 | "[{user.cursorless_every_scope_modifier}] " 49 | "({user.cursorless_first_modifier} | {user.cursorless_last_modifier}) " 50 | " " 51 | ), 52 | ) 53 | def cursorless_first_last(m) -> dict[str, Any]: 54 | """First/last `n` scopes; eg "first three funks""" 55 | is_every = hasattr(m, "cursorless_every_scope_modifier") 56 | if hasattr(m, "cursorless_first_modifier"): 57 | return create_ordinal_scope_modifier( 58 | m.cursorless_scope_type_plural, 59 | 0, 60 | m.number_small, 61 | is_every, 62 | ) 63 | return create_ordinal_scope_modifier( 64 | m.cursorless_scope_type_plural, 65 | -m.number_small, 66 | m.number_small, 67 | is_every, 68 | ) 69 | 70 | 71 | @mod.capture(rule=" | ") 72 | def cursorless_ordinal_scope(m) -> dict[str, Any]: 73 | """Ordinal ranges such as subwords or characters""" 74 | return m[0] 75 | 76 | 77 | def create_ordinal_scope_modifier( 78 | scope_type: dict, 79 | start: int, 80 | length: int = 1, 81 | is_every: bool = False, 82 | ): 83 | res = { 84 | "type": "ordinalScope", 85 | "scopeType": scope_type, 86 | "start": start, 87 | "length": length, 88 | } 89 | if is_every: 90 | res["isEvery"] = True 91 | return res 92 | -------------------------------------------------------------------------------- /src/snippets/snippet_types.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from ..targets.target_types import CursorlessDestination, CursorlessTarget 4 | 5 | # Scope types 6 | 7 | 8 | @dataclass 9 | class ScopeType: 10 | type: str 11 | 12 | 13 | def to_scope_types(scope_types: str | list[str]) -> list[ScopeType]: 14 | if isinstance(scope_types, str): 15 | return [ScopeType(scope_types)] 16 | return [ScopeType(st) for st in scope_types] 17 | 18 | 19 | # Community types 20 | 21 | 22 | @dataclass 23 | class CommunityInsertionSnippet: 24 | body: str 25 | languages: list[str] | None = None 26 | scopes: list[str] | None = None 27 | 28 | 29 | @dataclass 30 | class CommunityWrapperSnippet: 31 | body: str 32 | variable_name: str 33 | languages: list[str] | None 34 | scope: str | None 35 | 36 | 37 | # Insertion snippets 38 | 39 | 40 | @dataclass 41 | class CustomInsertionSnippet: 42 | type = "custom" 43 | body: str 44 | scopeTypes: list[ScopeType] | None 45 | languages: list[str] | None 46 | substitutions: dict[str, str] | None 47 | 48 | @staticmethod 49 | def create( 50 | snippet: CommunityInsertionSnippet, 51 | substitutions: dict[str, str] | None = None, 52 | ): 53 | return CustomInsertionSnippet( 54 | snippet.body, 55 | to_scope_types(snippet.scopes) if snippet.scopes else None, 56 | # languages will be missing if the user has an older version of community 57 | snippet.languages if hasattr(snippet, "languages") else None, 58 | substitutions=substitutions, 59 | ) 60 | 61 | 62 | @dataclass 63 | class ListInsertionSnippet: 64 | type = "list" 65 | fallbackLanguage: str | None 66 | substitutions: dict[str, str] | None 67 | snippets: list[CustomInsertionSnippet] 68 | 69 | 70 | @dataclass 71 | class InsertSnippetAction: 72 | name = "insertSnippet" 73 | snippetDescription: CustomInsertionSnippet | ListInsertionSnippet 74 | destination: CursorlessDestination 75 | 76 | 77 | # Wrapper snippets 78 | 79 | 80 | @dataclass 81 | class CustomWrapperSnippet: 82 | type = "custom" 83 | body: str 84 | variableName: str | None 85 | scopeType: ScopeType | None 86 | languages: list[str] | None 87 | 88 | @staticmethod 89 | def create(snippet: CommunityWrapperSnippet): 90 | return CustomWrapperSnippet( 91 | snippet.body, 92 | snippet.variable_name, 93 | ScopeType(snippet.scope) if snippet.scope else None, 94 | # languages will be missing if the user has an older version of community 95 | snippet.languages if hasattr(snippet, "languages") else None, 96 | ) 97 | 98 | 99 | @dataclass 100 | class ListWrapperSnippet: 101 | type = "list" 102 | fallbackLanguage: str | None 103 | snippets: list[CustomWrapperSnippet] 104 | 105 | 106 | @dataclass 107 | class WrapperSnippetAction: 108 | name = "wrapWithSnippet" 109 | snippetDescription: CustomWrapperSnippet | ListWrapperSnippet 110 | target: CursorlessTarget 111 | -------------------------------------------------------------------------------- /src/modifiers/relative_scope.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from talon import Module 4 | 5 | mod = Module() 6 | 7 | mod.list("cursorless_previous_next_modifier", desc="Cursorless previous/next modifiers") 8 | mod.list( 9 | "cursorless_forward_backward_modifier", desc="Cursorless forward/backward modifiers" 10 | ) 11 | 12 | 13 | @mod.capture(rule="{user.cursorless_previous_next_modifier}") 14 | def cursorless_relative_direction(m) -> str: 15 | """Previous/next""" 16 | return "backward" if m[0] == "previous" else "forward" 17 | 18 | 19 | @mod.capture( 20 | rule="[] " 21 | ) 22 | def cursorless_relative_scope_singular(m) -> dict[str, Any]: 23 | """Relative previous/next singular scope, eg `"next funk"` or `"third next funk"`.""" 24 | return create_relative_scope_modifier( 25 | m.cursorless_scope_type, 26 | getattr(m, "ordinals_small", 1), 27 | 1, 28 | m.cursorless_relative_direction, 29 | False, 30 | ) 31 | 32 | 33 | @mod.capture( 34 | rule="[{user.cursorless_every_scope_modifier}] " 35 | ) 36 | def cursorless_relative_scope_plural(m) -> dict[str, Any]: 37 | """Relative previous/next plural scope. `next three funks`""" 38 | return create_relative_scope_modifier( 39 | m.cursorless_scope_type_plural, 40 | 1, 41 | m.number_small, 42 | m.cursorless_relative_direction, 43 | hasattr(m, "cursorless_every_scope_modifier"), 44 | ) 45 | 46 | 47 | @mod.capture( 48 | rule="[{user.cursorless_every_scope_modifier}] [{user.cursorless_forward_backward_modifier}]" 49 | ) 50 | def cursorless_relative_scope_count(m) -> dict[str, Any]: 51 | """Relative count scope. `three funks`""" 52 | return create_relative_scope_modifier( 53 | m.cursorless_scope_type_plural, 54 | 0, 55 | m.number_small, 56 | getattr(m, "cursorless_forward_backward_modifier", "forward"), 57 | hasattr(m, "cursorless_every_scope_modifier"), 58 | ) 59 | 60 | 61 | @mod.capture( 62 | rule=" {user.cursorless_forward_backward_modifier}" 63 | ) 64 | def cursorless_relative_scope_one_backward(m) -> dict[str, Any]: 65 | """Take scope backward, eg `funk backward`""" 66 | return create_relative_scope_modifier( 67 | m.cursorless_scope_type, 68 | 0, 69 | 1, 70 | m.cursorless_forward_backward_modifier, 71 | False, 72 | ) 73 | 74 | 75 | @mod.capture( 76 | rule=( 77 | " | " 78 | " | " 79 | " | " 80 | "" 81 | ) 82 | ) 83 | def cursorless_relative_scope(m) -> dict[str, Any]: 84 | """Previous/next scope""" 85 | return m[0] 86 | 87 | 88 | def create_relative_scope_modifier( 89 | scope_type: dict, 90 | offset: int, 91 | length: int, 92 | direction: str, 93 | is_every: bool, 94 | ) -> dict[str, Any]: 95 | res = { 96 | "type": "relativeScope", 97 | "scopeType": scope_type, 98 | "offset": offset, 99 | "length": length, 100 | "direction": direction, 101 | } 102 | if is_every: 103 | res["isEvery"] = True 104 | return res 105 | -------------------------------------------------------------------------------- /src/command.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | from typing import Any 3 | 4 | from talon import Module, actions, speech_system 5 | 6 | from .fallback import perform_fallback 7 | from .versions import COMMAND_VERSION 8 | 9 | 10 | @dataclasses.dataclass 11 | class CursorlessCommand: 12 | version = COMMAND_VERSION 13 | spokenForm: str 14 | usePrePhraseSnapshot: bool 15 | action: dict 16 | 17 | 18 | CURSORLESS_COMMAND_ID = "cursorless.command" 19 | last_phrase: dict = {} 20 | 21 | mod = Module() 22 | 23 | 24 | def on_phrase(d): 25 | global last_phrase 26 | last_phrase = d 27 | 28 | 29 | speech_system.register("pre:phrase", on_phrase) 30 | 31 | 32 | @mod.action_class 33 | class Actions: 34 | def private_cursorless_command_and_wait(action: dict): # pyright: ignore [reportGeneralTypeIssues] 35 | """Execute cursorless command and wait for it to finish""" 36 | response = actions.user.private_cursorless_run_rpc_command_get( 37 | CURSORLESS_COMMAND_ID, 38 | construct_cursorless_command(action), 39 | ) 40 | if "fallback" in response: 41 | perform_fallback(response["fallback"]) 42 | 43 | def private_cursorless_command_no_wait(action: dict): # pyright: ignore [reportGeneralTypeIssues] 44 | """Execute cursorless command without waiting""" 45 | actions.user.private_cursorless_run_rpc_command_no_wait( 46 | CURSORLESS_COMMAND_ID, 47 | construct_cursorless_command(action), 48 | ) 49 | 50 | def private_cursorless_command_get(action: dict): # pyright: ignore [reportGeneralTypeIssues] 51 | """Execute cursorless command and return result""" 52 | response = actions.user.private_cursorless_run_rpc_command_get( 53 | CURSORLESS_COMMAND_ID, 54 | construct_cursorless_command(action), 55 | ) 56 | if "fallback" in response: 57 | return perform_fallback(response["fallback"]) 58 | if "returnValue" in response: 59 | return response["returnValue"] 60 | return None 61 | 62 | 63 | def construct_cursorless_command(action: dict) -> dict: 64 | try: 65 | use_pre_phrase_snapshot = actions.user.did_emit_pre_phrase_signal() 66 | except KeyError: 67 | use_pre_phrase_snapshot = False 68 | 69 | spoken_form = " ".join(last_phrase["phrase"]) 70 | 71 | return make_serializable( 72 | CursorlessCommand( 73 | spoken_form, 74 | use_pre_phrase_snapshot, 75 | action, 76 | ) 77 | ) 78 | 79 | 80 | def make_serializable(value: Any) -> Any: 81 | """ 82 | Converts a dataclass into a serializable dict 83 | 84 | Note that we don't use the built-in asdict() function because it will 85 | ignore the static `type` field. 86 | 87 | Args: 88 | value (any): The value to convert 89 | 90 | Returns: 91 | _type_: The converted value, ready for serialization 92 | """ 93 | if isinstance(value, dict): 94 | return {k: make_serializable(v) for k, v in value.items()} 95 | if isinstance(value, list): 96 | return [make_serializable(v) for v in value] 97 | if dataclasses.is_dataclass(value): 98 | items = { 99 | **{ 100 | k: v 101 | for k, v in vars(type(value)).items() 102 | if not k.startswith("_") 103 | and not isinstance(v, property) 104 | and not isinstance(v, staticmethod) 105 | }, 106 | **value.__dict__, 107 | } 108 | return {k: make_serializable(v) for k, v in items.items() if v is not None} 109 | return value 110 | -------------------------------------------------------------------------------- /src/apps/vscode_settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | from typing import Any 4 | 5 | from talon import Context, Module, actions 6 | 7 | from ..vendor.jstyleson import loads 8 | 9 | mod = Module() 10 | 11 | windows_ctx = Context() 12 | mac_ctx = Context() 13 | linux_ctx = Context() 14 | 15 | windows_ctx.matches = r""" 16 | os: windows 17 | """ 18 | mac_ctx.matches = r""" 19 | os: mac 20 | """ 21 | linux_ctx.matches = r""" 22 | os: linux 23 | """ 24 | 25 | 26 | @mod.action_class 27 | class Actions: 28 | def vscode_settings_path() -> Path: 29 | """Get path of vscode settings json file""" 30 | ... 31 | 32 | def vscode_get_setting(key: str, default_value: Any = None): # pyright: ignore [reportGeneralTypeIssues] 33 | """Get the value of vscode setting at the given key""" 34 | path: Path = actions.user.vscode_settings_path() 35 | settings: dict = loads(path.read_text()) 36 | 37 | if default_value is not None: 38 | return settings.get(key, default_value) 39 | else: 40 | return settings[key] 41 | 42 | def vscode_get_setting_with_fallback( 43 | key: str, # pyright: ignore [reportGeneralTypeIssues] 44 | default_value: Any, 45 | fallback_value: Any, 46 | fallback_message: str, 47 | ) -> tuple[Any, bool]: 48 | """Returns a vscode setting with a fallback in case there's an error 49 | 50 | Args: 51 | key (str): The key of the setting to look up 52 | default_value (Any): The default value to return if the setting is not defined 53 | fallback_value (Any): The value to return if there is an error looking up the setting 54 | fallback_message (str): The message to show to the user if we end up having to use the fallback 55 | 56 | Returns: 57 | tuple[Any, bool]: The value of the setting or the default or fall back, along with boolean which is true if there was an error 58 | """ 59 | try: 60 | return actions.user.vscode_get_setting(key, default_value), False 61 | except Exception: 62 | print(fallback_message) 63 | return fallback_value, True 64 | 65 | 66 | def pick_path(paths: list[Path]) -> Path: 67 | existing_paths = [path for path in paths if path.exists()] 68 | if not existing_paths: 69 | paths_str = ", ".join(str(path) for path in paths) 70 | raise FileNotFoundError( 71 | f"Couldn't find VSCode's settings JSON. Tried these paths: {paths_str}" 72 | ) 73 | return max(existing_paths, key=lambda path: path.stat().st_mtime) 74 | 75 | 76 | @mac_ctx.action_class("user") 77 | class MacUserActions: 78 | def vscode_settings_path() -> Path: 79 | application_support = Path.home() / "Library/Application Support" 80 | return pick_path( 81 | [ 82 | application_support / "Code/User/settings.json", 83 | application_support / "VSCodium/User/settings.json", 84 | ] 85 | ) 86 | 87 | 88 | @linux_ctx.action_class("user") 89 | class LinuxUserActions: 90 | def vscode_settings_path() -> Path: 91 | xdg_config_home = Path( 92 | os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config") 93 | ) 94 | flatpak_apps = Path.home() / ".var/app" 95 | return pick_path( 96 | [ 97 | xdg_config_home / "Code/User/settings.json", 98 | xdg_config_home / "VSCodium/User/settings.json", 99 | xdg_config_home / "Code - OSS/User/settings.json", 100 | xdg_config_home / "Cursor/User/settings.json", 101 | flatpak_apps / "com.visualstudio.code/config/Code/User/settings.json", 102 | flatpak_apps / "com.vscodium.codium/config/VSCodium/User/settings.json", 103 | flatpak_apps 104 | / "com.visualstudio.code-oss/config/Code - OSS/User/settings.json", 105 | ] 106 | ) 107 | 108 | 109 | @windows_ctx.action_class("user") 110 | class WindowsUserActions: 111 | def vscode_settings_path() -> Path: 112 | appdata = Path(os.environ["APPDATA"]) 113 | return pick_path( 114 | [ 115 | appdata / "Code/User/settings.json", 116 | appdata / "VSCodium/User/settings.json", 117 | ] 118 | ) 119 | -------------------------------------------------------------------------------- /src/get_grapheme_spoken_form_entries.py: -------------------------------------------------------------------------------- 1 | import re 2 | import typing 3 | from collections import defaultdict 4 | from typing import Iterator, Mapping 5 | 6 | from talon import app, registry, scope 7 | 8 | from .spoken_forms_output import SpokenFormOutputEntry 9 | 10 | grapheme_capture_name = "user.any_alphanumeric_key" 11 | 12 | 13 | def get_grapheme_spoken_form_entries( 14 | grapheme_talon_list: dict[str, str], 15 | ) -> list[SpokenFormOutputEntry]: 16 | return [ 17 | { 18 | "type": "grapheme", 19 | "id": id, 20 | "spokenForms": spoken_forms, 21 | } 22 | for id, spoken_forms in talon_list_to_spoken_form_map( 23 | grapheme_talon_list 24 | ).items() 25 | ] 26 | 27 | 28 | def get_graphemes_talon_list() -> dict[str, str]: 29 | if grapheme_capture_name not in registry.captures: 30 | # We require this capture, and expect it to be defined. We want to show a user friendly error if it isn't present (usually indicating a problem with their community.git setup) and we think the user is going to use Cursorless. 31 | # However, sometimes users use different dictation engines (Vosk, Webspeech) with entirely different/smaller grammars that don't have the capture, and this code will run then, and falsely error. We don't want to show an error in that case because they don't plan to actually use Cursorless. 32 | if "en" in scope.get("language", {}): 33 | app.notify(f"Capture <{grapheme_capture_name}> isn't defined") 34 | print( 35 | f"Capture <{grapheme_capture_name}> isn't defined, which is required by Cursorless. Please check your community setup" 36 | ) 37 | return {} 38 | 39 | return { 40 | spoken_form: id 41 | for symbol_list in generate_lists_from_capture(grapheme_capture_name) 42 | for spoken_form, id in get_id_to_talon_list(symbol_list).items() 43 | } 44 | 45 | 46 | def generate_lists_from_capture(capture_name) -> Iterator[str]: 47 | """ 48 | Given the name of a capture, yield the names of each list that the capture 49 | expands to. Note that we are somewhat strict about the format of the 50 | capture rule, and will not handle all possible cases. 51 | """ 52 | if capture_name.startswith("self."): 53 | capture_name = "user." + capture_name[5:] 54 | try: 55 | # NB: [-1] because the last capture is the active one 56 | rule = registry.captures[capture_name][-1].rule.rule 57 | except Exception: 58 | app.notify("Error constructing spoken forms for graphemes") 59 | print(f"Error getting rule for capture {capture_name}") 60 | return 61 | rule = rule.strip() 62 | if rule.startswith("(") and rule.endswith(")"): 63 | rule = rule[1:-1] 64 | rule = rule.strip() 65 | components = re.split(r"\s*\|\s*", rule) 66 | for component in components: 67 | if component.startswith("<") and component.endswith(">"): 68 | yield from generate_lists_from_capture(component[1:-1]) 69 | elif component.startswith("{") and component.endswith("}"): 70 | component = component[1:-1] 71 | if component.startswith("self."): 72 | component = "user." + component[5:] 73 | yield component 74 | else: 75 | app.notify("Error constructing spoken forms for graphemes") 76 | print( 77 | f"Unexpected component {component} while processing rule {rule} for capture {capture_name}" 78 | ) 79 | 80 | 81 | def get_id_to_talon_list(list_name: str) -> dict[str, str]: 82 | """ 83 | Given the name of a Talon list, return that list 84 | """ 85 | try: 86 | # NB: [-1] because the last list is the active one 87 | return typing.cast(dict[str, str], registry.lists[list_name][-1]).copy() 88 | except Exception: 89 | app.notify(f"Error getting list {list_name}") 90 | return {} 91 | 92 | 93 | def talon_list_to_spoken_form_map( 94 | talon_list: dict[str, str], 95 | ) -> Mapping[str, list[str]]: 96 | """ 97 | Given a Talon list, return a mapping from the values in that 98 | list to the list of spoken forms that map to the given value. 99 | """ 100 | inverted_list: defaultdict[str, list[str]] = defaultdict(list) 101 | for key, value in talon_list.items(): 102 | inverted_list[value].append(key) 103 | 104 | return inverted_list 105 | -------------------------------------------------------------------------------- /src/cheatsheet/sections/actions.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | 3 | from ...actions.actions import ACTION_LIST_NAMES 4 | from ..get_list import ListItemDescriptor, get_raw_list, make_dict_readable 5 | 6 | 7 | def get_actions() -> list[ListItemDescriptor]: 8 | all_actions = {} 9 | for name in ACTION_LIST_NAMES: 10 | all_actions.update(get_raw_list(name)) 11 | 12 | multiple_target_action_names = [ 13 | "replaceWithTarget", 14 | "moveToTarget", 15 | "swapTargets", 16 | "applyFormatter", 17 | "callAsFunction", 18 | "wrapWithPairedDelimiter", 19 | "rewrap", 20 | "pasteFromClipboard", 21 | ] 22 | simple_actions = { 23 | f"{key} ": value 24 | for key, value in all_actions.items() 25 | if value not in multiple_target_action_names 26 | } 27 | complex_actions = { 28 | value: key 29 | for key, value in all_actions.items() 30 | if value in multiple_target_action_names 31 | } 32 | 33 | swap_connectives = list(get_raw_list("swap_connective").keys()) 34 | swap_connective = swap_connectives[0] if swap_connectives else None 35 | 36 | items = make_dict_readable( 37 | "action", 38 | simple_actions, 39 | { 40 | "editNewLineAfter": "Edit new line/scope after", 41 | "editNewLineBefore": "Edit new line/scope before", 42 | }, 43 | ) 44 | 45 | fixtures: dict[str, list[tuple[Callable, str]]] = { 46 | "replaceWithTarget": [ 47 | ( 48 | lambda value: f"{value} ", 49 | "Copy to ", 50 | ), 51 | ( 52 | lambda value: f"{value} ", 53 | "Insert copy of at cursor", 54 | ), 55 | ], 56 | "pasteFromClipboard": [ 57 | ( 58 | lambda value: f"{value} ", 59 | "Paste from clipboard at ", 60 | ) 61 | ], 62 | "moveToTarget": [ 63 | ( 64 | lambda value: f"{value} ", 65 | "Move to ", 66 | ), 67 | ( 68 | lambda value: f"{value} ", 69 | "Move to cursor position", 70 | ), 71 | ], 72 | "applyFormatter": [ 73 | ( 74 | lambda value: f"{value} at ", 75 | "Reformat as ", 76 | ) 77 | ], 78 | "callAsFunction": [ 79 | ( 80 | lambda value: f"{value} ", 81 | "Call on selection", 82 | ), 83 | ( 84 | lambda value: f"{value} on ", 85 | "Call on ", 86 | ), 87 | ], 88 | "wrapWithPairedDelimiter": [ 89 | ( 90 | lambda value: f" {value} ", 91 | "Wrap with ", 92 | ) 93 | ], 94 | "rewrap": [ 95 | ( 96 | lambda value: f" {value} ", 97 | "Rewrap with ", 98 | ) 99 | ], 100 | } 101 | 102 | if swap_connective: 103 | fixtures["swapTargets"] = [ 104 | ( 105 | lambda value: f"{value} {swap_connective} ", 106 | "Swap with ", 107 | ), 108 | ( 109 | lambda value: f"{value} {swap_connective} ", 110 | "Swap selection with ", 111 | ), 112 | ] 113 | 114 | for action_id, variations in fixtures.items(): 115 | if action_id not in complex_actions: 116 | continue 117 | action = complex_actions[action_id] 118 | items.append( 119 | { 120 | "id": action_id, 121 | "type": "action", 122 | "variations": [ 123 | { 124 | "spokenForm": callback(action), 125 | "description": description, 126 | } 127 | for callback, description in variations 128 | ], 129 | } 130 | ) 131 | 132 | return items 133 | -------------------------------------------------------------------------------- /src/fallback.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | 3 | from talon import actions 4 | 5 | from .versions import COMMAND_VERSION 6 | 7 | # This ensures that we remember to update fallback if the response payload changes 8 | assert COMMAND_VERSION == 7 9 | 10 | action_callbacks = { 11 | "setSelection": actions.skip, 12 | "setSelectionBefore": actions.edit.left, 13 | "setSelectionAfter": actions.edit.right, 14 | "copyToClipboard": actions.edit.copy, 15 | "cutToClipboard": actions.edit.cut, 16 | "pasteFromClipboard": actions.edit.paste, 17 | "clearAndSetSelection": actions.edit.delete, 18 | "remove": actions.edit.delete, 19 | "editNewLineBefore": actions.edit.line_insert_up, 20 | "editNewLineAfter": actions.edit.line_insert_down, 21 | "insertCopyAfter": actions.edit.line_clone, 22 | } 23 | 24 | modifier_callbacks = { 25 | "extendThroughStartOf.line": actions.user.select_line_start, 26 | "extendThroughEndOf.line": actions.user.select_line_end, 27 | "containingScope.document": actions.edit.select_all, 28 | "containingScope.paragraph": actions.edit.select_paragraph, 29 | "containingScope.line": actions.edit.select_line, 30 | "containingScope.token": actions.edit.select_word, 31 | } 32 | 33 | 34 | def call_as_function(callee: str): 35 | wrap_with_paired_delimiter(f"{callee}(", ")") 36 | 37 | 38 | def wrap_with_paired_delimiter(left: str, right: str): 39 | selected = actions.edit.selected_text() 40 | actions.insert(f"{left}{selected}{right}") 41 | for _ in right: 42 | actions.edit.left() 43 | 44 | 45 | def containing_token_if_empty(): 46 | if actions.edit.selected_text() == "": 47 | actions.edit.select_word() 48 | 49 | 50 | def perform_fallback(fallback: dict): 51 | try: 52 | modifier_callbacks = get_modifier_callbacks(fallback) 53 | action_callback = get_action_callback(fallback) 54 | for callback in reversed(modifier_callbacks): 55 | callback() 56 | return action_callback() 57 | except ValueError as ex: 58 | actions.app.notify(str(ex)) 59 | raise ex 60 | 61 | 62 | def get_action_callback(fallback: dict) -> Callable: 63 | action = fallback["action"] 64 | 65 | if action in action_callbacks: 66 | return action_callbacks[action] 67 | 68 | match action: 69 | case "insert": 70 | return lambda: actions.insert(fallback["text"]) 71 | case "callAsFunction": 72 | return lambda: call_as_function(fallback["callee"]) 73 | case "wrapWithPairedDelimiter": 74 | return lambda: wrap_with_paired_delimiter( 75 | fallback["left"], fallback["right"] 76 | ) 77 | case "getText": 78 | return lambda: [actions.edit.selected_text()] 79 | case "findInWorkspace": 80 | return lambda: actions.user.find_everywhere(actions.edit.selected_text()) 81 | case "findInDocument": 82 | return lambda: actions.edit.find(actions.edit.selected_text()) 83 | 84 | raise ValueError(f"Unknown Cursorless fallback action: {action}") 85 | 86 | 87 | def get_modifier_callbacks(fallback: dict) -> list[Callable]: 88 | return [get_modifier_callback(modifier) for modifier in fallback["modifiers"]] 89 | 90 | 91 | def get_modifier_callback(modifier: dict) -> Callable: 92 | modifier_type = modifier["type"] 93 | 94 | match modifier_type: 95 | case "containingTokenIfEmpty": 96 | return containing_token_if_empty 97 | case "containingScope": 98 | scope_type_type = modifier["scopeType"]["type"] 99 | return get_simple_modifier_callback(f"{modifier_type}.{scope_type_type}") 100 | case "preferredScope": 101 | scope_type_type = modifier["scopeType"]["type"] 102 | return get_simple_modifier_callback(f"containingScope.{scope_type_type}") 103 | case "extendThroughStartOf": 104 | if "modifiers" not in modifier: 105 | return get_simple_modifier_callback(f"{modifier_type}.line") 106 | case "extendThroughEndOf": 107 | if "modifiers" not in modifier: 108 | return get_simple_modifier_callback(f"{modifier_type}.line") 109 | 110 | raise ValueError(f"Unknown Cursorless fallback modifier: {modifier_type}") 111 | 112 | 113 | def get_simple_modifier_callback(key: str) -> Callable: 114 | try: 115 | return modifier_callbacks[key] 116 | except KeyError: 117 | raise ValueError(f"Unknown Cursorless fallback modifier: {key}") 118 | -------------------------------------------------------------------------------- /src/vendor/jstyleson.py: -------------------------------------------------------------------------------- 1 | # From https://github.com/linjackson78/jstyleson/blob/8c47cc9e665b3b1744cccfaa7a650de5f3c575dd/jstyleson.py 2 | # License https://github.com/linjackson78/jstyleson/blob/8c47cc9e665b3b1744cccfaa7a650de5f3c575dd/LICENSE 3 | import json 4 | 5 | 6 | def dispose(json_str): 7 | """Clear all comments in json_str. 8 | 9 | Clear JS-style comments like // and /**/ in json_str. 10 | Accept a str or unicode as input. 11 | 12 | Args: 13 | json_str: A json string of str or unicode to clean up comment 14 | 15 | Returns: 16 | str: The str without comments (or unicode if you pass in unicode) 17 | """ 18 | result_str = list(json_str) 19 | escaped = False 20 | normal = True 21 | sl_comment = False 22 | ml_comment = False 23 | quoted = False 24 | 25 | a_step_from_comment = False 26 | a_step_from_comment_away = False 27 | 28 | former_index = None 29 | 30 | for index, char in enumerate(json_str): 31 | if escaped: # We have just met a '\' 32 | escaped = False 33 | continue 34 | 35 | if a_step_from_comment: # We have just met a '/' 36 | if char != "/" and char != "*": 37 | a_step_from_comment = False 38 | normal = True 39 | continue 40 | 41 | if a_step_from_comment_away: # We have just met a '*' 42 | if char != "/": 43 | a_step_from_comment_away = False 44 | 45 | if char == '"': 46 | if normal and not escaped: 47 | # We are now in a string 48 | quoted = True 49 | normal = False 50 | elif quoted and not escaped: 51 | # We are now out of a string 52 | quoted = False 53 | normal = True 54 | 55 | elif char == "\\": 56 | # '\' should not take effect in comment 57 | if normal or quoted: 58 | escaped = True 59 | 60 | elif char == "/": 61 | if a_step_from_comment: 62 | # Now we are in single line comment 63 | a_step_from_comment = False 64 | sl_comment = True 65 | normal = False 66 | former_index = index - 1 67 | elif a_step_from_comment_away: 68 | # Now we are out of comment 69 | a_step_from_comment_away = False 70 | normal = True 71 | ml_comment = False 72 | for i in range(former_index, index + 1): 73 | result_str[i] = "" 74 | 75 | elif normal: 76 | # Now we are just one step away from comment 77 | a_step_from_comment = True 78 | normal = False 79 | 80 | elif char == "*": 81 | if a_step_from_comment: 82 | # We are now in multi-line comment 83 | a_step_from_comment = False 84 | ml_comment = True 85 | normal = False 86 | former_index = index - 1 87 | elif ml_comment: 88 | a_step_from_comment_away = True 89 | elif char == "\n": 90 | if sl_comment: 91 | sl_comment = False 92 | normal = True 93 | for i in range(former_index, index + 1): 94 | result_str[i] = "" 95 | elif char == "]" or char == "}": 96 | if normal: 97 | _remove_last_comma(result_str, index) 98 | 99 | # To remove single line comment which is the last line of json 100 | if sl_comment: 101 | sl_comment = False 102 | normal = True 103 | for i in range(former_index, len(json_str)): 104 | result_str[i] = "" 105 | 106 | # Show respect to original input if we are in python2 107 | return ("" if isinstance(json_str, str) else "").join(result_str) 108 | 109 | 110 | # There may be performance suffer backtracking the last comma 111 | def _remove_last_comma(str_list, before_index): 112 | i = before_index - 1 113 | while str_list[i].isspace() or not str_list[i]: 114 | i -= 1 115 | 116 | # This is the first none space char before before_index 117 | if str_list[i] == ",": 118 | str_list[i] = "" 119 | 120 | 121 | # Below are just some wrapper function around the standard json module. 122 | 123 | 124 | def loads(text, **kwargs): 125 | return json.loads(dispose(text), **kwargs) 126 | 127 | 128 | def load(fp, **kwargs): 129 | return loads(fp.read(), **kwargs) 130 | 131 | 132 | def dumps(obj, **kwargs): 133 | return json.dumps(obj, **kwargs) 134 | 135 | 136 | def dump(obj, fp, **kwargs): 137 | json.dump(obj, fp, **kwargs) 138 | -------------------------------------------------------------------------------- /src/snippets/snippets.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Union 2 | 3 | from talon import Context, Module, actions 4 | 5 | from ..targets.target_types import ( 6 | CursorlessDestination, 7 | CursorlessTarget, 8 | ImplicitDestination, 9 | ) 10 | from .snippet_types import ( 11 | CustomInsertionSnippet, 12 | CustomWrapperSnippet, 13 | InsertSnippetAction, 14 | ScopeType, 15 | WrapperSnippetAction, 16 | to_scope_types, 17 | ) 18 | from .snippets_get import ( 19 | get_insertion_snippet, 20 | get_list_insertion_snippet, 21 | get_list_wrapper_snippet, 22 | get_wrapper_snippet, 23 | ) 24 | 25 | mod = Module() 26 | ctx = Context() 27 | 28 | ctx.matches = r""" 29 | tag: user.cursorless 30 | and not tag: user.code_language_forced 31 | """ 32 | 33 | mod.list("cursorless_insert_snippet_action", desc="Cursorless insert snippet action") 34 | 35 | 36 | @mod.action_class 37 | class Actions: 38 | def cursorless_insert_snippet( 39 | body: str, # pyright: ignore [reportGeneralTypeIssues] 40 | destination: CursorlessDestination = ImplicitDestination(), 41 | scope_type: Optional[Union[str, list[str]]] = None, 42 | ): 43 | """Cursorless: Insert custom snippet """ 44 | snippet = CustomInsertionSnippet( 45 | body, 46 | to_scope_types(scope_type) if scope_type else None, 47 | languages=None, 48 | substitutions=None, 49 | ) 50 | action = InsertSnippetAction(snippet, destination) 51 | actions.user.private_cursorless_command_and_wait(action) 52 | 53 | def cursorless_wrap_with_snippet( 54 | body: str, # pyright: ignore [reportGeneralTypeIssues] 55 | target: CursorlessTarget, 56 | variable_name: Optional[str] = None, 57 | scope: Optional[str] = None, 58 | ): 59 | """Cursorless: Wrap target with custom snippet """ 60 | snippet = CustomWrapperSnippet( 61 | body, 62 | variable_name, 63 | ScopeType(scope) if scope else None, 64 | languages=None, 65 | ) 66 | action = WrapperSnippetAction(snippet, target) 67 | actions.user.private_cursorless_command_and_wait(action) 68 | 69 | # These actions use a single custom snippets since a language mode is forced 70 | 71 | def private_cursorless_insert_community_snippet( 72 | name: str, # pyright: ignore [reportGeneralTypeIssues] 73 | destination: CursorlessDestination, 74 | ): 75 | """Cursorless: Insert community snippet """ 76 | action = InsertSnippetAction( 77 | get_insertion_snippet(name), 78 | destination, 79 | ) 80 | actions.user.private_cursorless_command_and_wait(action) 81 | 82 | def private_cursorless_wrap_with_community_snippet( 83 | name: str, # pyright: ignore [reportGeneralTypeIssues] 84 | target: CursorlessTarget, 85 | ): 86 | """Cursorless: Wrap target with community snippet """ 87 | action = WrapperSnippetAction( 88 | get_wrapper_snippet(name), 89 | target, 90 | ) 91 | actions.user.private_cursorless_command_and_wait(action) 92 | 93 | 94 | @ctx.action_class("user") 95 | class UserActions: 96 | # Since we don't have a forced language mode, these actions send all the snippets. 97 | # (note that this is the default mode of action, as most of the time the user will not 98 | # have a forced language mode) 99 | 100 | def insert_snippet_by_name( 101 | name: str, # pyright: ignore [reportGeneralTypeIssues] 102 | # Don't add optional: we need to match the type in community 103 | substitutions: dict[str, str] = None, # type: ignore 104 | ): 105 | action = InsertSnippetAction( 106 | get_list_insertion_snippet(name, substitutions), 107 | ImplicitDestination(), 108 | ) 109 | actions.user.private_cursorless_command_and_wait(action) 110 | 111 | def private_cursorless_insert_community_snippet( 112 | name: str, # pyright: ignore [reportGeneralTypeIssues] 113 | destination: CursorlessDestination, 114 | ): 115 | action = InsertSnippetAction( 116 | get_list_insertion_snippet(name), 117 | destination, 118 | ) 119 | actions.user.private_cursorless_command_and_wait(action) 120 | 121 | def private_cursorless_wrap_with_community_snippet( 122 | name: str, # pyright: ignore [reportGeneralTypeIssues] 123 | target: CursorlessTarget, 124 | ): 125 | action = WrapperSnippetAction( 126 | get_list_wrapper_snippet(name), 127 | target, 128 | ) 129 | actions.user.private_cursorless_command_and_wait(action) 130 | -------------------------------------------------------------------------------- /src/actions/actions.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Union 2 | 3 | from talon import Module, actions 4 | 5 | from ..targets.target_types import ( 6 | CursorlessDestination, 7 | CursorlessExplicitTarget, 8 | CursorlessTarget, 9 | ImplicitDestination, 10 | ) 11 | from .bring_move import BringMoveTargets 12 | from .execute_command import cursorless_execute_command_action 13 | from .homophones import cursorless_homophones_action 14 | from .replace import cursorless_replace_action 15 | 16 | mod = Module() 17 | 18 | mod.list( 19 | "cursorless_simple_action", 20 | desc="Cursorless internal: simple actions", 21 | ) 22 | 23 | mod.list( 24 | "cursorless_callback_action", 25 | desc="Cursorless internal: actions implemented via a callback function", 26 | ) 27 | 28 | mod.list( 29 | "cursorless_custom_action", 30 | desc="Cursorless internal: user-defined custom actions", 31 | ) 32 | 33 | mod.list( 34 | "cursorless_experimental_action", 35 | desc="Cursorless internal: experimental actions", 36 | ) 37 | 38 | ACTION_LIST_NAMES = [ 39 | "simple_action", 40 | "callback_action", 41 | "paste_action", 42 | "bring_move_action", 43 | "swap_action", 44 | "wrap_action", 45 | "insert_snippet_action", 46 | "reformat_action", 47 | "call_action", 48 | "experimental_action", 49 | "custom_action", 50 | ] 51 | 52 | callback_actions: dict[str, Callable[[CursorlessExplicitTarget], None]] = { 53 | "nextHomophone": cursorless_homophones_action, 54 | } 55 | 56 | # Don't wait for these actions to finish, usually because they hang on some kind of user interaction 57 | no_wait_actions = [ 58 | "rename", 59 | ] 60 | 61 | # These are actions that we don't wait for, but still want to have a post action sleep 62 | no_wait_actions_post_sleep = { 63 | "rename": 0.3, 64 | } 65 | 66 | 67 | @mod.capture( 68 | rule=( 69 | "{user.cursorless_simple_action} |" 70 | "{user.cursorless_experimental_action} |" 71 | "{user.cursorless_callback_action} |" 72 | "{user.cursorless_call_action} |" 73 | "{user.cursorless_custom_action}" 74 | ) 75 | ) 76 | def cursorless_action_or_ide_command(m) -> dict[str, str]: 77 | try: 78 | value = m.cursorless_custom_action 79 | type = "ide_command" 80 | except AttributeError: 81 | value = m[0] 82 | type = "cursorless_action" 83 | return { 84 | "value": value, 85 | "type": type, 86 | } 87 | 88 | 89 | @mod.action_class 90 | class Actions: 91 | def cursorless_command(action_name: str, target: CursorlessExplicitTarget): # pyright: ignore [reportGeneralTypeIssues] 92 | """Perform cursorless command on target""" 93 | if action_name in callback_actions: 94 | callback_actions[action_name](target) 95 | elif action_name in ["replaceWithTarget", "moveToTarget"]: 96 | actions.user.private_cursorless_bring_move( 97 | action_name, BringMoveTargets(target, ImplicitDestination()) 98 | ) 99 | elif action_name == "callAsFunction": 100 | actions.user.private_cursorless_call(target) 101 | elif action_name == "generateSnippet": 102 | actions.user.private_cursorless_generate_snippet_action(target) 103 | elif action_name in no_wait_actions: 104 | action = {"name": action_name, "target": target} 105 | actions.user.private_cursorless_command_no_wait(action) 106 | if action_name in no_wait_actions_post_sleep: 107 | actions.sleep(no_wait_actions_post_sleep[action_name]) 108 | else: 109 | action = {"name": action_name, "target": target} 110 | actions.user.private_cursorless_command_and_wait(action) 111 | 112 | def cursorless_vscode_command(command_id: str, target: CursorlessTarget): # pyright: ignore [reportGeneralTypeIssues] 113 | """ 114 | Perform vscode command on cursorless target 115 | 116 | Deprecated: prefer `cursorless_ide_command` 117 | """ 118 | return actions.user.cursorless_ide_command(command_id, target) 119 | 120 | def cursorless_ide_command(command_id: str, target: CursorlessTarget): # pyright: ignore [reportGeneralTypeIssues] 121 | """Perform ide command on cursorless target""" 122 | return cursorless_execute_command_action(command_id, target) 123 | 124 | def cursorless_insert( 125 | destination: CursorlessDestination, # pyright: ignore [reportGeneralTypeIssues] 126 | text: Union[str, list[str]], 127 | ): 128 | """Perform text insertion on Cursorless destination""" 129 | if isinstance(text, str): 130 | text = [text] 131 | cursorless_replace_action(destination, text) 132 | 133 | def private_cursorless_action_or_ide_command( 134 | instruction: dict[str, str], # pyright: ignore [reportGeneralTypeIssues] 135 | target: CursorlessTarget, 136 | ): 137 | """Perform cursorless action or ide command on target (internal use only)""" 138 | type = instruction["type"] 139 | value = instruction["value"] 140 | if type == "cursorless_action": 141 | actions.user.cursorless_command(value, target) 142 | elif type == "ide_command": 143 | actions.user.cursorless_ide_command(value, target) 144 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Welcome to Cursorless!

2 |

3 | 4 | Rating 5 | 6 | 7 | Documentation 8 | 9 | 10 | Tests 11 | 12 | 13 | Maintenance 14 | 15 | 16 | License: MIT 17 | 18 |

19 | 20 | Cursorless is a spoken language for structural code editing, enabling developers to code by voice at speeds not possible with a keyboard. Cursorless decorates every token on the screen and defines a spoken language for rapid, high-level semantic manipulation of structured text. 21 | 22 | This repository holds the Talon side of Cursorless. If you've arrived here as part of the [Cursorless installation process](https://www.cursorless.org/docs/user/installation/), then you're in the right place! 23 | 24 | # Installation 25 | 26 | See [Cursorless Installation](https://www.cursorless.org/docs/user/installation/#installing-the-talon-side) ("Installing the Talon side") for how to clone this repo into your Talon user folder. 27 | 28 | # Contributing 29 | 30 | If you're looking to improve Cursorless, note that Cursorless is now maintained as a monorepo, so if you're here in a browser, and your address bar points to https://github.com/cursorless-dev/cursorless-talon, then you're probably in the wrong place. The monorepo is hosted at [`cursorless`](https://github.com/cursorless-dev/cursorless), and the source of truth for these talon files is in the [`cursorless-talon`](https://github.com/cursorless-dev/cursorless/tree/main/cursorless-talon) subdirectory. 31 | 32 | See [the contributor docs](https://www.cursorless.org/docs/contributing/) to get started. 33 | 34 | # Cursorless talon 35 | 36 | This directory contains the talon side of [Cursorless](https://marketplace.visualstudio.com/items?itemName=pokey.cursorless). 37 | -------------------------------------------------------------------------------- /src/vendor/inflection.py: -------------------------------------------------------------------------------- 1 | # From https://github.com/jpvanhal/inflection/blob/b00d4d348b32ef5823221b20ee4cbd1d2d924462/inflection/__init__.py 2 | # License https://github.com/jpvanhal/inflection/blob/b00d4d348b32ef5823221b20ee4cbd1d2d924462/LICENSE 3 | import re 4 | 5 | PLURALS = [ 6 | (r"(?i)(quiz)$", r"\1zes"), 7 | (r"(?i)^(oxen)$", r"\1"), 8 | (r"(?i)^(ox)$", r"\1en"), 9 | (r"(?i)(m|l)ice$", r"\1ice"), 10 | (r"(?i)(m|l)ouse$", r"\1ice"), 11 | (r"(?i)(passer)s?by$", r"\1sby"), 12 | (r"(?i)(matr|vert|ind)(?:ix|ex)$", r"\1ices"), 13 | (r"(?i)(x|ch|ss|sh)$", r"\1es"), 14 | (r"(?i)([^aeiouy]|qu)y$", r"\1ies"), 15 | (r"(?i)(hive)$", r"\1s"), 16 | (r"(?i)([lr])f$", r"\1ves"), 17 | (r"(?i)([^f])fe$", r"\1ves"), 18 | (r"(?i)sis$", "ses"), 19 | (r"(?i)([ti])a$", r"\1a"), 20 | (r"(?i)([ti])um$", r"\1a"), 21 | (r"(?i)(buffal|potat|tomat)o$", r"\1oes"), 22 | (r"(?i)(bu)s$", r"\1ses"), 23 | (r"(?i)(alias|status)$", r"\1es"), 24 | (r"(?i)(octop|vir)i$", r"\1i"), 25 | (r"(?i)(octop|vir)us$", r"\1i"), 26 | (r"(?i)^(ax|test)is$", r"\1es"), 27 | (r"(?i)s$", "s"), 28 | (r"$", "s"), 29 | ] 30 | 31 | 32 | SINGULARS = [ 33 | (r"(?i)(database)s$", r"\1"), 34 | (r"(?i)(quiz)zes$", r"\1"), 35 | (r"(?i)(matr)ices$", r"\1ix"), 36 | (r"(?i)(vert|ind)ices$", r"\1ex"), 37 | (r"(?i)(passer)sby$", r"\1by"), 38 | (r"(?i)^(ox)en", r"\1"), 39 | (r"(?i)(alias|status)(es)?$", r"\1"), 40 | (r"(?i)(octop|vir)(us|i)$", r"\1us"), 41 | (r"(?i)^(a)x[ie]s$", r"\1xis"), 42 | (r"(?i)(cris|test)(is|es)$", r"\1is"), 43 | (r"(?i)(shoe)s$", r"\1"), 44 | (r"(?i)(o)es$", r"\1"), 45 | (r"(?i)(bus)(es)?$", r"\1"), 46 | (r"(?i)(m|l)ice$", r"\1ouse"), 47 | (r"(?i)(x|ch|ss|sh)es$", r"\1"), 48 | (r"(?i)(m)ovies$", r"\1ovie"), 49 | (r"(?i)(s)eries$", r"\1eries"), 50 | (r"(?i)([^aeiouy]|qu)ies$", r"\1y"), 51 | (r"(?i)([lr])ves$", r"\1f"), 52 | (r"(?i)(tive)s$", r"\1"), 53 | (r"(?i)(hive)s$", r"\1"), 54 | (r"(?i)([^f])ves$", r"\1fe"), 55 | (r"(?i)(t)he(sis|ses)$", r"\1hesis"), 56 | (r"(?i)(s)ynop(sis|ses)$", r"\1ynopsis"), 57 | (r"(?i)(p)rogno(sis|ses)$", r"\1rognosis"), 58 | (r"(?i)(p)arenthe(sis|ses)$", r"\1arenthesis"), 59 | (r"(?i)(d)iagno(sis|ses)$", r"\1iagnosis"), 60 | (r"(?i)(b)a(sis|ses)$", r"\1asis"), 61 | (r"(?i)(a)naly(sis|ses)$", r"\1nalysis"), 62 | (r"(?i)([ti])a$", r"\1um"), 63 | (r"(?i)(n)ews$", r"\1ews"), 64 | (r"(?i)(ss)$", r"\1"), 65 | (r"(?i)s$", ""), 66 | ] 67 | 68 | 69 | UNCOUNTABLES = { 70 | "equipment", 71 | "fish", 72 | "information", 73 | "jeans", 74 | "money", 75 | "rice", 76 | "series", 77 | "sheep", 78 | "species", 79 | } 80 | 81 | 82 | def _irregular(singular: str, plural: str) -> None: 83 | """ 84 | A convenience function to add appropriate rules to plurals and singular 85 | for irregular words. 86 | 87 | :param singular: irregular word in singular form 88 | :param plural: irregular word in plural form 89 | """ 90 | 91 | def caseinsensitive(string: str) -> str: 92 | return "".join("[" + char + char.upper() + "]" for char in string) 93 | 94 | if singular[0].upper() == plural[0].upper(): 95 | PLURALS.insert(0, (rf"(?i)({singular[0]}){singular[1:]}$", r"\1" + plural[1:])) 96 | PLURALS.insert(0, (rf"(?i)({plural[0]}){plural[1:]}$", r"\1" + plural[1:])) 97 | SINGULARS.insert(0, (rf"(?i)({plural[0]}){plural[1:]}$", r"\1" + singular[1:])) 98 | else: 99 | PLURALS.insert( 100 | 0, 101 | ( 102 | rf"{singular[0].upper()}{caseinsensitive(singular[1:])}$", 103 | plural[0].upper() + plural[1:], 104 | ), 105 | ) 106 | PLURALS.insert( 107 | 0, 108 | ( 109 | rf"{singular[0].lower()}{caseinsensitive(singular[1:])}$", 110 | plural[0].lower() + plural[1:], 111 | ), 112 | ) 113 | PLURALS.insert( 114 | 0, 115 | ( 116 | rf"{plural[0].upper()}{caseinsensitive(plural[1:])}$", 117 | plural[0].upper() + plural[1:], 118 | ), 119 | ) 120 | PLURALS.insert( 121 | 0, 122 | ( 123 | rf"{plural[0].lower()}{caseinsensitive(plural[1:])}$", 124 | plural[0].lower() + plural[1:], 125 | ), 126 | ) 127 | SINGULARS.insert( 128 | 0, 129 | ( 130 | rf"{plural[0].upper()}{caseinsensitive(plural[1:])}$", 131 | singular[0].upper() + singular[1:], 132 | ), 133 | ) 134 | SINGULARS.insert( 135 | 0, 136 | ( 137 | rf"{plural[0].lower()}{caseinsensitive(plural[1:])}$", 138 | singular[0].lower() + singular[1:], 139 | ), 140 | ) 141 | 142 | 143 | def pluralize(word: str) -> str: 144 | """ 145 | Return the plural form of a word. 146 | 147 | Examples:: 148 | 149 | >>> pluralize("posts") 150 | 'posts' 151 | >>> pluralize("octopus") 152 | 'octopi' 153 | >>> pluralize("sheep") 154 | 'sheep' 155 | >>> pluralize("CamelOctopus") 156 | 'CamelOctopi' 157 | 158 | """ 159 | if not word or word.lower() in UNCOUNTABLES: 160 | return word 161 | else: 162 | for rule, replacement in PLURALS: 163 | if re.search(rule, word): 164 | return re.sub(rule, replacement, word) 165 | return word 166 | 167 | 168 | _irregular("person", "people") 169 | _irregular("man", "men") 170 | _irregular("human", "humans") 171 | _irregular("child", "children") 172 | _irregular("sex", "sexes") 173 | _irregular("move", "moves") 174 | _irregular("cow", "kine") 175 | _irregular("zombie", "zombies") 176 | -------------------------------------------------------------------------------- /src/marks/decorated_mark.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Any 3 | 4 | from talon import Module, actions, cron, fs 5 | 6 | from ..csv_overrides import init_csv_and_watch_changes 7 | from .mark_types import DecoratedSymbol 8 | 9 | mod = Module() 10 | 11 | mod.list("cursorless_hat_color", desc="Supported hat colors for cursorless") 12 | mod.list("cursorless_hat_shape", desc="Supported hat shapes for cursorless") 13 | mod.list( 14 | "cursorless_unknown_symbol", 15 | "This list contains the term that is used to refer to any unknown symbol", 16 | ) 17 | 18 | 19 | @mod.capture(rule=" | {user.cursorless_unknown_symbol}") 20 | def cursorless_grapheme(m) -> str: 21 | try: 22 | return m.any_alphanumeric_key 23 | except AttributeError: 24 | # NB: This represents unknown char in Unicode. It will be translated 25 | # to "[unk]" by Cursorless extension. 26 | return "\ufffd" 27 | 28 | 29 | @mod.capture( 30 | rule="[{user.cursorless_hat_color}] [{user.cursorless_hat_shape}] " 31 | ) 32 | def cursorless_decorated_symbol(m) -> DecoratedSymbol: 33 | """A decorated symbol""" 34 | hat_color: str = getattr(m, "cursorless_hat_color", "default") 35 | try: 36 | hat_style_name = f"{hat_color}-{m.cursorless_hat_shape}" 37 | except AttributeError: 38 | hat_style_name = hat_color 39 | return { 40 | "type": "decoratedSymbol", 41 | "symbolColor": hat_style_name, 42 | "character": m.cursorless_grapheme, 43 | } 44 | 45 | 46 | DEFAULT_COLOR_ENABLEMENT = { 47 | "blue": True, 48 | "green": True, 49 | "red": True, 50 | "pink": True, 51 | "yellow": True, 52 | "userColor1": False, 53 | "userColor2": False, 54 | } 55 | 56 | DEFAULT_SHAPE_ENABLEMENT = { 57 | "ex": False, 58 | "fox": False, 59 | "wing": False, 60 | "hole": False, 61 | "frame": False, 62 | "curve": False, 63 | "eye": False, 64 | "play": False, 65 | "bolt": False, 66 | "crosshairs": False, 67 | } 68 | 69 | # Fall back to full enablement in case of error reading settings file 70 | # NB: This won't actually enable all the shapes and colors extension-side. 71 | # It'll just make it so that the user can say them whether or not they are enabled 72 | FALLBACK_SHAPE_ENABLEMENT = { 73 | "ex": True, 74 | "fox": True, 75 | "wing": True, 76 | "hole": True, 77 | "frame": True, 78 | "curve": True, 79 | "eye": True, 80 | "play": True, 81 | "bolt": True, 82 | "crosshairs": True, 83 | } 84 | FALLBACK_COLOR_ENABLEMENT = DEFAULT_COLOR_ENABLEMENT 85 | 86 | unsubscribe_hat_styles: Any = None 87 | 88 | 89 | def setup_hat_styles_csv(hat_colors: dict[str, str], hat_shapes: dict[str, str]): 90 | global unsubscribe_hat_styles 91 | 92 | ( 93 | color_enablement_settings, 94 | is_color_error, 95 | ) = actions.user.vscode_get_setting_with_fallback( 96 | "cursorless.hatEnablement.colors", 97 | default_value={}, 98 | fallback_value=FALLBACK_COLOR_ENABLEMENT, 99 | fallback_message="Error finding color enablement; falling back to full enablement", 100 | ) 101 | 102 | ( 103 | shape_enablement_settings, 104 | is_shape_error, 105 | ) = actions.user.vscode_get_setting_with_fallback( 106 | "cursorless.hatEnablement.shapes", 107 | default_value={}, 108 | fallback_value=FALLBACK_SHAPE_ENABLEMENT, 109 | fallback_message="Error finding shape enablement; falling back to full enablement", 110 | ) 111 | 112 | color_enablement = { 113 | **DEFAULT_COLOR_ENABLEMENT, 114 | **color_enablement_settings, 115 | } 116 | shape_enablement = { 117 | **DEFAULT_SHAPE_ENABLEMENT, 118 | **shape_enablement_settings, 119 | } 120 | 121 | active_hat_colors = { 122 | spoken_form: value 123 | for spoken_form, value in hat_colors.items() 124 | if color_enablement[value] 125 | } 126 | active_hat_shapes = { 127 | spoken_form: value 128 | for spoken_form, value in hat_shapes.items() 129 | if shape_enablement[value] 130 | } 131 | 132 | if unsubscribe_hat_styles is not None: 133 | unsubscribe_hat_styles() 134 | 135 | unsubscribe_hat_styles = init_csv_and_watch_changes( 136 | "hat_styles.csv", 137 | { 138 | "hat_color": active_hat_colors, 139 | "hat_shape": active_hat_shapes, 140 | }, 141 | extra_ignored_values=[*hat_colors.values(), *hat_shapes.values()], 142 | no_update_file=is_shape_error or is_color_error, 143 | ) 144 | 145 | if is_shape_error or is_color_error: 146 | actions.app.notify("Error reading vscode settings. Restart talon; see log") 147 | 148 | 149 | fast_reload_job = None 150 | slow_reload_job = None 151 | 152 | 153 | def init_hats(hat_colors: dict[str, str], hat_shapes: dict[str, str]): 154 | setup_hat_styles_csv(hat_colors, hat_shapes) 155 | 156 | vscode_settings_path: Path | None = None 157 | 158 | try: 159 | vscode_settings_path = actions.user.vscode_settings_path().resolve() 160 | except Exception as ex: 161 | print(ex) 162 | 163 | def on_watch(path, flags): 164 | global fast_reload_job, slow_reload_job 165 | cron.cancel(fast_reload_job) 166 | cron.cancel(slow_reload_job) 167 | fast_reload_job = cron.after( 168 | "500ms", lambda: setup_hat_styles_csv(hat_colors, hat_shapes) 169 | ) 170 | slow_reload_job = cron.after( 171 | "10s", lambda: setup_hat_styles_csv(hat_colors, hat_shapes) 172 | ) 173 | 174 | if vscode_settings_path is not None: 175 | fs.watch(vscode_settings_path, on_watch) 176 | 177 | def unsubscribe(): 178 | if vscode_settings_path is not None: 179 | fs.unwatch(vscode_settings_path, on_watch) 180 | if unsubscribe_hat_styles is not None: 181 | unsubscribe_hat_styles() 182 | 183 | return unsubscribe 184 | -------------------------------------------------------------------------------- /src/cheatsheet/cheat_sheet.py: -------------------------------------------------------------------------------- 1 | import webbrowser 2 | from pathlib import Path 3 | 4 | from talon import Context, Module, actions, app 5 | 6 | from .get_list import get_list, get_lists 7 | from .sections.actions import get_actions 8 | from .sections.compound_targets import get_compound_targets 9 | from .sections.destinations import get_destinations 10 | from .sections.get_scope_visualizer import get_scope_visualizer 11 | from .sections.modifiers import get_modifiers 12 | from .sections.scopes import get_scopes 13 | from .sections.special_marks import get_special_marks 14 | from .sections.tutorial import get_tutorial_entries 15 | 16 | mod = Module() 17 | ctx = Context() 18 | ctx.matches = r""" 19 | tag: user.cursorless 20 | """ 21 | 22 | instructions_url = "https://www.cursorless.org/docs/" 23 | 24 | 25 | @mod.action_class 26 | class Actions: 27 | def private_cursorless_cheat_sheet_show_html(): 28 | """Show new cursorless html cheat sheet""" 29 | app.notify( 30 | 'Please first focus an app that supports cursorless, eg say "focus code"' 31 | ) 32 | 33 | def private_cursorless_cheat_sheet_update_json(): 34 | """Update default cursorless cheatsheet json (for developer use only)""" 35 | app.notify( 36 | 'Please first focus an app that supports cursorless, eg say "focus code"' 37 | ) 38 | 39 | def private_cursorless_open_instructions(): 40 | """Open web page with cursorless instructions""" 41 | actions.user.private_cursorless_notify_docs_opened() 42 | webbrowser.open(instructions_url) 43 | 44 | 45 | @ctx.action_class("user") 46 | class CursorlessActions: 47 | def private_cursorless_cheat_sheet_show_html(): 48 | """Show cursorless html cheat sheet""" 49 | # On Linux browsers installed using snap can't open files in a hidden directory 50 | if app.platform == "linux": 51 | cheatsheet_out_dir = cheatsheet_dir_linux() 52 | cheatsheet_filename = "cursorless-cheatsheet.html" 53 | else: 54 | cheatsheet_out_dir = Path.home() / ".cursorless" 55 | cheatsheet_filename = "cheatsheet.html" 56 | 57 | cheatsheet_out_dir.mkdir(parents=True, exist_ok=True) 58 | cheatsheet_out_path = cheatsheet_out_dir / cheatsheet_filename 59 | actions.user.private_cursorless_run_rpc_command_and_wait( 60 | "cursorless.showCheatsheet", 61 | { 62 | "version": 0, 63 | "spokenFormInfo": cursorless_cheat_sheet_get_json(), 64 | "outputPath": str(cheatsheet_out_path), 65 | }, 66 | ) 67 | webbrowser.open(cheatsheet_out_path.as_uri()) 68 | 69 | def private_cursorless_cheat_sheet_update_json(): 70 | """Update default cursorless cheatsheet json (for developer use only)""" 71 | actions.user.private_cursorless_run_rpc_command_and_wait( 72 | "cursorless.internal.updateCheatsheetDefaults", 73 | cursorless_cheat_sheet_get_json(), 74 | ) 75 | 76 | 77 | def cheatsheet_dir_linux() -> Path: 78 | """Get cheatsheet directory for Linux""" 79 | try: 80 | # 1. Get users actual document directory 81 | import platformdirs # pyright: ignore [reportMissingImports] 82 | 83 | return Path(platformdirs.user_documents_dir()) 84 | except Exception: 85 | # 2. Look for a documents directory in user home 86 | user_documents_dir = Path.home() / "Documents" 87 | if user_documents_dir.is_dir(): 88 | return user_documents_dir 89 | 90 | # 3. Fall back to user home 91 | return Path.home() 92 | 93 | 94 | def cursorless_cheat_sheet_get_json(): 95 | """Get cursorless cheat sheet json""" 96 | return { 97 | "sections": [ 98 | { 99 | "name": "Actions", 100 | "id": "actions", 101 | "items": get_actions(), 102 | }, 103 | { 104 | "name": "Destinations", 105 | "id": "destinations", 106 | "items": get_destinations(), 107 | }, 108 | { 109 | "name": "Scopes", 110 | "id": "scopes", 111 | "items": get_scopes(), 112 | }, 113 | { 114 | "name": "Scope visualizer", 115 | "id": "scopeVisualizer", 116 | "items": get_scope_visualizer(), 117 | }, 118 | { 119 | "name": "Modifiers", 120 | "id": "modifiers", 121 | "items": get_modifiers(), 122 | }, 123 | { 124 | "name": "Paired delimiters", 125 | "id": "pairedDelimiters", 126 | "items": get_lists( 127 | [ 128 | "wrapper_only_paired_delimiter", 129 | "wrapper_selectable_paired_delimiter", 130 | "selectable_only_paired_delimiter", 131 | "surrounding_pair_scope_type", 132 | ], 133 | "pairedDelimiter", 134 | ), 135 | }, 136 | { 137 | "name": "Special marks", 138 | "id": "specialMarks", 139 | "items": get_special_marks(), 140 | }, 141 | { 142 | "name": "Compound targets", 143 | "id": "compoundTargets", 144 | "items": get_compound_targets(), 145 | }, 146 | { 147 | "name": "Colors", 148 | "id": "colors", 149 | "items": get_list("hat_color", "hatColor"), 150 | }, 151 | { 152 | "name": "Shapes", 153 | "id": "shapes", 154 | "items": get_list("hat_shape", "hatShape"), 155 | }, 156 | { 157 | "name": "Tutorial", 158 | "id": "tutorial", 159 | "items": get_tutorial_entries(), 160 | }, 161 | ] 162 | } 163 | -------------------------------------------------------------------------------- /src/spoken_forms.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | from typing import Callable, Concatenate, ParamSpec, TypeVar 4 | 5 | from talon import app, cron, fs, registry 6 | 7 | from .actions.actions import ACTION_LIST_NAMES 8 | from .csv_overrides import ( 9 | SPOKEN_FORM_HEADER, 10 | ListToSpokenForms, 11 | SpokenFormEntry, 12 | init_csv_and_watch_changes, 13 | ) 14 | from .get_grapheme_spoken_form_entries import ( 15 | get_grapheme_spoken_form_entries, 16 | get_graphemes_talon_list, 17 | grapheme_capture_name, 18 | ) 19 | from .marks.decorated_mark import init_hats 20 | from .spoken_forms_output import SpokenFormsOutput 21 | from .spoken_scope_forms import init_scope_spoken_forms 22 | 23 | JSON_FILE = Path(__file__).parent / "spoken_forms.json" 24 | disposables: list[Callable] = [] 25 | 26 | 27 | P = ParamSpec("P") 28 | R = TypeVar("R") 29 | 30 | 31 | def auto_construct_defaults( 32 | spoken_forms: dict[str, ListToSpokenForms], 33 | handle_new_values: Callable[[str, list[SpokenFormEntry]], None], 34 | f: Callable[ 35 | Concatenate[str, ListToSpokenForms, Callable[[list[SpokenFormEntry]], None], P], 36 | R, 37 | ], 38 | ): 39 | """ 40 | Decorator that automatically constructs the default values for the 41 | `default_values` parameter of `f` based on the spoken forms in 42 | `spoken_forms`, by extracting the value at the key given by the csv 43 | filename. 44 | 45 | Note that we only ever pass `init_csv_and_watch_changes` as `f`. The 46 | reason we have this decorator is so that we can destructure the kwargs 47 | of `init_csv_and_watch_changes` to remove the `default_values` parameter. 48 | 49 | Args: 50 | spoken_forms (dict[str, ListToSpokenForms]): The spoken forms 51 | handle_new_values (Callable[[ListToSpokenForms], None]): A callback to be called when the lists are updated 52 | f (Callable[Concatenate[str, ListToSpokenForms, P], R]): Will always be `init_csv_and_watch_changes` 53 | """ 54 | 55 | def ret(filename: str, *args: P.args, **kwargs: P.kwargs) -> R: 56 | default_values = spoken_forms[filename] 57 | return f( 58 | filename, 59 | default_values, 60 | lambda new_values: handle_new_values(filename, new_values), 61 | *args, 62 | **kwargs, 63 | ) 64 | 65 | return ret 66 | 67 | 68 | # Maps from Talon list name to the type of the value in that list, e.g. 69 | # `pairedDelimiter` or `simpleScopeTypeType` 70 | # FIXME: This is a hack until we generate spoken_forms.json from Typescript side 71 | # At that point we can just include its type as part of that file 72 | LIST_TO_TYPE_MAP = { 73 | "wrapper_selectable_paired_delimiter": "pairedDelimiter", 74 | "selectable_only_paired_delimiter": "pairedDelimiter", 75 | "wrapper_only_paired_delimiter": "pairedDelimiter", 76 | "surrounding_pair_scope_type": "pairedDelimiter", 77 | "scope_type": "simpleScopeTypeType", 78 | "glyph_scope_type": "complexScopeTypeType", 79 | "custom_regex_scope_type": "customRegex", 80 | **{ 81 | action_list_name: "action" 82 | for action_list_name in ACTION_LIST_NAMES 83 | if action_list_name != "custom_action" 84 | }, 85 | "custom_action": "customAction", 86 | } 87 | 88 | 89 | def update(): 90 | global disposables 91 | 92 | for disposable in disposables: 93 | disposable() 94 | 95 | with open(JSON_FILE, encoding="utf-8") as file: 96 | spoken_forms = json.load(file) 97 | 98 | initialized = False 99 | 100 | # Maps from csv name to list of SpokenFormEntry 101 | custom_spoken_forms: dict[str, list[SpokenFormEntry]] = {} 102 | spoken_forms_output = SpokenFormsOutput() 103 | spoken_forms_output.init() 104 | graphemes_talon_list = get_graphemes_talon_list() 105 | 106 | def update_spoken_forms_output(): 107 | spoken_forms_output.write( 108 | [ 109 | *[ 110 | { 111 | "type": LIST_TO_TYPE_MAP[entry.list_name], 112 | "id": entry.id, 113 | "spokenForms": entry.spoken_forms, 114 | } 115 | for spoken_form_list in custom_spoken_forms.values() 116 | for entry in spoken_form_list 117 | if entry.list_name in LIST_TO_TYPE_MAP 118 | ], 119 | *get_grapheme_spoken_form_entries(graphemes_talon_list), 120 | ] 121 | ) 122 | 123 | def handle_new_values(csv_name: str, values: list[SpokenFormEntry]): 124 | custom_spoken_forms[csv_name] = values 125 | if initialized: 126 | # On first run, we just do one update at the end, so we suppress 127 | # writing until we get there 128 | init_scope_spoken_forms(graphemes_talon_list) 129 | update_spoken_forms_output() 130 | 131 | handle_csv = auto_construct_defaults( 132 | spoken_forms, 133 | handle_new_values, 134 | init_csv_and_watch_changes, 135 | ) 136 | 137 | disposables = [ 138 | handle_csv("actions.csv"), 139 | handle_csv("target_connectives.csv"), 140 | handle_csv("modifiers.csv"), 141 | handle_csv("positions.csv"), 142 | handle_csv( 143 | "paired_delimiters.csv", 144 | pluralize_lists=[ 145 | "selectable_only_paired_delimiter", 146 | "wrapper_selectable_paired_delimiter", 147 | ], 148 | ), 149 | handle_csv("special_marks.csv"), 150 | handle_csv("scope_visualizer.csv"), 151 | handle_csv("experimental/experimental_actions.csv"), 152 | handle_csv( 153 | "modifier_scope_types.csv", 154 | pluralize_lists=[ 155 | "scope_type", 156 | "glyph_scope_type", 157 | "surrounding_pair_scope_type", 158 | ], 159 | extra_allowed_values=[ 160 | "private.fieldAccess", 161 | "textFragment", 162 | "disqualifyDelimiter", 163 | "pairDelimiter", 164 | "interior", 165 | ], 166 | default_list_name="scope_type", 167 | ), 168 | # DEPRECATED @ 2025-02-01 169 | handle_csv( 170 | "experimental/wrapper_snippets.csv", 171 | deprecated=True, 172 | allow_unknown_values=True, 173 | default_list_name="wrapper_snippet", 174 | ), 175 | handle_csv( 176 | "experimental/insertion_snippets.csv", 177 | deprecated=True, 178 | allow_unknown_values=True, 179 | default_list_name="insertion_snippet_no_phrase", 180 | ), 181 | handle_csv( 182 | "experimental/insertion_snippets_single_phrase.csv", 183 | deprecated=True, 184 | allow_unknown_values=True, 185 | default_list_name="insertion_snippet_single_phrase", 186 | ), 187 | handle_csv( 188 | "experimental/miscellaneous.csv", 189 | deprecated=True, 190 | ), 191 | # --- 192 | handle_csv( 193 | "experimental/actions_custom.csv", 194 | headers=[SPOKEN_FORM_HEADER, "VSCode command"], 195 | allow_unknown_values=True, 196 | default_list_name="custom_action", 197 | ), 198 | handle_csv( 199 | "experimental/regex_scope_types.csv", 200 | headers=[SPOKEN_FORM_HEADER, "Regex"], 201 | allow_unknown_values=True, 202 | default_list_name="custom_regex_scope_type", 203 | pluralize_lists=["custom_regex_scope_type"], 204 | ), 205 | init_hats( 206 | spoken_forms["hat_styles.csv"]["hat_color"], 207 | spoken_forms["hat_styles.csv"]["hat_shape"], 208 | ), 209 | ] 210 | 211 | init_scope_spoken_forms(graphemes_talon_list) 212 | update_spoken_forms_output() 213 | initialized = True 214 | 215 | 216 | def on_watch(path, flags): 217 | if JSON_FILE.match(path): 218 | update() 219 | 220 | 221 | update_captures_cron = None 222 | 223 | 224 | def update_captures_debounced(updated_captures: set[str]): 225 | if grapheme_capture_name not in updated_captures: 226 | return 227 | 228 | global update_captures_cron 229 | cron.cancel(update_captures_cron) 230 | update_captures_cron = cron.after("100ms", update_captures) 231 | 232 | 233 | def update_captures(): 234 | global update_captures_cron 235 | update_captures_cron = None 236 | 237 | update() 238 | 239 | 240 | def on_ready(): 241 | update() 242 | 243 | registry.register("update_captures", update_captures_debounced) 244 | 245 | fs.watch(JSON_FILE.parent, on_watch) 246 | 247 | 248 | app.register("ready", on_ready) 249 | -------------------------------------------------------------------------------- /src/spoken_forms.json: -------------------------------------------------------------------------------- 1 | { 2 | "NOTE FOR USERS": "Please don't edit this json file; see https://www.cursorless.org/docs/user/customization", 3 | "actions.csv": { 4 | "simple_action": { 5 | "append post": "addSelectionAfter", 6 | "append pre": "addSelectionBefore", 7 | "append": "addSelection", 8 | "bottom": "scrollToBottom", 9 | "break point": "toggleLineBreakpoint", 10 | "break": "breakLine", 11 | "carve": "cutToClipboard", 12 | "center": "scrollToCenter", 13 | "change": "clearAndSetSelection", 14 | "chuck": "remove", 15 | "clone up": "insertCopyBefore", 16 | "clone": "insertCopyAfter", 17 | "comment": "toggleLineComment", 18 | "copy": "copyToClipboard", 19 | "crown": "scrollToTop", 20 | "decrement": "decrement", 21 | "dedent": "outdentLine", 22 | "define": "revealDefinition", 23 | "drink": "editNewLineBefore", 24 | "drop": "insertEmptyLineBefore", 25 | "extract": "extractVariable", 26 | "flash": "flashTargets", 27 | "float": "insertEmptyLineAfter", 28 | "fold": "foldRegion", 29 | "follow split": "followLinkAside", 30 | "follow": "followLink", 31 | "git accept": "gitAccept", 32 | "git stage": "gitStage", 33 | "git unstage": "gitUnstage", 34 | "git revert": "gitRevert", 35 | "give": "deselect", 36 | "highlight": "highlight", 37 | "hover": "showHover", 38 | "increment": "increment", 39 | "indent": "indentLine", 40 | "inspect": "showDebugHover", 41 | "join": "joinLines", 42 | "post": "setSelectionAfter", 43 | "pour": "editNewLineAfter", 44 | "pre": "setSelectionBefore", 45 | "puff": "insertEmptyLinesAround", 46 | "quick fix": "showQuickFix", 47 | "reference": "showReferences", 48 | "rename": "rename", 49 | "reverse": "reverseTargets", 50 | "scout all": "findInWorkspace", 51 | "scout": "findInDocument", 52 | "shuffle": "randomizeTargets", 53 | "snip make": "generateSnippet", 54 | "sort": "sortTargets", 55 | "take": "setSelection", 56 | "type deaf": "revealTypeDefinition", 57 | "unfold": "unfoldRegion" 58 | }, 59 | "callback_action": { 60 | "phones": "nextHomophone" 61 | }, 62 | "paste_action": { "paste": "pasteFromClipboard" }, 63 | "bring_move_action": { 64 | "bring": "replaceWithTarget", 65 | "move": "moveToTarget" 66 | }, 67 | "swap_action": { "swap": "swapTargets" }, 68 | "wrap_action": { "wrap": "wrapWithPairedDelimiter", "repack": "rewrap" }, 69 | "insert_snippet_action": { "snip": "insertSnippet" }, 70 | "reformat_action": { "format": "applyFormatter" }, 71 | "call_action": { "call": "callAsFunction" } 72 | }, 73 | "target_connectives.csv": { 74 | "range_connective": { 75 | "between": "rangeExclusive", 76 | "past": "rangeInclusive", 77 | "-": "rangeExcludingStart", 78 | "until": "rangeExcludingEnd" 79 | }, 80 | "list_connective": { "and": "listConnective" }, 81 | "swap_connective": { "with": "swapConnective" }, 82 | "insertion_mode_to": { "to": "sourceDestinationConnective" } 83 | }, 84 | "modifiers.csv": { 85 | "simple_modifier": { 86 | "bounds": "excludeInterior", 87 | "just": "toRawSelection", 88 | "leading": "leading", 89 | "trailing": "trailing", 90 | "content": "keepContentFilter", 91 | "empty": "keepEmptyFilter", 92 | "its": "inferPreviousMark", 93 | "visible": "visible" 94 | }, 95 | "every_scope_modifier": { "every": "every" }, 96 | "ancestor_scope_modifier": { "grand": "ancestor" }, 97 | "interior_modifier": { 98 | "inside": "interiorOnly" 99 | }, 100 | "head_tail_modifier": { 101 | "head": "extendThroughStartOf", 102 | "tail": "extendThroughEndOf" 103 | }, 104 | "range_type": { 105 | "slice": "verticalRange" 106 | }, 107 | "first_modifier": { "first": "first" }, 108 | "last_modifier": { "last": "last" }, 109 | "previous_next_modifier": { "previous": "previous", "next": "next" }, 110 | "forward_backward_modifier": { 111 | "forward": "forward", 112 | "backward": "backward" 113 | } 114 | }, 115 | "positions.csv": { 116 | "position": { 117 | "start of": "start", 118 | "end of": "end" 119 | }, 120 | "insertion_mode_before_after": { 121 | "before": "before", 122 | "after": "after" 123 | } 124 | }, 125 | "modifier_scope_types.csv": { 126 | "scope_type": { 127 | "arg": "argumentOrParameter", 128 | "arg list": "argumentList", 129 | "attribute": "attribute", 130 | "call": "functionCall", 131 | "callee": "functionCallee", 132 | "class name": "className", 133 | "class": "class", 134 | "comment": "comment", 135 | "funk name": "functionName", 136 | "funk": "namedFunction", 137 | "if state": "ifStatement", 138 | "instance": "instance", 139 | "item": "collectionItem", 140 | "key": "collectionKey", 141 | "lambda": "anonymousFunction", 142 | "list": "list", 143 | "map": "map", 144 | "name": "name", 145 | "regex": "regularExpression", 146 | "section": "section", 147 | "-one section": "sectionLevelOne", 148 | "-two section": "sectionLevelTwo", 149 | "-three section": "sectionLevelThree", 150 | "-four section": "sectionLevelFour", 151 | "-five section": "sectionLevelFive", 152 | "-six section": "sectionLevelSix", 153 | "selector": "selector", 154 | "state": "statement", 155 | "branch": "branch", 156 | "type": "type", 157 | "value": "value", 158 | "condition": "condition", 159 | "unit": "unit", 160 | "element": "xmlElement", 161 | "tags": "xmlBothTags", 162 | "start tag": "xmlStartTag", 163 | "end tag": "xmlEndTag", 164 | "part": "part", 165 | "chapter": "chapter", 166 | "subsection": "subSection", 167 | "subsubsection": "subSubSection", 168 | "paragraph": "namedParagraph", 169 | "subparagraph": "subParagraph", 170 | "environment": "environment", 171 | "command": "command", 172 | "char": "character", 173 | "sub": "word", 174 | "token": "token", 175 | "identifier": "identifier", 176 | "line": "line", 177 | "full line": "fullLine", 178 | "sentence": "sentence", 179 | "block": "paragraph", 180 | "file": "document", 181 | "paint": "nonWhitespaceSequence", 182 | "short paint": "boundedNonWhitespaceSequence", 183 | "short block": "boundedParagraph", 184 | "link": "url", 185 | "cell": "notebookCell" 186 | }, 187 | "surrounding_pair_scope_type": { 188 | "string": "string" 189 | }, 190 | "glyph_scope_type": { 191 | "glyph": "glyph" 192 | } 193 | }, 194 | "paired_delimiters.csv": { 195 | "selectable_only_paired_delimiter": { "pair": "any" }, 196 | "wrapper_only_paired_delimiter": { "void": "whitespace" }, 197 | "wrapper_selectable_paired_delimiter": { 198 | "curly": "curlyBrackets", 199 | "diamond": "angleBrackets", 200 | "escaped quad": "escapedDoubleQuotes", 201 | "escaped twin": "escapedSingleQuotes", 202 | "escaped round": "escapedParentheses", 203 | "escaped box": "escapedSquareBrackets", 204 | "quad": "doubleQuotes", 205 | "round": "parentheses", 206 | "skis": "backtickQuotes", 207 | "box": "squareBrackets", 208 | "twin": "singleQuotes" 209 | } 210 | }, 211 | "special_marks.csv": { 212 | "simple_mark": { 213 | "this": "currentSelection", 214 | "that": "previousTarget", 215 | "source": "previousSource", 216 | "nothing": "nothing" 217 | }, 218 | "unknown_symbol": { "special": "unknownSymbol" }, 219 | "line_direction": { 220 | "row": "lineNumberModulo100", 221 | "up": "lineNumberRelativeUp", 222 | "down": "lineNumberRelativeDown" 223 | } 224 | }, 225 | "scope_visualizer.csv": { 226 | "show_scope_visualizer": { "visualize": "showScopeVisualizer" }, 227 | "hide_scope_visualizer": { "visualize nothing": "hideScopeVisualizer" }, 228 | "visualization_type": { 229 | "removal": "removal", 230 | "iteration": "iteration" 231 | } 232 | }, 233 | "experimental/experimental_actions.csv": { 234 | "experimental_action": { 235 | "from": "experimental.setInstanceReference" 236 | } 237 | }, 238 | "experimental/wrapper_snippets.csv": {}, 239 | "experimental/insertion_snippets.csv": {}, 240 | "experimental/insertion_snippets_single_phrase.csv": {}, 241 | "experimental/miscellaneous.csv": { 242 | "phrase_terminator": { "over": "phraseTerminator" } 243 | }, 244 | "experimental/actions_custom.csv": {}, 245 | "experimental/regex_scope_types.csv": {}, 246 | "hat_styles.csv": { 247 | "hat_color": { 248 | "blue": "blue", 249 | "green": "green", 250 | "red": "red", 251 | "pink": "pink", 252 | "yellow": "yellow", 253 | "navy": "userColor1", 254 | "apricot": "userColor2" 255 | }, 256 | "hat_shape": { 257 | "ex": "ex", 258 | "fox": "fox", 259 | "wing": "wing", 260 | "hole": "hole", 261 | "frame": "frame", 262 | "curve": "curve", 263 | "eye": "eye", 264 | "play": "play", 265 | "cross": "crosshairs", 266 | "bolt": "bolt" 267 | } 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /src/cheatsheet/sections/modifiers.py: -------------------------------------------------------------------------------- 1 | from itertools import chain 2 | from typing import Callable, TypedDict 3 | 4 | from ..get_list import ListItemDescriptor, Variation, get_raw_list, make_dict_readable 5 | 6 | MODIFIER_LIST_NAMES = [ 7 | "simple_modifier", 8 | "interior_modifier", 9 | "head_tail_modifier", 10 | "every_scope_modifier", 11 | "ancestor_scope_modifier", 12 | "first_modifier", 13 | "last_modifier", 14 | "previous_next_modifier", 15 | "forward_backward_modifier", 16 | "position", 17 | ] 18 | 19 | 20 | class Entry(TypedDict): 21 | spokenForm: str 22 | description: str 23 | 24 | 25 | def get_modifiers() -> list[ListItemDescriptor]: 26 | all_modifiers = {} 27 | for name in MODIFIER_LIST_NAMES: 28 | all_modifiers.update(get_raw_list(name)) 29 | 30 | complex_modifier_ids = [ 31 | "extendThroughStartOf", 32 | "extendThroughEndOf", 33 | "every", 34 | "ancestor", 35 | "first", 36 | "last", 37 | "previous", 38 | "next", 39 | "backward", 40 | "forward", 41 | ] 42 | simple_modifiers = { 43 | key: value 44 | for key, value in all_modifiers.items() 45 | if value not in complex_modifier_ids 46 | } 47 | complex_modifiers = { 48 | value: key 49 | for key, value in all_modifiers.items() 50 | if value in complex_modifier_ids 51 | } 52 | 53 | items = make_dict_readable( 54 | "modifier", 55 | simple_modifiers, 56 | { 57 | "excludeInterior": "Bounding paired delimiters", 58 | "toRawSelection": "No inference", 59 | "leading": "Leading delimiter range", 60 | "trailing": "Trailing delimiter range", 61 | "start": "Empty position at start of target", 62 | "end": "Empty position at end of target", 63 | }, 64 | ) 65 | 66 | if "extendThroughStartOf" in complex_modifiers: 67 | items.append( 68 | { 69 | "id": "extendThroughStartOf", 70 | "type": "modifier", 71 | "variations": [ 72 | { 73 | "spokenForm": complex_modifiers["extendThroughStartOf"], 74 | "description": "Extend through start of line/pair", 75 | }, 76 | { 77 | "spokenForm": f"{complex_modifiers['extendThroughStartOf']} ", 78 | "description": "Extend through start of ", 79 | }, 80 | ], 81 | } 82 | ) 83 | 84 | if "extendThroughEndOf" in complex_modifiers: 85 | items.append( 86 | { 87 | "id": "extendThroughEndOf", 88 | "type": "modifier", 89 | "variations": [ 90 | { 91 | "spokenForm": complex_modifiers["extendThroughEndOf"], 92 | "description": "Extend through end of line/pair", 93 | }, 94 | { 95 | "spokenForm": f"{complex_modifiers['extendThroughEndOf']} ", 96 | "description": "Extend through end of ", 97 | }, 98 | ], 99 | } 100 | ) 101 | 102 | items.append( 103 | { 104 | "id": "containingScope", 105 | "type": "modifier", 106 | "variations": [ 107 | { 108 | "spokenForm": "", 109 | "description": "Containing instance of ", 110 | }, 111 | ], 112 | } 113 | ) 114 | 115 | if "every" in complex_modifiers: 116 | items.append( 117 | { 118 | "id": "every", 119 | "type": "modifier", 120 | "variations": [ 121 | { 122 | "spokenForm": f"{complex_modifiers['every']} ", 123 | "description": "Every instance of ", 124 | }, 125 | ], 126 | } 127 | ) 128 | 129 | if "ancestor" in complex_modifiers: 130 | items.append( 131 | { 132 | "id": "ancestor", 133 | "type": "modifier", 134 | "variations": [ 135 | { 136 | "spokenForm": f"{complex_modifiers['ancestor']} ", 137 | "description": "Grandparent containing instance of ", 138 | }, 139 | ], 140 | } 141 | ) 142 | 143 | items.append(get_relative_scope(complex_modifiers)) 144 | items.append(get_ordinal_scope(complex_modifiers)) 145 | 146 | return items 147 | 148 | 149 | def get_relative_scope(complex_modifiers: dict[str, str]) -> ListItemDescriptor: 150 | variations: list[Variation] = [] 151 | 152 | fixtures: dict[str, list[tuple[Callable, str]]] = { 153 | "previous": [ 154 | ( 155 | lambda value: f"{value} ", 156 | "Previous instance of ", 157 | ), 158 | ( 159 | lambda value: f" {value} ", 160 | " instance of before target", 161 | ), 162 | ], 163 | "next": [ 164 | ( 165 | lambda value: f"{value} ", 166 | "Next instance of ", 167 | ), 168 | ( 169 | lambda value: f" {value} ", 170 | " instance of after target", 171 | ), 172 | ], 173 | "backward": [ 174 | ( 175 | lambda value: f" {value}", 176 | "single instance of including target, going backwards", 177 | ) 178 | ], 179 | "forward": [ 180 | ( 181 | lambda value: f" {value}", 182 | "single instance of including target, going forwards", 183 | ) 184 | ], 185 | } 186 | 187 | for mod_id, vars in fixtures.items(): 188 | if mod_id not in complex_modifiers: 189 | continue 190 | mod = complex_modifiers[mod_id] 191 | for callback, description in vars: 192 | variations.append( 193 | { 194 | "spokenForm": callback(mod), 195 | "description": description, 196 | } 197 | ) 198 | 199 | if "every" in complex_modifiers: 200 | entries: list[Entry] = [] 201 | 202 | if "backward" in complex_modifiers: 203 | entries.append( 204 | { 205 | "spokenForm": f" s {complex_modifiers['backward']}", 206 | "description": " instances of including target, going backwards", 207 | } 208 | ) 209 | 210 | entries.append( 211 | { 212 | "spokenForm": " s", 213 | "description": " instances of including target, going forwards", 214 | } 215 | ) 216 | 217 | if "previous" in complex_modifiers: 218 | entries.append( 219 | { 220 | "spokenForm": f"{complex_modifiers['previous']} s", 221 | "description": "previous instances of ", 222 | } 223 | ) 224 | 225 | if "next" in complex_modifiers: 226 | entries.append( 227 | { 228 | "spokenForm": f"{complex_modifiers['next']} s", 229 | "description": "next instances of ", 230 | } 231 | ) 232 | 233 | variations.extend(generateOptionalEvery(complex_modifiers["every"], *entries)) 234 | 235 | return { 236 | "id": "relativeScope", 237 | "type": "modifier", 238 | "variations": variations, 239 | } 240 | 241 | 242 | def get_ordinal_scope(complex_modifiers: dict[str, str]) -> ListItemDescriptor: 243 | variations: list[Variation] = [ 244 | { 245 | "spokenForm": " ", 246 | "description": " instance of in iteration scope", 247 | } 248 | ] 249 | 250 | if "last" in complex_modifiers: 251 | variations.append( 252 | { 253 | "spokenForm": f" {complex_modifiers['last']} ", 254 | "description": "-to-last instance of in iteration scope", 255 | } 256 | ) 257 | 258 | if "every" in complex_modifiers: 259 | entries: list[Entry] = [] 260 | 261 | if "first" in complex_modifiers: 262 | entries.append( 263 | { 264 | "spokenForm": f"{complex_modifiers['first']} s", 265 | "description": "first instances of in iteration scope", 266 | } 267 | ) 268 | 269 | if "last" in complex_modifiers: 270 | entries.append( 271 | { 272 | "spokenForm": f"{complex_modifiers['last']} s", 273 | "description": "last instances of in iteration scope", 274 | } 275 | ) 276 | 277 | variations.extend(generateOptionalEvery(complex_modifiers["every"], *entries)) 278 | 279 | return { 280 | "id": "ordinalScope", 281 | "type": "modifier", 282 | "variations": variations, 283 | } 284 | 285 | 286 | def generateOptionalEvery(every: str, *entries: Entry) -> list[Entry]: 287 | return list( 288 | chain.from_iterable( 289 | [ 290 | { 291 | "spokenForm": entry["spokenForm"], 292 | "description": f"{entry['description']}, as contiguous range", 293 | }, 294 | { 295 | "spokenForm": f"{every} {entry['spokenForm']}", 296 | "description": f"{entry['description']}, as individual targets", 297 | }, 298 | ] 299 | for entry in entries 300 | ) 301 | ) 302 | -------------------------------------------------------------------------------- /src/csv_overrides.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import typing 3 | from collections import defaultdict 4 | from collections.abc import Container 5 | from dataclasses import dataclass 6 | from datetime import datetime 7 | from pathlib import Path 8 | from typing import Callable, Iterable, Optional, TypedDict 9 | 10 | from talon import Context, Module, actions, app, fs, settings 11 | 12 | from .conventions import get_cursorless_list_name 13 | from .vendor.inflection import pluralize 14 | 15 | SPOKEN_FORM_HEADER = "Spoken form" 16 | CURSORLESS_IDENTIFIER_HEADER = "Cursorless identifier" 17 | 18 | 19 | mod = Module() 20 | mod.tag( 21 | "cursorless_default_vocabulary", 22 | desc="Use default cursorless vocabulary instead of user custom", 23 | ) 24 | mod.setting( 25 | "cursorless_settings_directory", 26 | type=str, 27 | default="cursorless-settings", 28 | desc="The directory to use for cursorless settings csvs relative to talon user directory", 29 | ) 30 | 31 | # The global context we use for our lists 32 | ctx = Context() 33 | 34 | # A context that contains default vocabulary, for use in testing 35 | normalized_ctx = Context() 36 | normalized_ctx.matches = r""" 37 | tag: user.cursorless_default_vocabulary 38 | """ 39 | 40 | 41 | # Maps from Talon list name to a map from spoken form to value 42 | ListToSpokenForms = dict[str, dict[str, str]] 43 | 44 | 45 | @dataclass 46 | class SpokenFormEntry: 47 | list_name: str 48 | id: str 49 | spoken_forms: list[str] 50 | 51 | 52 | def csv_get_ctx(): 53 | return ctx 54 | 55 | 56 | def csv_get_normalized_ctx(): 57 | return normalized_ctx 58 | 59 | 60 | def init_csv_and_watch_changes( 61 | filename: str, 62 | default_values: ListToSpokenForms, 63 | handle_new_values: Optional[Callable[[list[SpokenFormEntry]], None]] = None, 64 | *, 65 | extra_ignored_values: Optional[list[str]] = None, 66 | extra_allowed_values: Optional[list[str]] = None, 67 | allow_unknown_values: bool = False, 68 | deprecated: bool = False, 69 | default_list_name: Optional[str] = None, 70 | headers: list[str] = [SPOKEN_FORM_HEADER, CURSORLESS_IDENTIFIER_HEADER], 71 | no_update_file: bool = False, 72 | pluralize_lists: Optional[list[str]] = None, 73 | ): 74 | """ 75 | Initialize a cursorless settings csv, creating it if necessary, and watch 76 | for changes to the csv. Talon lists will be generated based on the keys of 77 | `default_values`. For example, if there is a key `foo`, there will be a 78 | list created called `user.cursorless_foo` that will contain entries from the 79 | original dict at the key `foo`, updated according to customization in the 80 | csv at 81 | 82 | ``` 83 | actions.path.talon_user() / "cursorless-settings" / filename 84 | ``` 85 | 86 | Note that the settings directory location can be customized using the 87 | `user.cursorless_settings_directory` setting. 88 | 89 | Args: 90 | filename (str): The name of the csv file to be placed in 91 | `cursorles-settings` dir 92 | default_values (ListToSpokenForms): The default values for the lists to 93 | be customized in the given csv 94 | handle_new_values (Optional[Callable[[list[SpokenFormEntry]], None]]): A 95 | callback to be called when the lists are updated 96 | extra_ignored_values (Optional[list[str]]): Don't throw an exception if 97 | any of these appear as values; just ignore them and don't add them 98 | to any list 99 | allow_unknown_values (bool): If unknown values appear, just put them in 100 | the list 101 | default_list_name (Optional[str]): If unknown values are 102 | allowed, put any unknown values in this list 103 | headers (list[str]): The headers to use for the csv 104 | no_update_file (bool): Set this to `True` to indicate that we should not 105 | update the csv. This is used generally in case there was an issue 106 | coming up with the default set of values so we don't want to persist 107 | those to disk 108 | pluralize_lists (list[str]): Create plural version of given lists 109 | """ 110 | # Don't allow both `extra_allowed_values` and `allow_unknown_values` 111 | assert not (extra_allowed_values and allow_unknown_values) 112 | 113 | # If `extra_allowed_values` or `allow_unknown_values` is given, we need a 114 | # `default_list_name` to put unknown values in 115 | assert not ( 116 | (extra_allowed_values or allow_unknown_values) and not default_list_name 117 | ) 118 | 119 | if extra_ignored_values is None: 120 | extra_ignored_values = [] 121 | if extra_allowed_values is None: 122 | extra_allowed_values = [] 123 | if pluralize_lists is None: 124 | pluralize_lists = [] 125 | 126 | file_path = get_full_path(filename) 127 | is_file = file_path.is_file() 128 | 129 | # Deprecated file that doesn't exist. Do nothing. 130 | if deprecated and not is_file: 131 | return lambda: None 132 | 133 | super_default_values = get_super_values(default_values) 134 | 135 | file_path.parent.mkdir(parents=True, exist_ok=True) 136 | 137 | check_for_duplicates(filename, default_values) 138 | create_default_vocabulary_dicts(default_values, pluralize_lists) 139 | 140 | def on_watch(path, flags): 141 | if file_path.match(path): 142 | current_values, has_errors = read_file( 143 | path=file_path, 144 | headers=headers, 145 | default_identifiers=super_default_values.values(), 146 | extra_ignored_values=extra_ignored_values, 147 | extra_allowed_values=extra_allowed_values, 148 | allow_unknown_values=allow_unknown_values, 149 | ) 150 | update_dicts( 151 | default_values=default_values, 152 | current_values=current_values, 153 | extra_ignored_values=extra_ignored_values, 154 | extra_allowed_values=extra_allowed_values, 155 | allow_unknown_values=allow_unknown_values, 156 | default_list_name=default_list_name, 157 | pluralize_lists=pluralize_lists, 158 | handle_new_values=handle_new_values, 159 | ) 160 | 161 | fs.watch(file_path.parent, on_watch) 162 | 163 | if is_file: 164 | current_values = update_file( 165 | path=file_path, 166 | headers=headers, 167 | default_values=super_default_values, 168 | extra_ignored_values=extra_ignored_values, 169 | extra_allowed_values=extra_allowed_values, 170 | allow_unknown_values=allow_unknown_values, 171 | no_update_file=no_update_file, 172 | ) 173 | update_dicts( 174 | default_values=default_values, 175 | current_values=current_values, 176 | extra_ignored_values=extra_ignored_values, 177 | extra_allowed_values=extra_allowed_values, 178 | allow_unknown_values=allow_unknown_values, 179 | default_list_name=default_list_name, 180 | pluralize_lists=pluralize_lists, 181 | handle_new_values=handle_new_values, 182 | ) 183 | else: 184 | if not no_update_file: 185 | create_file(file_path, headers, super_default_values) 186 | update_dicts( 187 | default_values=default_values, 188 | current_values=super_default_values, 189 | extra_ignored_values=extra_ignored_values, 190 | extra_allowed_values=extra_allowed_values, 191 | allow_unknown_values=allow_unknown_values, 192 | default_list_name=default_list_name, 193 | pluralize_lists=pluralize_lists, 194 | handle_new_values=handle_new_values, 195 | ) 196 | 197 | def unsubscribe(): 198 | fs.unwatch(file_path.parent, on_watch) 199 | 200 | return unsubscribe 201 | 202 | 203 | def check_for_duplicates(filename, default_values): 204 | results_map = {} 205 | for list_name, dict in default_values.items(): 206 | for key, value in dict.items(): 207 | if value in results_map: 208 | existing_list_name = results_map[value] 209 | warning = f"WARNING ({filename}): Value `{value}` duplicated between lists '{existing_list_name}' and '{list_name}'" 210 | print(warning) 211 | app.notify(warning) 212 | else: 213 | results_map[value] = list_name 214 | 215 | 216 | def is_removed(value: str): 217 | return value.startswith("-") 218 | 219 | 220 | def create_default_vocabulary_dicts( 221 | default_values: dict[str, dict], pluralize_lists: list[str] 222 | ): 223 | default_values_updated = {} 224 | for key, value in default_values.items(): 225 | updated_dict = {} 226 | for key2, value2 in value.items(): 227 | # Enable deactivated(prefixed with a `-`) items 228 | active_key = key2[1:] if key2.startswith("-") else key2 229 | if active_key: 230 | updated_dict[active_key] = value2 231 | default_values_updated[key] = updated_dict 232 | assign_lists_to_context(normalized_ctx, default_values_updated, pluralize_lists) 233 | 234 | 235 | def update_dicts( 236 | default_values: ListToSpokenForms, 237 | current_values: dict[str, str], 238 | extra_ignored_values: list[str], 239 | extra_allowed_values: list[str], 240 | allow_unknown_values: bool, 241 | default_list_name: str | None, 242 | pluralize_lists: list[str], 243 | handle_new_values: Callable[[list[SpokenFormEntry]], None] | None, 244 | ): 245 | # Create map with all default values 246 | results_map: dict[str, ResultsListEntry] = {} 247 | for list_name, obj in default_values.items(): 248 | for spoken, id in obj.items(): 249 | results_map[id] = {"spoken": spoken, "id": id, "list": list_name} 250 | 251 | # Update result with current values 252 | for spoken, id in current_values.items(): 253 | try: 254 | results_map[id]["spoken"] = spoken 255 | except KeyError: 256 | if id in extra_ignored_values: 257 | pass 258 | elif allow_unknown_values or id in extra_allowed_values: 259 | assert default_list_name is not None 260 | results_map[id] = { 261 | "spoken": spoken, 262 | "id": id, 263 | "list": default_list_name, 264 | } 265 | else: 266 | raise 267 | 268 | spoken_form_entries = list(generate_spoken_forms(results_map.values())) 269 | 270 | # Assign result to talon context list 271 | lists: ListToSpokenForms = defaultdict(dict) 272 | for entry in spoken_form_entries: 273 | for spoken_form in entry.spoken_forms: 274 | lists[entry.list_name][spoken_form] = entry.id 275 | assign_lists_to_context(ctx, lists, pluralize_lists) 276 | 277 | if handle_new_values is not None: 278 | handle_new_values(spoken_form_entries) 279 | 280 | 281 | class ResultsListEntry(TypedDict): 282 | spoken: str 283 | id: str 284 | list: str 285 | 286 | 287 | def generate_spoken_forms(results_list: Iterable[ResultsListEntry]): 288 | for obj in results_list: 289 | id = obj["id"] 290 | spoken = obj["spoken"] 291 | 292 | spoken_forms = [] 293 | if not is_removed(spoken): 294 | for k in spoken.split("|"): 295 | if id == "pasteFromClipboard" and k.endswith(" to"): 296 | # FIXME: This is a hack to work around the fact that the 297 | # spoken form of the `pasteFromClipboard` action used to be 298 | # "paste to", but now the spoken form is just "paste" and 299 | # the "to" is part of the positional target. Users who had 300 | # cursorless before this change would have "paste to" as 301 | # their spoken form and so would need to say "paste to to". 302 | k = k[:-3] 303 | spoken_forms.append(k.strip()) 304 | 305 | yield SpokenFormEntry( 306 | list_name=obj["list"], 307 | id=id, 308 | spoken_forms=spoken_forms, 309 | ) 310 | 311 | 312 | def assign_lists_to_context( 313 | ctx: Context, 314 | lists: ListToSpokenForms, 315 | pluralize_lists: list[str], 316 | ): 317 | for list_name, dict in lists.items(): 318 | list_singular_name = get_cursorless_list_name(list_name) 319 | ctx.lists[list_singular_name] = dict 320 | if list_name in pluralize_lists: 321 | list_plural_name = f"{list_singular_name}_plural" 322 | ctx.lists[list_plural_name] = {pluralize(k): v for k, v in dict.items()} 323 | 324 | 325 | def update_file( 326 | path: Path, 327 | headers: list[str], 328 | default_values: dict[str, str], 329 | extra_ignored_values: list[str], 330 | extra_allowed_values: list[str], 331 | allow_unknown_values: bool, 332 | no_update_file: bool, 333 | ): 334 | current_values, has_errors = read_file( 335 | path=path, 336 | headers=headers, 337 | default_identifiers=default_values.values(), 338 | extra_ignored_values=extra_ignored_values, 339 | extra_allowed_values=extra_allowed_values, 340 | allow_unknown_values=allow_unknown_values, 341 | ) 342 | current_identifiers = current_values.values() 343 | 344 | missing = {} 345 | for key, value in default_values.items(): 346 | if value not in current_identifiers: 347 | missing[key] = value 348 | 349 | if missing: 350 | if has_errors or no_update_file: 351 | print( 352 | "NOTICE: New cursorless features detected, but refusing to update " 353 | "csv due to errors. Please fix csv errors above and restart talon" 354 | ) 355 | else: 356 | timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") 357 | lines = [ 358 | f"# {timestamp} - New entries automatically added by cursorless", 359 | *[create_line(key, missing[key]) for key in sorted(missing)], 360 | ] 361 | with open(path, "a") as f: 362 | f.write("\n\n" + "\n".join(lines)) 363 | 364 | print(f"New cursorless features added to {path.name}") 365 | for key in sorted(missing): 366 | print(f"{key}: {missing[key]}") 367 | print( 368 | "See release notes for more info: " 369 | "https://github.com/cursorless-dev/cursorless/blob/main/CHANGELOG.md" 370 | ) 371 | app.notify("🎉🎉 New cursorless features; see log") 372 | 373 | return current_values 374 | 375 | 376 | def create_line(*cells: str): 377 | return ", ".join(cells) 378 | 379 | 380 | def create_file(path: Path, headers: list[str], default_values: dict): 381 | lines = [create_line(key, default_values[key]) for key in sorted(default_values)] 382 | lines.insert(0, create_line(*headers)) 383 | lines.append("") 384 | path.write_text("\n".join(lines)) 385 | 386 | 387 | def csv_error(path: Path, index: int, message: str, value: str): 388 | """Check that an expected condition is true 389 | 390 | Note that we try to continue reading in this case so cursorless doesn't get bricked 391 | 392 | Args: 393 | path (Path): The path of the CSV (for error reporting) 394 | index (int): The index into the file (for error reporting) 395 | text (str): The text of the error message to report if condition is false 396 | """ 397 | print(f"ERROR: {path}:{index + 1}: {message} '{value}'") 398 | 399 | 400 | def read_file( 401 | path: Path, 402 | headers: list[str], 403 | default_identifiers: Container[str], 404 | extra_ignored_values: list[str], 405 | extra_allowed_values: list[str], 406 | allow_unknown_values: bool, 407 | ): 408 | with open(path) as csv_file: 409 | # Use `skipinitialspace` to allow spaces before quote. `, "a,b"` 410 | csv_reader = csv.reader(csv_file, skipinitialspace=True) 411 | rows = list(csv_reader) 412 | 413 | result = {} 414 | used_identifiers = [] 415 | has_errors = False 416 | seen_headers = False 417 | 418 | for i, row in enumerate(rows): 419 | # Remove trailing whitespaces for each cell 420 | row = [x.rstrip() for x in row] 421 | # Exclude empty or comment rows 422 | if len(row) == 0 or (len(row) == 1 and row[0] == "") or row[0].startswith("#"): 423 | continue 424 | 425 | if not seen_headers: 426 | seen_headers = True 427 | if row != headers: 428 | has_errors = True 429 | csv_error(path, i, "Malformed header", create_line(*row)) 430 | print(f"Expected '{create_line(*headers)}'") 431 | continue 432 | 433 | if len(row) != len(headers): 434 | has_errors = True 435 | csv_error( 436 | path, 437 | i, 438 | f"Malformed csv entry. Expected {len(headers)} columns.", 439 | create_line(*row), 440 | ) 441 | continue 442 | 443 | key, value = row 444 | 445 | if ( 446 | value not in default_identifiers 447 | and value not in extra_ignored_values 448 | and value not in extra_allowed_values 449 | and not allow_unknown_values 450 | ): 451 | has_errors = True 452 | csv_error(path, i, "Unknown identifier", value) 453 | continue 454 | 455 | if value in used_identifiers: 456 | has_errors = True 457 | csv_error(path, i, "Duplicate identifier", value) 458 | continue 459 | 460 | result[key] = value 461 | used_identifiers.append(value) 462 | 463 | if has_errors: 464 | app.notify("Cursorless settings error; see log") 465 | 466 | return result, has_errors 467 | 468 | 469 | def get_full_path(filename: str): 470 | if not filename.endswith(".csv"): 471 | filename = f"{filename}.csv" 472 | 473 | user_dir: Path = actions.path.talon_user() 474 | settings_directory = Path( 475 | typing.cast(str, settings.get("user.cursorless_settings_directory")) 476 | ) 477 | 478 | if not settings_directory.is_absolute(): 479 | settings_directory = user_dir / settings_directory 480 | 481 | return (settings_directory / filename).resolve() 482 | 483 | 484 | def get_super_values(values: ListToSpokenForms): 485 | result: dict[str, str] = {} 486 | for value_dict in values.values(): 487 | result.update(value_dict) 488 | return result 489 | --------------------------------------------------------------------------------