init commit

This commit is contained in:
unknown
2025-08-19 08:06:37 -04:00
commit 2957b5515a
743 changed files with 45495 additions and 0 deletions
@@ -0,0 +1,143 @@
from typing import Callable, Union
from talon import Module, actions
from ..targets.target_types import (
CursorlessDestination,
CursorlessExplicitTarget,
CursorlessTarget,
ImplicitDestination,
)
from .bring_move import BringMoveTargets
from .execute_command import cursorless_execute_command_action
from .homophones import cursorless_homophones_action
from .replace import cursorless_replace_action
mod = Module()
mod.list(
"cursorless_simple_action",
desc="Cursorless internal: simple actions",
)
mod.list(
"cursorless_callback_action",
desc="Cursorless internal: actions implemented via a callback function",
)
mod.list(
"cursorless_custom_action",
desc="Cursorless internal: user-defined custom actions",
)
mod.list(
"cursorless_experimental_action",
desc="Cursorless internal: experimental actions",
)
ACTION_LIST_NAMES = [
"simple_action",
"callback_action",
"paste_action",
"bring_move_action",
"swap_action",
"wrap_action",
"insert_snippet_action",
"reformat_action",
"call_action",
"experimental_action",
"custom_action",
]
callback_actions: dict[str, Callable[[CursorlessExplicitTarget], None]] = {
"nextHomophone": cursorless_homophones_action,
}
# Don't wait for these actions to finish, usually because they hang on some kind of user interaction
no_wait_actions = [
"rename",
]
# These are actions that we don't wait for, but still want to have a post action sleep
no_wait_actions_post_sleep = {
"rename": 0.3,
}
@mod.capture(
rule=(
"{user.cursorless_simple_action} |"
"{user.cursorless_experimental_action} |"
"{user.cursorless_callback_action} |"
"{user.cursorless_call_action} |"
"{user.cursorless_custom_action}"
)
)
def cursorless_action_or_ide_command(m) -> dict[str, str]:
try:
value = m.cursorless_custom_action
type = "ide_command"
except AttributeError:
value = m[0]
type = "cursorless_action"
return {
"value": value,
"type": type,
}
@mod.action_class
class Actions:
def cursorless_command(action_name: str, target: CursorlessExplicitTarget): # pyright: ignore [reportGeneralTypeIssues]
"""Perform cursorless command on target"""
if action_name in callback_actions:
callback_actions[action_name](target)
elif action_name in ["replaceWithTarget", "moveToTarget"]:
actions.user.private_cursorless_bring_move(
action_name, BringMoveTargets(target, ImplicitDestination())
)
elif action_name == "callAsFunction":
actions.user.private_cursorless_call(target)
elif action_name == "generateSnippet":
actions.user.private_cursorless_generate_snippet_action(target)
elif action_name in no_wait_actions:
action = {"name": action_name, "target": target}
actions.user.private_cursorless_command_no_wait(action)
if action_name in no_wait_actions_post_sleep:
actions.sleep(no_wait_actions_post_sleep[action_name])
else:
action = {"name": action_name, "target": target}
actions.user.private_cursorless_command_and_wait(action)
def cursorless_vscode_command(command_id: str, target: CursorlessTarget): # pyright: ignore [reportGeneralTypeIssues]
"""
Perform vscode command on cursorless target
Deprecated: prefer `cursorless_ide_command`
"""
return actions.user.cursorless_ide_command(command_id, target)
def cursorless_ide_command(command_id: str, target: CursorlessTarget): # pyright: ignore [reportGeneralTypeIssues]
"""Perform ide command on cursorless target"""
return cursorless_execute_command_action(command_id, target)
def cursorless_insert(
destination: CursorlessDestination, # pyright: ignore [reportGeneralTypeIssues]
text: Union[str, list[str]],
):
"""Perform text insertion on Cursorless destination"""
if isinstance(text, str):
text = [text]
cursorless_replace_action(destination, text)
def private_cursorless_action_or_ide_command(
instruction: dict[str, str], # pyright: ignore [reportGeneralTypeIssues]
target: CursorlessTarget,
):
"""Perform cursorless action or ide command on target (internal use only)"""
type = instruction["type"]
value = instruction["value"]
if type == "cursorless_action":
actions.user.cursorless_command(value, target)
elif type == "ide_command":
actions.user.cursorless_ide_command(value, target)
@@ -0,0 +1,46 @@
from dataclasses import dataclass
from talon import Module, actions
from ..targets.target_types import (
CursorlessDestination,
CursorlessTarget,
ImplicitDestination,
)
@dataclass
class BringMoveTargets:
source: CursorlessTarget
destination: CursorlessDestination
mod = Module()
mod.list("cursorless_bring_move_action", desc="Cursorless bring or move actions")
@mod.capture(rule="<user.cursorless_target> [<user.cursorless_destination>]")
def cursorless_bring_move_targets(m) -> BringMoveTargets:
source = m.cursorless_target
try:
destination = m.cursorless_destination
except AttributeError:
destination = ImplicitDestination()
return BringMoveTargets(source, destination)
@mod.action_class
class Actions:
def private_cursorless_bring_move(action_name: str, targets: BringMoveTargets): # pyright: ignore [reportGeneralTypeIssues]
"""Execute Cursorless move/bring action"""
actions.user.private_cursorless_command_and_wait(
{
"name": action_name,
"source": targets.source,
"destination": targets.destination,
}
)
+22
View File
@@ -0,0 +1,22 @@
from talon import Module, actions
from ..targets.target_types import CursorlessTarget, ImplicitTarget
mod = Module()
mod.list("cursorless_call_action", desc="Cursorless call action")
@mod.action_class
class Actions:
def private_cursorless_call(
callee: CursorlessTarget, # pyright: ignore [reportGeneralTypeIssues]
argument: CursorlessTarget = ImplicitTarget(),
):
"""Execute Cursorless call action"""
actions.user.private_cursorless_command_and_wait(
{
"name": "callAsFunction",
"callee": callee,
"argument": argument,
}
)
@@ -0,0 +1,17 @@
from talon import actions
from ..targets.target_types import CursorlessTarget
def cursorless_execute_command_action(
command_id: str, target: CursorlessTarget, command_options: dict = {}
):
"""Execute Cursorless execute command action"""
actions.user.private_cursorless_command_and_wait(
{
"name": "executeCommand",
"commandId": command_id,
"options": command_options,
"target": target,
}
)
@@ -0,0 +1,71 @@
import glob
from pathlib import Path
from talon import Module, actions, registry, settings
from ..targets.target_types import CursorlessExplicitTarget
mod = Module()
@mod.action_class
class Actions:
def private_cursorless_migrate_snippets():
"""Migrate snippets from Cursorless to community format"""
actions.user.private_cursorless_run_rpc_command_no_wait(
"cursorless.migrateSnippets",
str(get_directory_path()),
{
"insertion": registry.lists[
"user.cursorless_insertion_snippet_no_phrase"
][-1],
"insertionWithPhrase": registry.lists[
"user.cursorless_insertion_snippet_single_phrase"
][-1],
"wrapper": registry.lists["user.cursorless_wrapper_snippet"][-1],
},
)
def private_cursorless_generate_snippet_action(target: CursorlessExplicitTarget): # pyright: ignore [reportGeneralTypeIssues]
"""Generate a snippet from the given target"""
actions.user.private_cursorless_command_no_wait(
{
"name": "generateSnippet",
"target": target,
"directory": str(get_directory_path()),
}
)
def get_directory_path() -> Path:
settings_dir = get_setting_dir()
if settings_dir is not None:
return settings_dir
return get_community_snippets_dir()
def get_setting_dir() -> Path | None:
try:
setting_dir = settings.get("user.snippets_dir")
if not setting_dir:
return None
dir = Path(str(setting_dir))
if not dir.is_absolute():
user_dir = Path(actions.path.talon_user())
dir = user_dir / dir
return dir.resolve()
except Exception:
return None
def get_community_snippets_dir() -> Path:
files = glob.iglob(
f"{actions.path.talon_user()}/**/snippets/snippets/*.snippet",
recursive=True,
)
for file in files:
return Path(file).parent
raise ValueError("Could not find community snippets directory")
@@ -0,0 +1,56 @@
from typing import Optional
from talon import Module, actions
from ..targets.target_types import CursorlessTarget
mod = Module()
@mod.action_class
class Actions:
def cursorless_get_text(
target: CursorlessTarget, # pyright: ignore [reportGeneralTypeIssues]
hide_decorations: bool = False,
) -> str:
"""Get target text. If hide_decorations is True, don't show decorations"""
return cursorless_get_text_action(
target,
show_decorations=not hide_decorations,
ensure_single_target=True,
)[0]
def cursorless_get_text_list(
target: CursorlessTarget, # pyright: ignore [reportGeneralTypeIssues]
hide_decorations: bool = False,
) -> list[str]:
"""Get texts for multiple targets. If hide_decorations is True, don't show decorations"""
return cursorless_get_text_action(
target,
show_decorations=not hide_decorations,
ensure_single_target=False,
)
def cursorless_get_text_action(
target: CursorlessTarget,
*,
show_decorations: Optional[bool] = None,
ensure_single_target: Optional[bool] = None,
) -> list[str]:
"""Get target texts"""
options: dict[str, bool] = {}
if show_decorations is not None:
options["showDecorations"] = show_decorations
if ensure_single_target is not None:
options["ensureSingleTarget"] = ensure_single_target
return actions.user.private_cursorless_command_get(
{
"name": "getText",
"options": options,
"target": target,
}
)
@@ -0,0 +1,39 @@
from typing import Optional
from talon import actions, app
from ..targets.target_types import (
CursorlessExplicitTarget,
PrimitiveDestination,
)
from .get_text import cursorless_get_text_action
from .replace import cursorless_replace_action
def cursorless_homophones_action(target: CursorlessExplicitTarget):
"""Replaced target with next homophone"""
texts = cursorless_get_text_action(target, show_decorations=False)
try:
updated_texts = list(map(get_next_homophone, texts))
except LookupError as e:
app.notify(str(e))
return
destination = PrimitiveDestination("to", target)
cursorless_replace_action(destination, updated_texts)
def get_next_homophone(word: str) -> str:
homophones: Optional[list[str]] = actions.user.homophones_get(word)
if not homophones:
raise LookupError(f"Found no homophones for '{word}'")
index = (homophones.index(word.lower()) + 1) % len(homophones)
homophone = homophones[index]
return format_homophone(word, homophone)
def format_homophone(word: str, homophone: str) -> str:
if word.isupper():
return homophone.upper()
if word == word.capitalize():
return homophone.capitalize()
return homophone
@@ -0,0 +1,21 @@
from talon import Module, actions
from ..targets.target_types import CursorlessDestination
mod = Module()
mod.list("cursorless_paste_action", desc="Cursorless paste action")
@mod.action_class
class Actions:
def private_cursorless_paste(
destination: CursorlessDestination, # pyright: ignore [reportGeneralTypeIssues]
):
"""Execute Cursorless paste action"""
actions.user.private_cursorless_command_and_wait(
{
"name": "pasteFromClipboard",
"destination": destination,
}
)
@@ -0,0 +1,25 @@
from talon import Module, actions
from ..targets.target_types import (
CursorlessExplicitTarget,
PrimitiveDestination,
)
from .get_text import cursorless_get_text_action
from .replace import cursorless_replace_action
mod = Module()
mod.list("cursorless_reformat_action", desc="Cursorless reformat action")
@mod.action_class
class Actions:
def cursorless_reformat(
target: CursorlessExplicitTarget, # pyright: ignore [reportGeneralTypeIssues]
formatters: str,
):
"""Execute Cursorless reformat action. Reformat target with formatter"""
texts = cursorless_get_text_action(target, show_decorations=False)
updated_texts = [actions.user.reformat_text(text, formatters) for text in texts]
destination = PrimitiveDestination("to", target)
cursorless_replace_action(destination, updated_texts)
@@ -0,0 +1,16 @@
from talon import actions
from ..targets.target_types import CursorlessDestination
def cursorless_replace_action(
destination: CursorlessDestination, replace_with: list[str]
):
"""Execute Cursorless replace action. Replace targets with texts"""
actions.user.private_cursorless_command_and_wait(
{
"name": "replace",
"replaceWith": replace_with,
"destination": destination,
}
)
+49
View File
@@ -0,0 +1,49 @@
from dataclasses import dataclass
from talon import Module, actions
from ..targets.target_types import CursorlessTarget, ImplicitTarget
@dataclass
class SwapTargets:
target1: CursorlessTarget
target2: CursorlessTarget
mod = Module()
mod.list("cursorless_swap_action", desc="Cursorless swap action")
mod.list(
"cursorless_swap_connective",
desc="The connective used to separate swap targets",
)
@mod.capture(
rule=(
"[<user.cursorless_target>] {user.cursorless_swap_connective} <user.cursorless_target>"
)
)
def cursorless_swap_targets(m) -> SwapTargets:
targets = m.cursorless_target_list
return SwapTargets(
ImplicitTarget() if len(targets) == 1 else targets[0],
targets[-1],
)
@mod.action_class
class Actions:
def private_cursorless_swap(
targets: SwapTargets, # pyright: ignore [reportGeneralTypeIssues]
):
"""Execute Cursorless swap action"""
actions.user.private_cursorless_command_and_wait(
{
"name": "swapTargets",
"target1": targets.target1,
"target2": targets.target2,
}
)
+60
View File
@@ -0,0 +1,60 @@
from talon import Module, actions
from ..targets.target_types import CursorlessTarget
mod = Module()
mod.list("cursorless_wrap_action", desc="Cursorless wrap action")
@mod.action_class
class Actions:
def private_cursorless_wrap_with_paired_delimiter(
action_name: str, # pyright: ignore [reportGeneralTypeIssues]
target: CursorlessTarget,
paired_delimiter: list[str],
):
"""Execute Cursorless wrap/rewrap with paired delimiter action"""
if action_name == "rewrap":
action_name = "rewrapWithPairedDelimiter"
actions.user.private_cursorless_command_and_wait(
{
"name": action_name,
"left": paired_delimiter[0],
"right": paired_delimiter[1],
"target": target,
}
)
def private_cursorless_wrap_with_snippet(
action_name: str, # pyright: ignore [reportGeneralTypeIssues]
target: CursorlessTarget,
snippet_location: str,
):
"""Execute Cursorless wrap with snippet action"""
if action_name == "wrapWithPairedDelimiter":
action_name = "wrapWithSnippet"
elif action_name == "rewrap":
raise Exception("Rewrapping with snippet not supported")
snippet_name, variable_name = parse_snippet_location(snippet_location)
actions.user.private_cursorless_command_and_wait(
{
"name": action_name,
"snippetDescription": {
"type": "named",
"name": snippet_name,
"variableName": variable_name,
},
"target": target,
}
)
def parse_snippet_location(snippet_location: str) -> tuple[str, str]:
[snippet_name, variable_name] = snippet_location.split(".")
if snippet_name is None or variable_name is None:
raise Exception("Snippet location missing '.'")
return (snippet_name, variable_name)
@@ -0,0 +1,29 @@
from talon import Context, actions
ctx = Context()
ctx.matches = r"""
app: vscode
# Disable Cursorless when VS Code is displaying a native OS dialog during which the command server
# hotkey will not work.
not win.title: /^(Open Folder|Open File|Save As|Open Workspace from File|Add Folder to Workspace|Save Workspace)$/i
"""
ctx.tags = ["user.cursorless"]
@ctx.action_class("user")
class Actions:
def private_cursorless_show_settings_in_ide():
"""Show Cursorless-specific settings in ide"""
actions.user.private_cursorless_run_rpc_command_no_wait(
"workbench.action.openSettings", "@ext:pokey.cursorless "
)
actions.sleep("250ms")
actions.key("right")
def private_cursorless_show_sidebar():
"""Show Cursorless sidebar"""
actions.user.private_cursorless_run_rpc_command_and_wait(
"workbench.view.extension.cursorless"
)
@@ -0,0 +1,118 @@
import os
from pathlib import Path
from typing import Any
from talon import Context, Module, actions
from ..vendor.jstyleson import loads
mod = Module()
windows_ctx = Context()
mac_ctx = Context()
linux_ctx = Context()
windows_ctx.matches = r"""
os: windows
"""
mac_ctx.matches = r"""
os: mac
"""
linux_ctx.matches = r"""
os: linux
"""
@mod.action_class
class Actions:
def vscode_settings_path() -> Path:
"""Get path of vscode settings json file"""
...
def vscode_get_setting(key: str, default_value: Any = None): # pyright: ignore [reportGeneralTypeIssues]
"""Get the value of vscode setting at the given key"""
path: Path = actions.user.vscode_settings_path()
settings: dict = loads(path.read_text())
if default_value is not None:
return settings.get(key, default_value)
else:
return settings[key]
def vscode_get_setting_with_fallback(
key: str, # pyright: ignore [reportGeneralTypeIssues]
default_value: Any,
fallback_value: Any,
fallback_message: str,
) -> tuple[Any, bool]:
"""Returns a vscode setting with a fallback in case there's an error
Args:
key (str): The key of the setting to look up
default_value (Any): The default value to return if the setting is not defined
fallback_value (Any): The value to return if there is an error looking up the setting
fallback_message (str): The message to show to the user if we end up having to use the fallback
Returns:
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
"""
try:
return actions.user.vscode_get_setting(key, default_value), False
except Exception:
print(fallback_message)
return fallback_value, True
def pick_path(paths: list[Path]) -> Path:
existing_paths = [path for path in paths if path.exists()]
if not existing_paths:
paths_str = ", ".join(str(path) for path in paths)
raise FileNotFoundError(
f"Couldn't find VSCode's settings JSON. Tried these paths: {paths_str}"
)
return max(existing_paths, key=lambda path: path.stat().st_mtime)
@mac_ctx.action_class("user")
class MacUserActions:
def vscode_settings_path() -> Path:
application_support = Path.home() / "Library/Application Support"
return pick_path(
[
application_support / "Code/User/settings.json",
application_support / "VSCodium/User/settings.json",
]
)
@linux_ctx.action_class("user")
class LinuxUserActions:
def vscode_settings_path() -> Path:
xdg_config_home = Path(
os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")
)
flatpak_apps = Path.home() / ".var/app"
return pick_path(
[
xdg_config_home / "Code/User/settings.json",
xdg_config_home / "VSCodium/User/settings.json",
xdg_config_home / "Code - OSS/User/settings.json",
xdg_config_home / "Cursor/User/settings.json",
flatpak_apps / "com.visualstudio.code/config/Code/User/settings.json",
flatpak_apps / "com.vscodium.codium/config/VSCodium/User/settings.json",
flatpak_apps
/ "com.visualstudio.code-oss/config/Code - OSS/User/settings.json",
]
)
@windows_ctx.action_class("user")
class WindowsUserActions:
def vscode_settings_path() -> Path:
appdata = Path(os.environ["APPDATA"])
return pick_path(
[
appdata / "Code/User/settings.json",
appdata / "VSCodium/User/settings.json",
]
)
@@ -0,0 +1,162 @@
import webbrowser
from pathlib import Path
from talon import Context, Module, actions, app
from .get_list import get_list, get_lists
from .sections.actions import get_actions
from .sections.compound_targets import get_compound_targets
from .sections.destinations import get_destinations
from .sections.get_scope_visualizer import get_scope_visualizer
from .sections.modifiers import get_modifiers
from .sections.scopes import get_scopes
from .sections.special_marks import get_special_marks
from .sections.tutorial import get_tutorial_entries
mod = Module()
ctx = Context()
ctx.matches = r"""
tag: user.cursorless
"""
instructions_url = "https://www.cursorless.org/docs/"
@mod.action_class
class Actions:
def private_cursorless_cheat_sheet_show_html():
"""Show new cursorless html cheat sheet"""
app.notify(
'Please first focus an app that supports cursorless, eg say "focus code"'
)
def private_cursorless_cheat_sheet_update_json():
"""Update default cursorless cheatsheet json (for developer use only)"""
app.notify(
'Please first focus an app that supports cursorless, eg say "focus code"'
)
def private_cursorless_open_instructions():
"""Open web page with cursorless instructions"""
actions.user.private_cursorless_notify_docs_opened()
webbrowser.open(instructions_url)
@ctx.action_class("user")
class CursorlessActions:
def private_cursorless_cheat_sheet_show_html():
"""Show cursorless html cheat sheet"""
# On Linux browsers installed using snap can't open files in a hidden directory
if app.platform == "linux":
cheatsheet_out_dir = cheatsheet_dir_linux()
cheatsheet_filename = "cursorless-cheatsheet.html"
else:
cheatsheet_out_dir = Path.home() / ".cursorless"
cheatsheet_filename = "cheatsheet.html"
cheatsheet_out_dir.mkdir(parents=True, exist_ok=True)
cheatsheet_out_path = cheatsheet_out_dir / cheatsheet_filename
actions.user.private_cursorless_run_rpc_command_and_wait(
"cursorless.showCheatsheet",
{
"version": 0,
"spokenFormInfo": cursorless_cheat_sheet_get_json(),
"outputPath": str(cheatsheet_out_path),
},
)
webbrowser.open(cheatsheet_out_path.as_uri())
def private_cursorless_cheat_sheet_update_json():
"""Update default cursorless cheatsheet json (for developer use only)"""
actions.user.private_cursorless_run_rpc_command_and_wait(
"cursorless.internal.updateCheatsheetDefaults",
cursorless_cheat_sheet_get_json(),
)
def cheatsheet_dir_linux() -> Path:
"""Get cheatsheet directory for Linux"""
try:
# 1. Get users actual document directory
import platformdirs # pyright: ignore [reportMissingImports]
return Path(platformdirs.user_documents_dir())
except Exception:
# 2. Look for a documents directory in user home
user_documents_dir = Path.home() / "Documents"
if user_documents_dir.is_dir():
return user_documents_dir
# 3. Fall back to user home
return Path.home()
def cursorless_cheat_sheet_get_json():
"""Get cursorless cheat sheet json"""
return {
"sections": [
{
"name": "Actions",
"id": "actions",
"items": get_actions(),
},
{
"name": "Destinations",
"id": "destinations",
"items": get_destinations(),
},
{
"name": "Scopes",
"id": "scopes",
"items": get_scopes(),
},
{
"name": "Scope visualizer",
"id": "scopeVisualizer",
"items": get_scope_visualizer(),
},
{
"name": "Modifiers",
"id": "modifiers",
"items": get_modifiers(),
},
{
"name": "Paired delimiters",
"id": "pairedDelimiters",
"items": get_lists(
[
"wrapper_only_paired_delimiter",
"wrapper_selectable_paired_delimiter",
"selectable_only_paired_delimiter",
"surrounding_pair_scope_type",
],
"pairedDelimiter",
),
},
{
"name": "Special marks",
"id": "specialMarks",
"items": get_special_marks(),
},
{
"name": "Compound targets",
"id": "compoundTargets",
"items": get_compound_targets(),
},
{
"name": "Colors",
"id": "colors",
"items": get_list("hat_color", "hatColor"),
},
{
"name": "Shapes",
"id": "shapes",
"items": get_list("hat_shape", "hatShape"),
},
{
"name": "Tutorial",
"id": "tutorial",
"items": get_tutorial_entries(),
},
]
}
@@ -0,0 +1,97 @@
import re
import typing
from collections.abc import Mapping, Sequence
from typing import Optional, TypedDict
from talon import registry
from ..conventions import get_cursorless_list_name
class Variation(TypedDict):
spokenForm: str
description: str
class ListItemDescriptor(TypedDict):
id: str
type: str
variations: list[Variation]
def get_list(
name: str, type: str, descriptions: Optional[Mapping[str, str]] = None
) -> list[ListItemDescriptor]:
if descriptions is None:
descriptions = {}
items = get_raw_list(name)
return make_dict_readable(type, items, descriptions)
def get_lists(
names: Sequence[str], type: str, descriptions: Optional[Mapping[str, str]] = None
) -> list[ListItemDescriptor]:
return [item for name in names for item in get_list(name, type, descriptions)]
def get_raw_list(name: str) -> Mapping[str, str]:
cursorless_list_name = get_cursorless_list_name(name)
return typing.cast(dict[str, str], registry.lists[cursorless_list_name][0]).copy()
def get_spoken_form_from_list(list_name: str, value: str) -> str | None:
"""Get the spoken form of a value from a list.
Args:
list_name (str): The name of the list.
value (str): The value to look up.
Returns:
str: The spoken form of the value if found, otherwise None.
"""
return next(
(
spoken_form
for spoken_form, v in get_raw_list(list_name).items()
if v == value
),
None,
)
def make_dict_readable(
type: str, dict: Mapping[str, str], descriptions: Mapping[str, str]
) -> list[ListItemDescriptor]:
return [
{
"id": value,
"type": type,
"variations": [
{
"spokenForm": key,
"description": descriptions.get(value, make_readable(value)),
}
],
}
for key, value in dict.items()
]
def make_readable(text: str) -> str:
text, is_private = (
(text[8:], True) if text.startswith("private.") else (text, False)
)
text = text.replace(".", " ")
text = de_camel(text).lower().capitalize()
return f"{text} (PRIVATE)" if is_private else text
def de_camel(text: str) -> str:
"""Replacing camelCase boundaries with blank space"""
return re.sub(
r"(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|(?<=[a-zA-Z])(?=[0-9])|(?<=[0-9])(?=[a-zA-Z])",
" ",
text,
)
@@ -0,0 +1,132 @@
from typing import Callable
from ...actions.actions import ACTION_LIST_NAMES
from ..get_list import ListItemDescriptor, get_raw_list, make_dict_readable
def get_actions() -> list[ListItemDescriptor]:
all_actions = {}
for name in ACTION_LIST_NAMES:
all_actions.update(get_raw_list(name))
multiple_target_action_names = [
"replaceWithTarget",
"moveToTarget",
"swapTargets",
"applyFormatter",
"callAsFunction",
"wrapWithPairedDelimiter",
"rewrap",
"pasteFromClipboard",
]
simple_actions = {
f"{key} <target>": value
for key, value in all_actions.items()
if value not in multiple_target_action_names
}
complex_actions = {
value: key
for key, value in all_actions.items()
if value in multiple_target_action_names
}
swap_connectives = list(get_raw_list("swap_connective").keys())
swap_connective = swap_connectives[0] if swap_connectives else None
items = make_dict_readable(
"action",
simple_actions,
{
"editNewLineAfter": "Edit new line/scope after",
"editNewLineBefore": "Edit new line/scope before",
},
)
fixtures: dict[str, list[tuple[Callable, str]]] = {
"replaceWithTarget": [
(
lambda value: f"{value} <target> <destination>",
"Copy <target> to <destination>",
),
(
lambda value: f"{value} <target>",
"Insert copy of <target> at cursor",
),
],
"pasteFromClipboard": [
(
lambda value: f"{value} <destination>",
"Paste from clipboard at <destination>",
)
],
"moveToTarget": [
(
lambda value: f"{value} <target> <destination>",
"Move <target> to <destination>",
),
(
lambda value: f"{value} <target>",
"Move <target> to cursor position",
),
],
"applyFormatter": [
(
lambda value: f"{value} <formatter> at <target>",
"Reformat <target> as <formatter>",
)
],
"callAsFunction": [
(
lambda value: f"{value} <target>",
"Call <target> on selection",
),
(
lambda value: f"{value} <target 1> on <target 2>",
"Call <target 1> on <target 2>",
),
],
"wrapWithPairedDelimiter": [
(
lambda value: f"<pair> {value} <target>",
"Wrap <target> with <pair>",
)
],
"rewrap": [
(
lambda value: f"<pair> {value} <target>",
"Rewrap <target> with <pair>",
)
],
}
if swap_connective:
fixtures["swapTargets"] = [
(
lambda value: f"{value} <target 1> {swap_connective} <target 2>",
"Swap <target 1> with <target 2>",
),
(
lambda value: f"{value} {swap_connective} <target>",
"Swap selection with <target>",
),
]
for action_id, variations in fixtures.items():
if action_id not in complex_actions:
continue
action = complex_actions[action_id]
items.append(
{
"id": action_id,
"type": "action",
"variations": [
{
"spokenForm": callback(action),
"description": description,
}
for callback, description in variations
],
}
)
return items
@@ -0,0 +1,63 @@
from ..get_list import ListItemDescriptor, get_raw_list, get_spoken_form_from_list
FORMATTERS = {
"rangeExclusive": lambda start, end: f"between {start} and {end}",
"rangeInclusive": lambda start, end: f"{start} through {end}",
"rangeExcludingStart": lambda start, end: f"end of {start} through {end}",
"rangeExcludingEnd": lambda start, end: f"{start} until start of {end}",
"verticalRange": lambda start, end: f"{start} vertically through {end}",
}
def get_compound_targets() -> list[ListItemDescriptor]:
list_connective_term = get_spoken_form_from_list(
"list_connective", "listConnective"
)
vertical_range_term = get_spoken_form_from_list("range_type", "verticalRange")
items: list[ListItemDescriptor] = []
if list_connective_term:
items.append(
{
"id": "listConnective",
"type": "compoundTargetConnective",
"variations": [
{
"spokenForm": f"<target 1> {list_connective_term} <target 2>",
"description": "<target 1> and <target 2>",
},
],
}
)
items.extend(
[
get_entry(spoken_form, id)
for spoken_form, id in get_raw_list("range_connective").items()
]
)
if vertical_range_term:
items.append(get_entry(vertical_range_term, "verticalRange"))
return items
def get_entry(spoken_form, id) -> ListItemDescriptor:
formatter = FORMATTERS[id]
return {
"id": id,
"type": "compoundTargetConnective",
"variations": [
{
"spokenForm": f"<target 1> {spoken_form} <target 2>",
"description": formatter("<target 1>", "<target 2>"),
},
{
"spokenForm": f"{spoken_form} <target>",
"description": formatter("selection", "<target>"),
},
],
}
@@ -0,0 +1,28 @@
from ..get_list import ListItemDescriptor, get_raw_list
def get_destinations() -> list[ListItemDescriptor]:
insertion_modes = {
**{p: "to" for p in get_raw_list("insertion_mode_to")},
**get_raw_list("insertion_mode_before_after"),
}
descriptions = {
"to": "Replace <target>",
"before": "Insert before <target>",
"after": "Insert after <target>",
}
return [
{
"id": f"destination_{id}",
"type": "destination",
"variations": [
{
"spokenForm": f"{spoken_form} <target>",
"description": descriptions[id],
}
],
}
for spoken_form, id in insertion_modes.items()
]
@@ -0,0 +1,47 @@
from ..get_list import ListItemDescriptor, get_list, get_raw_list, make_readable
def get_scope_visualizer() -> list[ListItemDescriptor]:
show_scope_visualizers = list(get_raw_list("show_scope_visualizer").keys())
show_scope_visualizer = (
show_scope_visualizers[0] if show_scope_visualizers else None
)
visualization_types = get_raw_list("visualization_type")
items = get_list("hide_scope_visualizer", "command")
if show_scope_visualizer:
items.append(
{
"id": "show_scope_visualizer",
"type": "command",
"variations": [
{
"spokenForm": f"{show_scope_visualizer} <scope>",
"description": "Visualize <scope>",
},
*[
{
"spokenForm": f"{show_scope_visualizer} <scope> {spoken_form}",
"description": f"Visualize <scope> {make_readable(id).lower()} range",
}
for spoken_form, id in visualization_types.items()
],
],
}
)
items.append(
{
"id": "show_scope_sidebar",
"type": "command",
"variations": [
{
"spokenForm": "bar cursorless",
"description": "Show cursorless sidebar",
},
],
}
)
return items
@@ -0,0 +1,301 @@
from itertools import chain
from typing import Callable, TypedDict
from ..get_list import ListItemDescriptor, Variation, get_raw_list, make_dict_readable
MODIFIER_LIST_NAMES = [
"simple_modifier",
"interior_modifier",
"head_tail_modifier",
"every_scope_modifier",
"ancestor_scope_modifier",
"first_modifier",
"last_modifier",
"previous_next_modifier",
"forward_backward_modifier",
"position",
]
class Entry(TypedDict):
spokenForm: str
description: str
def get_modifiers() -> list[ListItemDescriptor]:
all_modifiers = {}
for name in MODIFIER_LIST_NAMES:
all_modifiers.update(get_raw_list(name))
complex_modifier_ids = [
"extendThroughStartOf",
"extendThroughEndOf",
"every",
"ancestor",
"first",
"last",
"previous",
"next",
"backward",
"forward",
]
simple_modifiers = {
key: value
for key, value in all_modifiers.items()
if value not in complex_modifier_ids
}
complex_modifiers = {
value: key
for key, value in all_modifiers.items()
if value in complex_modifier_ids
}
items = make_dict_readable(
"modifier",
simple_modifiers,
{
"excludeInterior": "Bounding paired delimiters",
"toRawSelection": "No inference",
"leading": "Leading delimiter range",
"trailing": "Trailing delimiter range",
"start": "Empty position at start of target",
"end": "Empty position at end of target",
},
)
if "extendThroughStartOf" in complex_modifiers:
items.append(
{
"id": "extendThroughStartOf",
"type": "modifier",
"variations": [
{
"spokenForm": complex_modifiers["extendThroughStartOf"],
"description": "Extend through start of line/pair",
},
{
"spokenForm": f"{complex_modifiers['extendThroughStartOf']} <modifier>",
"description": "Extend through start of <modifier>",
},
],
}
)
if "extendThroughEndOf" in complex_modifiers:
items.append(
{
"id": "extendThroughEndOf",
"type": "modifier",
"variations": [
{
"spokenForm": complex_modifiers["extendThroughEndOf"],
"description": "Extend through end of line/pair",
},
{
"spokenForm": f"{complex_modifiers['extendThroughEndOf']} <modifier>",
"description": "Extend through end of <modifier>",
},
],
}
)
items.append(
{
"id": "containingScope",
"type": "modifier",
"variations": [
{
"spokenForm": "<scope>",
"description": "Containing instance of <scope>",
},
],
}
)
if "every" in complex_modifiers:
items.append(
{
"id": "every",
"type": "modifier",
"variations": [
{
"spokenForm": f"{complex_modifiers['every']} <scope>",
"description": "Every instance of <scope>",
},
],
}
)
if "ancestor" in complex_modifiers:
items.append(
{
"id": "ancestor",
"type": "modifier",
"variations": [
{
"spokenForm": f"{complex_modifiers['ancestor']} <scope>",
"description": "Grandparent containing instance of <scope>",
},
],
}
)
items.append(get_relative_scope(complex_modifiers))
items.append(get_ordinal_scope(complex_modifiers))
return items
def get_relative_scope(complex_modifiers: dict[str, str]) -> ListItemDescriptor:
variations: list[Variation] = []
fixtures: dict[str, list[tuple[Callable, str]]] = {
"previous": [
(
lambda value: f"{value} <scope>",
"Previous instance of <scope>",
),
(
lambda value: f"<ordinal> {value} <scope>",
"<ordinal> instance of <scope> before target",
),
],
"next": [
(
lambda value: f"{value} <scope>",
"Next instance of <scope>",
),
(
lambda value: f"<ordinal> {value} <scope>",
"<ordinal> instance of <scope> after target",
),
],
"backward": [
(
lambda value: f"<scope> {value}",
"single instance of <scope> including target, going backwards",
)
],
"forward": [
(
lambda value: f"<scope> {value}",
"single instance of <scope> including target, going forwards",
)
],
}
for mod_id, vars in fixtures.items():
if mod_id not in complex_modifiers:
continue
mod = complex_modifiers[mod_id]
for callback, description in vars:
variations.append(
{
"spokenForm": callback(mod),
"description": description,
}
)
if "every" in complex_modifiers:
entries: list[Entry] = []
if "backward" in complex_modifiers:
entries.append(
{
"spokenForm": f"<number> <scope>s {complex_modifiers['backward']}",
"description": "<number> instances of <scope> including target, going backwards",
}
)
entries.append(
{
"spokenForm": "<number> <scope>s",
"description": "<number> instances of <scope> including target, going forwards",
}
)
if "previous" in complex_modifiers:
entries.append(
{
"spokenForm": f"{complex_modifiers['previous']} <number> <scope>s",
"description": "previous <number> instances of <scope>",
}
)
if "next" in complex_modifiers:
entries.append(
{
"spokenForm": f"{complex_modifiers['next']} <number> <scope>s",
"description": "next <number> instances of <scope>",
}
)
variations.extend(generateOptionalEvery(complex_modifiers["every"], *entries))
return {
"id": "relativeScope",
"type": "modifier",
"variations": variations,
}
def get_ordinal_scope(complex_modifiers: dict[str, str]) -> ListItemDescriptor:
variations: list[Variation] = [
{
"spokenForm": "<ordinal> <scope>",
"description": "<ordinal> instance of <scope> in iteration scope",
}
]
if "last" in complex_modifiers:
variations.append(
{
"spokenForm": f"<ordinal> {complex_modifiers['last']} <scope>",
"description": "<ordinal>-to-last instance of <scope> in iteration scope",
}
)
if "every" in complex_modifiers:
entries: list[Entry] = []
if "first" in complex_modifiers:
entries.append(
{
"spokenForm": f"{complex_modifiers['first']} <number> <scope>s",
"description": "first <number> instances of <scope> in iteration scope",
}
)
if "last" in complex_modifiers:
entries.append(
{
"spokenForm": f"{complex_modifiers['last']} <number> <scope>s",
"description": "last <number> instances of <scope> in iteration scope",
}
)
variations.extend(generateOptionalEvery(complex_modifiers["every"], *entries))
return {
"id": "ordinalScope",
"type": "modifier",
"variations": variations,
}
def generateOptionalEvery(every: str, *entries: Entry) -> list[Entry]:
return list(
chain.from_iterable(
[
{
"spokenForm": entry["spokenForm"],
"description": f"{entry['description']}, as contiguous range",
},
{
"spokenForm": f"{every} {entry['spokenForm']}",
"description": f"{entry['description']}, as individual targets",
},
]
for entry in entries
)
)
@@ -0,0 +1,44 @@
from ..get_list import ListItemDescriptor, get_lists, get_spoken_form_from_list
def get_scopes() -> list[ListItemDescriptor]:
glyph_spoken_form = get_spoken_form_from_list("glyph_scope_type", "glyph")
items = get_lists(
["scope_type"],
"scopeType",
{
"argumentOrParameter": "Argument",
"boundedNonWhitespaceSequence": "Non-whitespace sequence bounded by surrounding pair delimeters",
"boundedParagraph": "Paragraph bounded by surrounding pair delimeters",
},
)
if glyph_spoken_form:
items.append(
{
"id": "glyph",
"type": "scopeType",
"variations": [
{
"spokenForm": f"{glyph_spoken_form} <character>",
"description": "Instance of single character <character>",
},
],
}
)
items.append(
{
"id": "pair",
"type": "scopeType",
"variations": [
{
"spokenForm": "<pair>",
"description": "Paired delimiters",
},
],
},
)
return items
@@ -0,0 +1,20 @@
from ..get_list import ListItemDescriptor, get_lists, get_raw_list, make_dict_readable
def get_special_marks() -> list[ListItemDescriptor]:
line_direction_marks = make_dict_readable(
"mark",
{
f"{key} <number>": value
for key, value in get_raw_list("line_direction").items()
},
{
"lineNumberRelativeUp": "Line number up from cursor",
"lineNumberRelativeDown": "Line number down from cursor",
},
)
return [
*get_lists(["simple_mark", "unknown_symbol"], "mark"),
*line_direction_marks,
]
@@ -0,0 +1,86 @@
from ..get_list import ListItemDescriptor
def get_tutorial_entries() -> list[ListItemDescriptor]:
return [
{
"id": "start_tutorial",
"type": "command",
"variations": [
{
"spokenForm": "cursorless tutorial",
"description": "Start the introductory Cursorless tutorial",
},
],
},
{
"id": "tutorial_next",
"type": "command",
"variations": [
{
"spokenForm": "tutorial next",
"description": "Advance to next step in tutorial",
},
],
},
{
"id": "tutorial_previous",
"type": "command",
"variations": [
{
"spokenForm": "tutorial previous",
"description": "Go back to previous step in tutorial",
},
],
},
{
"id": "tutorial_restart",
"type": "command",
"variations": [
{
"spokenForm": "tutorial restart",
"description": "Restart the tutorial",
},
],
},
{
"id": "tutorial_resume",
"type": "command",
"variations": [
{
"spokenForm": "tutorial resume",
"description": "Resume the tutorial",
},
],
},
{
"id": "tutorial_list",
"type": "command",
"variations": [
{
"spokenForm": "tutorial list",
"description": "List all available tutorials",
},
],
},
{
"id": "tutorial_close",
"type": "command",
"variations": [
{
"spokenForm": "tutorial close",
"description": "Close the tutorial",
},
],
},
{
"id": "tutorial_start_by_number",
"type": "command",
"variations": [
{
"spokenForm": "tutorial <number>",
"description": "Start a specific tutorial by number",
},
],
},
]
@@ -0,0 +1,39 @@
from talon import app, registry
required_captures = [
"number_small",
"user.any_alphanumeric_key",
"user.formatters",
"user.ordinals_small",
]
required_actions = [
"code.language",
"user.homophones_get",
"user.insert_snippet_by_name",
"user.reformat_text",
]
def on_ready():
missing_captures = [
capture for capture in required_captures if capture not in registry.captures
]
missing_actions = [
action for action in required_actions if action not in registry.actions
]
errors = []
if missing_captures:
errors.append(f"Missing captures: {', '.join(missing_captures)}")
if missing_actions:
errors.append(f"Missing actions: {', '.join(missing_actions)}")
if errors:
print("Cursorless community requirements:")
print("\n".join(errors))
app.notify(
"Cursorless: Please install the community repository",
body="https://github.com/talonhub/community",
)
app.register("ready", on_ready)
+109
View File
@@ -0,0 +1,109 @@
import dataclasses
from typing import Any
from talon import Module, actions, speech_system
from .fallback import perform_fallback
from .versions import COMMAND_VERSION
@dataclasses.dataclass
class CursorlessCommand:
version = COMMAND_VERSION
spokenForm: str
usePrePhraseSnapshot: bool
action: dict
CURSORLESS_COMMAND_ID = "cursorless.command"
last_phrase: dict = {}
mod = Module()
def on_phrase(d):
global last_phrase
last_phrase = d
speech_system.register("pre:phrase", on_phrase)
@mod.action_class
class Actions:
def private_cursorless_command_and_wait(action: dict): # pyright: ignore [reportGeneralTypeIssues]
"""Execute cursorless command and wait for it to finish"""
response = actions.user.private_cursorless_run_rpc_command_get(
CURSORLESS_COMMAND_ID,
construct_cursorless_command(action),
)
if "fallback" in response:
perform_fallback(response["fallback"])
def private_cursorless_command_no_wait(action: dict): # pyright: ignore [reportGeneralTypeIssues]
"""Execute cursorless command without waiting"""
actions.user.private_cursorless_run_rpc_command_no_wait(
CURSORLESS_COMMAND_ID,
construct_cursorless_command(action),
)
def private_cursorless_command_get(action: dict): # pyright: ignore [reportGeneralTypeIssues]
"""Execute cursorless command and return result"""
response = actions.user.private_cursorless_run_rpc_command_get(
CURSORLESS_COMMAND_ID,
construct_cursorless_command(action),
)
if "fallback" in response:
return perform_fallback(response["fallback"])
if "returnValue" in response:
return response["returnValue"]
return None
def construct_cursorless_command(action: dict) -> dict:
try:
use_pre_phrase_snapshot = actions.user.did_emit_pre_phrase_signal()
except KeyError:
use_pre_phrase_snapshot = False
spoken_form = " ".join(last_phrase["phrase"])
return make_serializable(
CursorlessCommand(
spoken_form,
use_pre_phrase_snapshot,
action,
)
)
def make_serializable(value: Any) -> Any:
"""
Converts a dataclass into a serializable dict
Note that we don't use the built-in asdict() function because it will
ignore the static `type` field.
Args:
value (any): The value to convert
Returns:
_type_: The converted value, ready for serialization
"""
if isinstance(value, dict):
return {k: make_serializable(v) for k, v in value.items()}
if isinstance(value, list):
return [make_serializable(v) for v in value]
if dataclasses.is_dataclass(value):
items = {
**{
k: v
for k, v in vars(type(value)).items()
if not k.startswith("_")
and not isinstance(v, property)
and not isinstance(v, staticmethod)
},
**value.__dict__,
}
return {k: make_serializable(v) for k, v in items.items() if v is not None}
return value
+2
View File
@@ -0,0 +1,2 @@
def get_cursorless_list_name(name: str):
return f"user.cursorless_{name}"
+488
View File
@@ -0,0 +1,488 @@
import csv
import typing
from collections import defaultdict
from collections.abc import Container
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Callable, Iterable, Optional, TypedDict
from talon import Context, Module, actions, app, fs, settings
from .conventions import get_cursorless_list_name
from .vendor.inflection import pluralize
SPOKEN_FORM_HEADER = "Spoken form"
CURSORLESS_IDENTIFIER_HEADER = "Cursorless identifier"
mod = Module()
mod.tag(
"cursorless_default_vocabulary",
desc="Use default cursorless vocabulary instead of user custom",
)
mod.setting(
"cursorless_settings_directory",
type=str,
default="cursorless-settings",
desc="The directory to use for cursorless settings csvs relative to talon user directory",
)
# The global context we use for our lists
ctx = Context()
# A context that contains default vocabulary, for use in testing
normalized_ctx = Context()
normalized_ctx.matches = r"""
tag: user.cursorless_default_vocabulary
"""
# Maps from Talon list name to a map from spoken form to value
ListToSpokenForms = dict[str, dict[str, str]]
@dataclass
class SpokenFormEntry:
list_name: str
id: str
spoken_forms: list[str]
def csv_get_ctx():
return ctx
def csv_get_normalized_ctx():
return normalized_ctx
def init_csv_and_watch_changes(
filename: str,
default_values: ListToSpokenForms,
handle_new_values: Optional[Callable[[list[SpokenFormEntry]], None]] = None,
*,
extra_ignored_values: Optional[list[str]] = None,
extra_allowed_values: Optional[list[str]] = None,
allow_unknown_values: bool = False,
deprecated: bool = False,
default_list_name: Optional[str] = None,
headers: list[str] = [SPOKEN_FORM_HEADER, CURSORLESS_IDENTIFIER_HEADER],
no_update_file: bool = False,
pluralize_lists: Optional[list[str]] = None,
):
"""
Initialize a cursorless settings csv, creating it if necessary, and watch
for changes to the csv. Talon lists will be generated based on the keys of
`default_values`. For example, if there is a key `foo`, there will be a
list created called `user.cursorless_foo` that will contain entries from the
original dict at the key `foo`, updated according to customization in the
csv at
```
actions.path.talon_user() / "cursorless-settings" / filename
```
Note that the settings directory location can be customized using the
`user.cursorless_settings_directory` setting.
Args:
filename (str): The name of the csv file to be placed in
`cursorles-settings` dir
default_values (ListToSpokenForms): The default values for the lists to
be customized in the given csv
handle_new_values (Optional[Callable[[list[SpokenFormEntry]], None]]): A
callback to be called when the lists are updated
extra_ignored_values (Optional[list[str]]): Don't throw an exception if
any of these appear as values; just ignore them and don't add them
to any list
allow_unknown_values (bool): If unknown values appear, just put them in
the list
default_list_name (Optional[str]): If unknown values are
allowed, put any unknown values in this list
headers (list[str]): The headers to use for the csv
no_update_file (bool): Set this to `True` to indicate that we should not
update the csv. This is used generally in case there was an issue
coming up with the default set of values so we don't want to persist
those to disk
pluralize_lists (list[str]): Create plural version of given lists
"""
# Don't allow both `extra_allowed_values` and `allow_unknown_values`
assert not (extra_allowed_values and allow_unknown_values)
# If `extra_allowed_values` or `allow_unknown_values` is given, we need a
# `default_list_name` to put unknown values in
assert not (
(extra_allowed_values or allow_unknown_values) and not default_list_name
)
if extra_ignored_values is None:
extra_ignored_values = []
if extra_allowed_values is None:
extra_allowed_values = []
if pluralize_lists is None:
pluralize_lists = []
file_path = get_full_path(filename)
is_file = file_path.is_file()
# Deprecated file that doesn't exist. Do nothing.
if deprecated and not is_file:
return lambda: None
super_default_values = get_super_values(default_values)
file_path.parent.mkdir(parents=True, exist_ok=True)
check_for_duplicates(filename, default_values)
create_default_vocabulary_dicts(default_values, pluralize_lists)
def on_watch(path, flags):
if file_path.match(path):
current_values, has_errors = read_file(
path=file_path,
headers=headers,
default_identifiers=super_default_values.values(),
extra_ignored_values=extra_ignored_values,
extra_allowed_values=extra_allowed_values,
allow_unknown_values=allow_unknown_values,
)
update_dicts(
default_values=default_values,
current_values=current_values,
extra_ignored_values=extra_ignored_values,
extra_allowed_values=extra_allowed_values,
allow_unknown_values=allow_unknown_values,
default_list_name=default_list_name,
pluralize_lists=pluralize_lists,
handle_new_values=handle_new_values,
)
fs.watch(file_path.parent, on_watch)
if is_file:
current_values = update_file(
path=file_path,
headers=headers,
default_values=super_default_values,
extra_ignored_values=extra_ignored_values,
extra_allowed_values=extra_allowed_values,
allow_unknown_values=allow_unknown_values,
no_update_file=no_update_file,
)
update_dicts(
default_values=default_values,
current_values=current_values,
extra_ignored_values=extra_ignored_values,
extra_allowed_values=extra_allowed_values,
allow_unknown_values=allow_unknown_values,
default_list_name=default_list_name,
pluralize_lists=pluralize_lists,
handle_new_values=handle_new_values,
)
else:
if not no_update_file:
create_file(file_path, headers, super_default_values)
update_dicts(
default_values=default_values,
current_values=super_default_values,
extra_ignored_values=extra_ignored_values,
extra_allowed_values=extra_allowed_values,
allow_unknown_values=allow_unknown_values,
default_list_name=default_list_name,
pluralize_lists=pluralize_lists,
handle_new_values=handle_new_values,
)
def unsubscribe():
fs.unwatch(file_path.parent, on_watch)
return unsubscribe
def check_for_duplicates(filename, default_values):
results_map = {}
for list_name, dict in default_values.items():
for key, value in dict.items():
if value in results_map:
existing_list_name = results_map[value]
warning = f"WARNING ({filename}): Value `{value}` duplicated between lists '{existing_list_name}' and '{list_name}'"
print(warning)
app.notify(warning)
else:
results_map[value] = list_name
def is_removed(value: str):
return value.startswith("-")
def create_default_vocabulary_dicts(
default_values: dict[str, dict], pluralize_lists: list[str]
):
default_values_updated = {}
for key, value in default_values.items():
updated_dict = {}
for key2, value2 in value.items():
# Enable deactivated(prefixed with a `-`) items
active_key = key2[1:] if key2.startswith("-") else key2
if active_key:
updated_dict[active_key] = value2
default_values_updated[key] = updated_dict
assign_lists_to_context(normalized_ctx, default_values_updated, pluralize_lists)
def update_dicts(
default_values: ListToSpokenForms,
current_values: dict[str, str],
extra_ignored_values: list[str],
extra_allowed_values: list[str],
allow_unknown_values: bool,
default_list_name: str | None,
pluralize_lists: list[str],
handle_new_values: Callable[[list[SpokenFormEntry]], None] | None,
):
# Create map with all default values
results_map: dict[str, ResultsListEntry] = {}
for list_name, obj in default_values.items():
for spoken, id in obj.items():
results_map[id] = {"spoken": spoken, "id": id, "list": list_name}
# Update result with current values
for spoken, id in current_values.items():
try:
results_map[id]["spoken"] = spoken
except KeyError:
if id in extra_ignored_values:
pass
elif allow_unknown_values or id in extra_allowed_values:
assert default_list_name is not None
results_map[id] = {
"spoken": spoken,
"id": id,
"list": default_list_name,
}
else:
raise
spoken_form_entries = list(generate_spoken_forms(results_map.values()))
# Assign result to talon context list
lists: ListToSpokenForms = defaultdict(dict)
for entry in spoken_form_entries:
for spoken_form in entry.spoken_forms:
lists[entry.list_name][spoken_form] = entry.id
assign_lists_to_context(ctx, lists, pluralize_lists)
if handle_new_values is not None:
handle_new_values(spoken_form_entries)
class ResultsListEntry(TypedDict):
spoken: str
id: str
list: str
def generate_spoken_forms(results_list: Iterable[ResultsListEntry]):
for obj in results_list:
id = obj["id"]
spoken = obj["spoken"]
spoken_forms = []
if not is_removed(spoken):
for k in spoken.split("|"):
if id == "pasteFromClipboard" and k.endswith(" to"):
# FIXME: This is a hack to work around the fact that the
# spoken form of the `pasteFromClipboard` action used to be
# "paste to", but now the spoken form is just "paste" and
# the "to" is part of the positional target. Users who had
# cursorless before this change would have "paste to" as
# their spoken form and so would need to say "paste to to".
k = k[:-3]
spoken_forms.append(k.strip())
yield SpokenFormEntry(
list_name=obj["list"],
id=id,
spoken_forms=spoken_forms,
)
def assign_lists_to_context(
ctx: Context,
lists: ListToSpokenForms,
pluralize_lists: list[str],
):
for list_name, dict in lists.items():
list_singular_name = get_cursorless_list_name(list_name)
ctx.lists[list_singular_name] = dict
if list_name in pluralize_lists:
list_plural_name = f"{list_singular_name}_plural"
ctx.lists[list_plural_name] = {pluralize(k): v for k, v in dict.items()}
def update_file(
path: Path,
headers: list[str],
default_values: dict[str, str],
extra_ignored_values: list[str],
extra_allowed_values: list[str],
allow_unknown_values: bool,
no_update_file: bool,
):
current_values, has_errors = read_file(
path=path,
headers=headers,
default_identifiers=default_values.values(),
extra_ignored_values=extra_ignored_values,
extra_allowed_values=extra_allowed_values,
allow_unknown_values=allow_unknown_values,
)
current_identifiers = current_values.values()
missing = {}
for key, value in default_values.items():
if value not in current_identifiers:
missing[key] = value
if missing:
if has_errors or no_update_file:
print(
"NOTICE: New cursorless features detected, but refusing to update "
"csv due to errors. Please fix csv errors above and restart talon"
)
else:
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
lines = [
f"# {timestamp} - New entries automatically added by cursorless",
*[create_line(key, missing[key]) for key in sorted(missing)],
]
with open(path, "a") as f:
f.write("\n\n" + "\n".join(lines))
print(f"New cursorless features added to {path.name}")
for key in sorted(missing):
print(f"{key}: {missing[key]}")
print(
"See release notes for more info: "
"https://github.com/cursorless-dev/cursorless/blob/main/CHANGELOG.md"
)
app.notify("🎉🎉 New cursorless features; see log")
return current_values
def create_line(*cells: str):
return ", ".join(cells)
def create_file(path: Path, headers: list[str], default_values: dict):
lines = [create_line(key, default_values[key]) for key in sorted(default_values)]
lines.insert(0, create_line(*headers))
lines.append("")
path.write_text("\n".join(lines))
def csv_error(path: Path, index: int, message: str, value: str):
"""Check that an expected condition is true
Note that we try to continue reading in this case so cursorless doesn't get bricked
Args:
path (Path): The path of the CSV (for error reporting)
index (int): The index into the file (for error reporting)
text (str): The text of the error message to report if condition is false
"""
print(f"ERROR: {path}:{index + 1}: {message} '{value}'")
def read_file(
path: Path,
headers: list[str],
default_identifiers: Container[str],
extra_ignored_values: list[str],
extra_allowed_values: list[str],
allow_unknown_values: bool,
):
with open(path) as csv_file:
# Use `skipinitialspace` to allow spaces before quote. `, "a,b"`
csv_reader = csv.reader(csv_file, skipinitialspace=True)
rows = list(csv_reader)
result = {}
used_identifiers = []
has_errors = False
seen_headers = False
for i, row in enumerate(rows):
# Remove trailing whitespaces for each cell
row = [x.rstrip() for x in row]
# Exclude empty or comment rows
if len(row) == 0 or (len(row) == 1 and row[0] == "") or row[0].startswith("#"):
continue
if not seen_headers:
seen_headers = True
if row != headers:
has_errors = True
csv_error(path, i, "Malformed header", create_line(*row))
print(f"Expected '{create_line(*headers)}'")
continue
if len(row) != len(headers):
has_errors = True
csv_error(
path,
i,
f"Malformed csv entry. Expected {len(headers)} columns.",
create_line(*row),
)
continue
key, value = row
if (
value not in default_identifiers
and value not in extra_ignored_values
and value not in extra_allowed_values
and not allow_unknown_values
):
has_errors = True
csv_error(path, i, "Unknown identifier", value)
continue
if value in used_identifiers:
has_errors = True
csv_error(path, i, "Duplicate identifier", value)
continue
result[key] = value
used_identifiers.append(value)
if has_errors:
app.notify("Cursorless settings error; see log")
return result, has_errors
def get_full_path(filename: str):
if not filename.endswith(".csv"):
filename = f"{filename}.csv"
user_dir: Path = actions.path.talon_user()
settings_directory = Path(
typing.cast(str, settings.get("user.cursorless_settings_directory"))
)
if not settings_directory.is_absolute():
settings_directory = user_dir / settings_directory
return (settings_directory / filename).resolve()
def get_super_values(values: ListToSpokenForms):
result: dict[str, str] = {}
for value_dict in values.values():
result.update(value_dict)
return result
+82
View File
@@ -0,0 +1,82 @@
from talon import Context, Module, actions
mod = Module()
mod.tag(
"cursorless",
"Application supporting cursorless commands",
)
ctx = Context()
ctx.matches = r"""
tag: user.cursorless
"""
@mod.action_class
class Actions:
def private_cursorless_show_settings_in_ide():
"""Show Cursorless-specific settings in ide"""
def private_cursorless_show_sidebar():
"""Show Cursorless-specific settings in ide"""
def private_cursorless_notify_docs_opened():
"""Notify the ide that the docs were opened in case the tutorial is waiting for that event"""
actions.skip()
def private_cursorless_show_command_statistics():
"""Show Cursorless command statistics"""
actions.user.private_cursorless_run_rpc_command_no_wait(
"cursorless.analyzeCommandHistory"
)
def private_cursorless_start_tutorial():
"""Start the introductory Cursorless tutorial"""
actions.user.private_cursorless_run_rpc_command_no_wait(
"cursorless.tutorial.start", "tutorial-1-basics"
)
def private_cursorless_tutorial_next():
"""Cursorless tutorial: next"""
actions.user.private_cursorless_run_rpc_command_no_wait(
"cursorless.tutorial.next"
)
def private_cursorless_tutorial_previous():
"""Cursorless tutorial: previous"""
actions.user.private_cursorless_run_rpc_command_no_wait(
"cursorless.tutorial.previous"
)
def private_cursorless_tutorial_restart():
"""Cursorless tutorial: restart"""
actions.user.private_cursorless_run_rpc_command_no_wait(
"cursorless.tutorial.restart"
)
def private_cursorless_tutorial_resume():
"""Cursorless tutorial: resume"""
actions.user.private_cursorless_run_rpc_command_no_wait(
"cursorless.tutorial.resume"
)
def private_cursorless_tutorial_list():
"""Cursorless tutorial: list all available tutorials"""
actions.user.private_cursorless_run_rpc_command_no_wait(
"cursorless.tutorial.list"
)
def private_cursorless_tutorial_start_by_number(number: int): # pyright: ignore [reportGeneralTypeIssues]
"""Start Cursorless tutorial by number"""
actions.user.private_cursorless_run_rpc_command_no_wait(
"cursorless.tutorial.start", number - 1
)
@ctx.action_class("user")
class CursorlessActions:
def private_cursorless_notify_docs_opened():
actions.user.private_cursorless_run_rpc_command_no_wait(
"cursorless.documentationOpened"
)
@@ -0,0 +1,58 @@
mode: command
mode: user.cursorless_spoken_form_test
tag: user.cursorless
-
<user.cursorless_action_or_ide_command> <user.cursorless_target>:
user.private_cursorless_action_or_ide_command(cursorless_action_or_ide_command, cursorless_target)
{user.cursorless_bring_move_action} <user.cursorless_bring_move_targets>:
user.private_cursorless_bring_move(cursorless_bring_move_action, cursorless_bring_move_targets)
{user.cursorless_swap_action} <user.cursorless_swap_targets>:
user.private_cursorless_swap(cursorless_swap_targets)
{user.cursorless_paste_action} <user.cursorless_destination>:
user.private_cursorless_paste(cursorless_destination)
{user.cursorless_reformat_action} <user.formatters> at <user.cursorless_target>:
user.cursorless_reformat(cursorless_target, formatters)
{user.cursorless_call_action} <user.cursorless_target> on <user.cursorless_target>:
user.private_cursorless_call(cursorless_target_1, cursorless_target_2)
<user.cursorless_wrapper_paired_delimiter> {user.cursorless_wrap_action} <user.cursorless_target>:
user.private_cursorless_wrap_with_paired_delimiter(cursorless_wrap_action, cursorless_target, cursorless_wrapper_paired_delimiter)
{user.cursorless_insert_snippet_action} {user.snippet} <user.cursorless_destination>:
user.private_cursorless_insert_community_snippet(snippet, cursorless_destination)
{user.snippet_wrapper} {user.cursorless_wrap_action} <user.cursorless_target>:
user.private_cursorless_wrap_with_community_snippet(snippet_wrapper, cursorless_target)
{user.cursorless_show_scope_visualizer} <user.cursorless_scope_type> [{user.cursorless_visualization_type}]:
user.private_cursorless_show_scope_visualizer(cursorless_scope_type, cursorless_visualization_type or "content")
{user.cursorless_hide_scope_visualizer}:
user.private_cursorless_hide_scope_visualizer()
{user.cursorless_homophone} settings:
user.private_cursorless_show_settings_in_ide()
bar {user.cursorless_homophone}:
user.private_cursorless_show_sidebar()
{user.cursorless_homophone} stats:
user.private_cursorless_show_command_statistics()
{user.cursorless_homophone} tutorial:
user.private_cursorless_start_tutorial()
tutorial next: user.private_cursorless_tutorial_next()
tutorial (previous | last): user.private_cursorless_tutorial_previous()
tutorial restart: user.private_cursorless_tutorial_restart()
tutorial resume: user.private_cursorless_tutorial_resume()
tutorial (list | close): user.private_cursorless_tutorial_list()
tutorial <number_small>:
user.private_cursorless_tutorial_start_by_number(number_small)
{user.cursorless_homophone} migrate snippets:
user.private_cursorless_migrate_snippets()
@@ -0,0 +1,41 @@
from typing import Any
from talon import Module, actions
mod = Module()
@mod.action_class
class Actions:
def private_cursorless_run_rpc_command_and_wait(
command_id: str, # pyright: ignore [reportGeneralTypeIssues]
arg1: Any = None,
arg2: Any = None,
):
"""Execute command via rpc and wait for command to finish."""
try:
actions.user.run_rpc_command_and_wait(command_id, arg1, arg2)
except KeyError:
actions.user.vscode_with_plugin_and_wait(command_id, arg1, arg2)
def private_cursorless_run_rpc_command_no_wait(
command_id: str, # pyright: ignore [reportGeneralTypeIssues]
arg1: Any = None,
arg2: Any = None,
):
"""Execute command via rpc and DON'T wait."""
try:
actions.user.run_rpc_command(command_id, arg1, arg2)
except KeyError:
actions.user.vscode_with_plugin(command_id, arg1, arg2)
def private_cursorless_run_rpc_command_get(
command_id: str, # pyright: ignore [reportGeneralTypeIssues]
arg1: Any = None,
arg2: Any = None,
) -> Any:
"""Execute command via rpc and return command output."""
try:
return actions.user.run_rpc_command_get(command_id, arg1, arg2)
except KeyError:
return actions.user.vscode_get(command_id, arg1, arg2)
@@ -0,0 +1,4 @@
{user.cursorless_homophone} (reference | ref | cheatsheet | cheat sheet):
user.private_cursorless_cheat_sheet_show_html()
{user.cursorless_homophone} (instructions | docks | help) | help {user.cursorless_homophone}:
user.private_cursorless_open_instructions()
+117
View File
@@ -0,0 +1,117 @@
from typing import Callable
from talon import actions
from .versions import COMMAND_VERSION
# This ensures that we remember to update fallback if the response payload changes
assert COMMAND_VERSION == 7
action_callbacks = {
"setSelection": actions.skip,
"setSelectionBefore": actions.edit.left,
"setSelectionAfter": actions.edit.right,
"copyToClipboard": actions.edit.copy,
"cutToClipboard": actions.edit.cut,
"pasteFromClipboard": actions.edit.paste,
"clearAndSetSelection": actions.edit.delete,
"remove": actions.edit.delete,
"editNewLineBefore": actions.edit.line_insert_up,
"editNewLineAfter": actions.edit.line_insert_down,
"insertCopyAfter": actions.edit.line_clone,
}
modifier_callbacks = {
"extendThroughStartOf.line": actions.user.select_line_start,
"extendThroughEndOf.line": actions.user.select_line_end,
"containingScope.document": actions.edit.select_all,
"containingScope.paragraph": actions.edit.select_paragraph,
"containingScope.line": actions.edit.select_line,
"containingScope.token": actions.edit.select_word,
}
def call_as_function(callee: str):
wrap_with_paired_delimiter(f"{callee}(", ")")
def wrap_with_paired_delimiter(left: str, right: str):
selected = actions.edit.selected_text()
actions.insert(f"{left}{selected}{right}")
for _ in right:
actions.edit.left()
def containing_token_if_empty():
if actions.edit.selected_text() == "":
actions.edit.select_word()
def perform_fallback(fallback: dict):
try:
modifier_callbacks = get_modifier_callbacks(fallback)
action_callback = get_action_callback(fallback)
for callback in reversed(modifier_callbacks):
callback()
return action_callback()
except ValueError as ex:
actions.app.notify(str(ex))
raise ex
def get_action_callback(fallback: dict) -> Callable:
action = fallback["action"]
if action in action_callbacks:
return action_callbacks[action]
match action:
case "insert":
return lambda: actions.insert(fallback["text"])
case "callAsFunction":
return lambda: call_as_function(fallback["callee"])
case "wrapWithPairedDelimiter":
return lambda: wrap_with_paired_delimiter(
fallback["left"], fallback["right"]
)
case "getText":
return lambda: [actions.edit.selected_text()]
case "findInWorkspace":
return lambda: actions.user.find_everywhere(actions.edit.selected_text())
case "findInDocument":
return lambda: actions.edit.find(actions.edit.selected_text())
raise ValueError(f"Unknown Cursorless fallback action: {action}")
def get_modifier_callbacks(fallback: dict) -> list[Callable]:
return [get_modifier_callback(modifier) for modifier in fallback["modifiers"]]
def get_modifier_callback(modifier: dict) -> Callable:
modifier_type = modifier["type"]
match modifier_type:
case "containingTokenIfEmpty":
return containing_token_if_empty
case "containingScope":
scope_type_type = modifier["scopeType"]["type"]
return get_simple_modifier_callback(f"{modifier_type}.{scope_type_type}")
case "preferredScope":
scope_type_type = modifier["scopeType"]["type"]
return get_simple_modifier_callback(f"containingScope.{scope_type_type}")
case "extendThroughStartOf":
if "modifiers" not in modifier:
return get_simple_modifier_callback(f"{modifier_type}.line")
case "extendThroughEndOf":
if "modifiers" not in modifier:
return get_simple_modifier_callback(f"{modifier_type}.line")
raise ValueError(f"Unknown Cursorless fallback modifier: {modifier_type}")
def get_simple_modifier_callback(key: str) -> Callable:
try:
return modifier_callbacks[key]
except KeyError:
raise ValueError(f"Unknown Cursorless fallback modifier: {key}")
@@ -0,0 +1,104 @@
import re
import typing
from collections import defaultdict
from typing import Iterator, Mapping
from talon import app, registry, scope
from .spoken_forms_output import SpokenFormOutputEntry
grapheme_capture_name = "user.any_alphanumeric_key"
def get_grapheme_spoken_form_entries(
grapheme_talon_list: dict[str, str],
) -> list[SpokenFormOutputEntry]:
return [
{
"type": "grapheme",
"id": id,
"spokenForms": spoken_forms,
}
for id, spoken_forms in talon_list_to_spoken_form_map(
grapheme_talon_list
).items()
]
def get_graphemes_talon_list() -> dict[str, str]:
if grapheme_capture_name not in registry.captures:
# 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.
# 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.
if "en" in scope.get("language", {}):
app.notify(f"Capture <{grapheme_capture_name}> isn't defined")
print(
f"Capture <{grapheme_capture_name}> isn't defined, which is required by Cursorless. Please check your community setup"
)
return {}
return {
spoken_form: id
for symbol_list in generate_lists_from_capture(grapheme_capture_name)
for spoken_form, id in get_id_to_talon_list(symbol_list).items()
}
def generate_lists_from_capture(capture_name) -> Iterator[str]:
"""
Given the name of a capture, yield the names of each list that the capture
expands to. Note that we are somewhat strict about the format of the
capture rule, and will not handle all possible cases.
"""
if capture_name.startswith("self."):
capture_name = "user." + capture_name[5:]
try:
# NB: [-1] because the last capture is the active one
rule = registry.captures[capture_name][-1].rule.rule
except Exception:
app.notify("Error constructing spoken forms for graphemes")
print(f"Error getting rule for capture {capture_name}")
return
rule = rule.strip()
if rule.startswith("(") and rule.endswith(")"):
rule = rule[1:-1]
rule = rule.strip()
components = re.split(r"\s*\|\s*", rule)
for component in components:
if component.startswith("<") and component.endswith(">"):
yield from generate_lists_from_capture(component[1:-1])
elif component.startswith("{") and component.endswith("}"):
component = component[1:-1]
if component.startswith("self."):
component = "user." + component[5:]
yield component
else:
app.notify("Error constructing spoken forms for graphemes")
print(
f"Unexpected component {component} while processing rule {rule} for capture {capture_name}"
)
def get_id_to_talon_list(list_name: str) -> dict[str, str]:
"""
Given the name of a Talon list, return that list
"""
try:
# NB: [-1] because the last list is the active one
return typing.cast(dict[str, str], registry.lists[list_name][-1]).copy()
except Exception:
app.notify(f"Error getting list {list_name}")
return {}
def talon_list_to_spoken_form_map(
talon_list: dict[str, str],
) -> Mapping[str, list[str]]:
"""
Given a Talon list, return a mapping from the values in that
list to the list of spoken forms that map to the given value.
"""
inverted_list: defaultdict[str, list[str]] = defaultdict(list)
for key, value in talon_list.items():
inverted_list[value].append(key)
return inverted_list
@@ -0,0 +1,183 @@
from pathlib import Path
from typing import Any
from talon import Module, actions, cron, fs
from ..csv_overrides import init_csv_and_watch_changes
from .mark_types import DecoratedSymbol
mod = Module()
mod.list("cursorless_hat_color", desc="Supported hat colors for cursorless")
mod.list("cursorless_hat_shape", desc="Supported hat shapes for cursorless")
mod.list(
"cursorless_unknown_symbol",
"This list contains the term that is used to refer to any unknown symbol",
)
@mod.capture(rule="<user.any_alphanumeric_key> | {user.cursorless_unknown_symbol}")
def cursorless_grapheme(m) -> str:
try:
return m.any_alphanumeric_key
except AttributeError:
# NB: This represents unknown char in Unicode. It will be translated
# to "[unk]" by Cursorless extension.
return "\ufffd"
@mod.capture(
rule="[{user.cursorless_hat_color}] [{user.cursorless_hat_shape}] <user.cursorless_grapheme>"
)
def cursorless_decorated_symbol(m) -> DecoratedSymbol:
"""A decorated symbol"""
hat_color: str = getattr(m, "cursorless_hat_color", "default")
try:
hat_style_name = f"{hat_color}-{m.cursorless_hat_shape}"
except AttributeError:
hat_style_name = hat_color
return {
"type": "decoratedSymbol",
"symbolColor": hat_style_name,
"character": m.cursorless_grapheme,
}
DEFAULT_COLOR_ENABLEMENT = {
"blue": True,
"green": True,
"red": True,
"pink": True,
"yellow": True,
"userColor1": False,
"userColor2": False,
}
DEFAULT_SHAPE_ENABLEMENT = {
"ex": False,
"fox": False,
"wing": False,
"hole": False,
"frame": False,
"curve": False,
"eye": False,
"play": False,
"bolt": False,
"crosshairs": False,
}
# Fall back to full enablement in case of error reading settings file
# NB: This won't actually enable all the shapes and colors extension-side.
# It'll just make it so that the user can say them whether or not they are enabled
FALLBACK_SHAPE_ENABLEMENT = {
"ex": True,
"fox": True,
"wing": True,
"hole": True,
"frame": True,
"curve": True,
"eye": True,
"play": True,
"bolt": True,
"crosshairs": True,
}
FALLBACK_COLOR_ENABLEMENT = DEFAULT_COLOR_ENABLEMENT
unsubscribe_hat_styles: Any = None
def setup_hat_styles_csv(hat_colors: dict[str, str], hat_shapes: dict[str, str]):
global unsubscribe_hat_styles
(
color_enablement_settings,
is_color_error,
) = actions.user.vscode_get_setting_with_fallback(
"cursorless.hatEnablement.colors",
default_value={},
fallback_value=FALLBACK_COLOR_ENABLEMENT,
fallback_message="Error finding color enablement; falling back to full enablement",
)
(
shape_enablement_settings,
is_shape_error,
) = actions.user.vscode_get_setting_with_fallback(
"cursorless.hatEnablement.shapes",
default_value={},
fallback_value=FALLBACK_SHAPE_ENABLEMENT,
fallback_message="Error finding shape enablement; falling back to full enablement",
)
color_enablement = {
**DEFAULT_COLOR_ENABLEMENT,
**color_enablement_settings,
}
shape_enablement = {
**DEFAULT_SHAPE_ENABLEMENT,
**shape_enablement_settings,
}
active_hat_colors = {
spoken_form: value
for spoken_form, value in hat_colors.items()
if color_enablement[value]
}
active_hat_shapes = {
spoken_form: value
for spoken_form, value in hat_shapes.items()
if shape_enablement[value]
}
if unsubscribe_hat_styles is not None:
unsubscribe_hat_styles()
unsubscribe_hat_styles = init_csv_and_watch_changes(
"hat_styles.csv",
{
"hat_color": active_hat_colors,
"hat_shape": active_hat_shapes,
},
extra_ignored_values=[*hat_colors.values(), *hat_shapes.values()],
no_update_file=is_shape_error or is_color_error,
)
if is_shape_error or is_color_error:
actions.app.notify("Error reading vscode settings. Restart talon; see log")
fast_reload_job = None
slow_reload_job = None
def init_hats(hat_colors: dict[str, str], hat_shapes: dict[str, str]):
setup_hat_styles_csv(hat_colors, hat_shapes)
vscode_settings_path: Path | None = None
try:
vscode_settings_path = actions.user.vscode_settings_path().resolve()
except Exception as ex:
print(ex)
def on_watch(path, flags):
global fast_reload_job, slow_reload_job
cron.cancel(fast_reload_job)
cron.cancel(slow_reload_job)
fast_reload_job = cron.after(
"500ms", lambda: setup_hat_styles_csv(hat_colors, hat_shapes)
)
slow_reload_job = cron.after(
"10s", lambda: setup_hat_styles_csv(hat_colors, hat_shapes)
)
if vscode_settings_path is not None:
fs.watch(vscode_settings_path, on_watch)
def unsubscribe():
if vscode_settings_path is not None:
fs.unwatch(vscode_settings_path, on_watch)
if unsubscribe_hat_styles is not None:
unsubscribe_hat_styles()
return unsubscribe
@@ -0,0 +1,62 @@
from collections.abc import Callable
from dataclasses import dataclass
from talon import Module
from ..targets.range_target import RangeConnective
from .mark_types import LineNumber, LineNumberMark, LineNumberType
mod = Module()
mod.list("cursorless_line_direction", desc="Supported directions for line modifier")
@dataclass
class CustomizableTerm:
cursorlessIdentifier: str
type: LineNumberType
formatter: Callable[[int], int]
# NOTE: Please do not change these dicts. Use the CSVs for customization.
# See https://www.cursorless.org/docs/user/customization
directions = [
CustomizableTerm("lineNumberModulo100", "modulo100", lambda number: number - 1),
CustomizableTerm("lineNumberRelativeUp", "relative", lambda number: -number),
CustomizableTerm("lineNumberRelativeDown", "relative", lambda number: number),
]
directions_map = {d.cursorlessIdentifier: d for d in directions}
@mod.capture(
rule=(
"{user.cursorless_line_direction} <number_small> "
"[<user.cursorless_range_connective> <number_small>]"
)
)
def cursorless_line_number(m) -> LineNumber:
direction = directions_map[m.cursorless_line_direction]
numbers: list[int] = m.number_small_list
anchor = create_line_number_mark(direction.type, direction.formatter(numbers[0]))
if len(numbers) > 1:
active = create_line_number_mark(
direction.type, direction.formatter(numbers[1])
)
range_connective: RangeConnective = m.cursorless_range_connective
return {
"type": "range",
"anchor": anchor,
"active": active,
"excludeAnchor": range_connective.excludeAnchor,
"excludeActive": range_connective.excludeActive,
}
return anchor
def create_line_number_mark(type: LineNumberType, line_number: int) -> LineNumberMark:
return {
"type": "lineNumber",
"lineNumberType": type,
"lineNumber": line_number,
}
@@ -0,0 +1,50 @@
from talon import Context, Module
from .mark_types import LiteralMark
mod = Module()
mod.list("private_cursorless_literal_mark", desc="Cursorless literal mark")
# This is a private tag and should not be used by non Cursorless developers
mod.tag(
"private_cursorless_literal_mark_no_prefix",
desc="Tag for enabling literal mark without prefix",
)
ctx_no_prefix = Context()
ctx_no_prefix.matches = r"""
tag: user.private_cursorless_literal_mark_no_prefix
"""
# NB: <phrase> is used over <user.text> for DFA performance reasons
# (we intend to replace this with a dynamic list of document contents eventually)
@mod.capture(rule="{user.private_cursorless_literal_mark} <phrase>")
def cursorless_literal_mark(m) -> LiteralMark:
return construct_mark(str(m.phrase))
@ctx_no_prefix.capture("user.cursorless_literal_mark", rule="<phrase>")
def cursorless_literal_mark_no_prefix(m) -> LiteralMark:
return construct_mark(str(m.phrase))
def construct_mark(text: str) -> LiteralMark:
return {
"type": "literal",
"modifier": {
"type": "preferredScope",
"scopeType": {
"type": "customRegex",
"regex": construct_fuzzy_regex(text),
"flags": "gui",
},
},
}
def construct_fuzzy_regex(text: str) -> str:
parts = text.split(" ")
# 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.
return r"([^a-zA-Z]|\\[trn])*".join(parts)
+17
View File
@@ -0,0 +1,17 @@
from talon import Module
from .mark_types import Mark
mod = Module()
@mod.capture(
rule=(
"<user.cursorless_decorated_symbol> | "
"<user.cursorless_literal_mark> | "
"<user.cursorless_simple_mark> |"
"<user.cursorless_line_number>" # row (ie absolute mod 100), up, down
)
)
def cursorless_mark(m) -> Mark:
return m[0]
@@ -0,0 +1,36 @@
from typing import Literal, TypedDict, Union
class DecoratedSymbol(TypedDict):
type: Literal["decoratedSymbol"]
symbolColor: str
character: str
class LiteralMark(TypedDict):
type: Literal["literal"]
modifier: dict
SimpleMark = dict[Literal["type"], str]
LineNumberType = Literal["modulo100", "relative"]
class LineNumberMark(TypedDict):
type: Literal["lineNumber"]
lineNumberType: LineNumberType
lineNumber: int
class LineNumberRange(TypedDict):
type: Literal["range"]
anchor: LineNumberMark
active: LineNumberMark
excludeAnchor: bool
excludeActive: bool
LineNumber = Union[LineNumberMark, LineNumberRange]
Mark = Union[DecoratedSymbol, LiteralMark, SimpleMark, LineNumber]
@@ -0,0 +1,23 @@
from talon import Module
from .mark_types import SimpleMark
mod = Module()
mod.list("cursorless_simple_mark", desc="Cursorless simple marks")
# Maps from the id we use in the spoken form csv to the modifier type
# expected by Cursorless extension
simple_marks = {
"currentSelection": "cursor",
"previousTarget": "that",
"previousSource": "source",
"nothing": "nothing",
}
@mod.capture(rule="{user.cursorless_simple_mark}")
def cursorless_simple_mark(m) -> SimpleMark:
return {
"type": simple_marks[m.cursorless_simple_mark],
}
@@ -0,0 +1,39 @@
from talon import Module
mod = Module()
mod.list(
"cursorless_head_tail_modifier",
desc="Cursorless head and tail modifiers",
)
@mod.capture(
rule=(
"{user.cursorless_head_tail_modifier} "
"[<user.cursorless_interior_modifier>] "
"[<user.cursorless_head_tail_swallowed_modifier>]"
)
)
def cursorless_head_tail_modifier(m) -> dict[str, str]:
"""Cursorless head and tail modifier"""
modifiers = []
try:
modifiers.append(m.cursorless_interior_modifier)
except AttributeError:
pass
try:
modifiers.append(m.cursorless_head_tail_swallowed_modifier)
except AttributeError:
pass
result = {
"type": m.cursorless_head_tail_modifier,
}
if modifiers:
result["modifiers"] = modifiers
return result
@@ -0,0 +1,16 @@
from talon import Module
mod = Module()
mod.list(
"cursorless_interior_modifier",
desc="Cursorless interior modifier",
)
@mod.capture(rule="{user.cursorless_interior_modifier}")
def cursorless_interior_modifier(m) -> dict[str, str]:
"""Cursorless interior modifier"""
return {
"type": m.cursorless_interior_modifier,
}
@@ -0,0 +1,10 @@
from typing import Any
from talon import Module
mod = Module()
@mod.capture(rule="matching")
def cursorless_matching_paired_delimiter(m) -> dict[str, Any]:
return {"modifier": {"type": "matchingPairedDelimiter"}}
@@ -0,0 +1,49 @@
from talon import Module
mod = Module()
mod.list(
"cursorless_simple_modifier",
desc="Simple cursorless modifiers that only need to specify their type",
)
@mod.capture(rule="{user.cursorless_simple_modifier}")
def cursorless_simple_modifier(m) -> dict[str, str]:
"""Simple cursorless modifiers that only need to specify their type"""
return {
"type": m.cursorless_simple_modifier,
}
# These are the modifiers that will be "swallowed" by the head/tail modifier.
# For example, saying "head funk" will result in a "head" modifier that will
# select past the start of the function.
# Note that we don't include "inside" here, because that requires slightly
# special treatment to ensure that "head inside round" swallows "inside round"
# rather than just "inside".
head_tail_swallowed_modifiers = [
"<user.cursorless_simple_modifier>", # bounds, just, leading, trailing
"<user.cursorless_simple_scope_modifier>", # funk, state, class, every funk
"<user.cursorless_ordinal_scope>", # first past second word
"<user.cursorless_relative_scope>", # next funk, 3 funks
]
modifiers = [
"<user.cursorless_interior_modifier>", # inside
"<user.cursorless_head_tail_modifier>", # head, tail
"<user.cursorless_position_modifier>", # start of, end of
*head_tail_swallowed_modifiers,
]
@mod.capture(rule="|".join(modifiers))
def cursorless_modifier(m) -> str:
"""Cursorless modifier"""
return m[0]
@mod.capture(rule="|".join(head_tail_swallowed_modifiers))
def cursorless_head_tail_swallowed_modifier(m) -> str:
"""Cursorless modifier that is swallowed by the head/tail modifier, excluding interior, which requires special treatment"""
return m[0]
@@ -0,0 +1,91 @@
from typing import Any
from talon import Module
from ..targets.range_target import RangeConnective
mod = Module()
mod.list("cursorless_first_modifier", desc="Cursorless first modifiers")
mod.list("cursorless_last_modifier", desc="Cursorless last modifiers")
@mod.capture(
rule="<user.ordinals_small> | [<user.ordinals_small>] {user.cursorless_last_modifier}"
)
def cursorless_ordinal_or_last(m) -> int:
"""An ordinal or the word 'last'"""
if m[-1] == "last":
return -getattr(m, "ordinals_small", 1)
return m.ordinals_small - 1
@mod.capture(
rule="<user.cursorless_ordinal_or_last> [<user.cursorless_range_connective> <user.cursorless_ordinal_or_last>] <user.cursorless_scope_type>"
)
def cursorless_ordinal_range(m) -> dict[str, Any]:
"""Ordinal range"""
anchor = create_ordinal_scope_modifier(
m.cursorless_scope_type, m.cursorless_ordinal_or_last_list[0]
)
if len(m.cursorless_ordinal_or_last_list) > 1:
active = create_ordinal_scope_modifier(
m.cursorless_scope_type, m.cursorless_ordinal_or_last_list[1]
)
range_connective: RangeConnective = m.cursorless_range_connective
return {
"type": "range",
"anchor": anchor,
"active": active,
"excludeAnchor": range_connective.excludeAnchor,
"excludeActive": range_connective.excludeActive,
}
return anchor
@mod.capture(
rule=(
"[{user.cursorless_every_scope_modifier}] "
"({user.cursorless_first_modifier} | {user.cursorless_last_modifier}) "
"<number_small> <user.cursorless_scope_type_plural>"
),
)
def cursorless_first_last(m) -> dict[str, Any]:
"""First/last `n` scopes; eg "first three funks"""
is_every = hasattr(m, "cursorless_every_scope_modifier")
if hasattr(m, "cursorless_first_modifier"):
return create_ordinal_scope_modifier(
m.cursorless_scope_type_plural,
0,
m.number_small,
is_every,
)
return create_ordinal_scope_modifier(
m.cursorless_scope_type_plural,
-m.number_small,
m.number_small,
is_every,
)
@mod.capture(rule="<user.cursorless_ordinal_range> | <user.cursorless_first_last>")
def cursorless_ordinal_scope(m) -> dict[str, Any]:
"""Ordinal ranges such as subwords or characters"""
return m[0]
def create_ordinal_scope_modifier(
scope_type: dict,
start: int,
length: int = 1,
is_every: bool = False,
):
res = {
"type": "ordinalScope",
"scopeType": scope_type,
"start": start,
"length": length,
}
if is_every:
res["isEvery"] = True
return res
@@ -0,0 +1,12 @@
from typing import Any
from talon import Module
mod = Module()
mod.list("cursorless_position", desc='Positions such as "before", "after" etc')
@mod.capture(rule="{user.cursorless_position}")
def cursorless_position_modifier(m) -> dict[str, Any]:
return {"type": "startOf" if m.cursorless_position == "start" else "endOf"}
@@ -0,0 +1,104 @@
from typing import Any
from talon import Module
mod = Module()
mod.list("cursorless_previous_next_modifier", desc="Cursorless previous/next modifiers")
mod.list(
"cursorless_forward_backward_modifier", desc="Cursorless forward/backward modifiers"
)
@mod.capture(rule="{user.cursorless_previous_next_modifier}")
def cursorless_relative_direction(m) -> str:
"""Previous/next"""
return "backward" if m[0] == "previous" else "forward"
@mod.capture(
rule="[<user.ordinals_small>] <user.cursorless_relative_direction> <user.cursorless_scope_type>"
)
def cursorless_relative_scope_singular(m) -> dict[str, Any]:
"""Relative previous/next singular scope, eg `"next funk"` or `"third next funk"`."""
return create_relative_scope_modifier(
m.cursorless_scope_type,
getattr(m, "ordinals_small", 1),
1,
m.cursorless_relative_direction,
False,
)
@mod.capture(
rule="[{user.cursorless_every_scope_modifier}] <user.cursorless_relative_direction> <number_small> <user.cursorless_scope_type_plural>"
)
def cursorless_relative_scope_plural(m) -> dict[str, Any]:
"""Relative previous/next plural scope. `next three funks`"""
return create_relative_scope_modifier(
m.cursorless_scope_type_plural,
1,
m.number_small,
m.cursorless_relative_direction,
hasattr(m, "cursorless_every_scope_modifier"),
)
@mod.capture(
rule="[{user.cursorless_every_scope_modifier}] <number_small> <user.cursorless_scope_type_plural> [{user.cursorless_forward_backward_modifier}]"
)
def cursorless_relative_scope_count(m) -> dict[str, Any]:
"""Relative count scope. `three funks`"""
return create_relative_scope_modifier(
m.cursorless_scope_type_plural,
0,
m.number_small,
getattr(m, "cursorless_forward_backward_modifier", "forward"),
hasattr(m, "cursorless_every_scope_modifier"),
)
@mod.capture(
rule="<user.cursorless_scope_type> {user.cursorless_forward_backward_modifier}"
)
def cursorless_relative_scope_one_backward(m) -> dict[str, Any]:
"""Take scope backward, eg `funk backward`"""
return create_relative_scope_modifier(
m.cursorless_scope_type,
0,
1,
m.cursorless_forward_backward_modifier,
False,
)
@mod.capture(
rule=(
"<user.cursorless_relative_scope_singular> | "
"<user.cursorless_relative_scope_plural> | "
"<user.cursorless_relative_scope_count> | "
"<user.cursorless_relative_scope_one_backward>"
)
)
def cursorless_relative_scope(m) -> dict[str, Any]:
"""Previous/next scope"""
return m[0]
def create_relative_scope_modifier(
scope_type: dict,
offset: int,
length: int,
direction: str,
is_every: bool,
) -> dict[str, Any]:
res = {
"type": "relativeScope",
"scopeType": scope_type,
"offset": offset,
"length": length,
"direction": direction,
}
if is_every:
res["isEvery"] = True
return res
@@ -0,0 +1,80 @@
from talon import Module
mod = Module()
mod.list("cursorless_scope_type", desc="Supported scope types")
mod.list("cursorless_scope_type_plural", desc="Supported plural scope types")
mod.list(
"cursorless_glyph_scope_type",
desc="Cursorless glyph scope type",
)
mod.list(
"cursorless_glyph_scope_type_plural",
desc="Plural version of Cursorless glyph scope type",
)
mod.list(
"cursorless_surrounding_pair_scope_type",
desc="Scope types that can function as surrounding pairs",
)
mod.list(
"cursorless_surrounding_pair_scope_type_plural",
desc="Plural form of scope types that can function as surrounding pairs",
)
mod.list(
"cursorless_custom_regex_scope_type",
desc="Supported custom regular expression scope types",
)
mod.list(
"cursorless_custom_regex_scope_type_plural",
desc="Supported plural custom regular expression scope types",
)
mod.list(
"cursorless_scope_type_flattened",
desc="All supported scope types flattened",
)
mod.list(
"cursorless_scope_type_flattened_plural",
desc="All supported plural scope types flattened",
)
@mod.capture(rule="{user.cursorless_scope_type_flattened}")
def cursorless_scope_type(m) -> dict[str, str]:
"""Cursorless scope type singular"""
return creates_scope_type(m.cursorless_scope_type_flattened)
@mod.capture(rule="{user.cursorless_scope_type_flattened_plural}")
def cursorless_scope_type_plural(m) -> dict[str, str]:
"""Cursorless scope type plural"""
return creates_scope_type(m.cursorless_scope_type_flattened_plural)
def creates_scope_type(id: str) -> dict[str, str]:
grouping, value = id.split(".", 1)
match grouping:
case "simple":
return {
"type": value,
}
case "surroundingPair":
return {
"type": "surroundingPair",
"delimiter": value,
}
case "customRegex":
return {
"type": "customRegex",
"regex": value,
}
case "glyph":
return {
"type": "glyph",
"character": value,
}
case _:
raise ValueError(f"Unsupported scope type grouping: {grouping}")
@@ -0,0 +1,55 @@
from typing import Any
from talon import Module, settings
mod = Module()
mod.list(
"cursorless_every_scope_modifier",
desc="Cursorless every scope modifiers",
)
mod.list(
"cursorless_ancestor_scope_modifier",
desc="Cursorless ancestor scope modifiers",
)
# This is a private setting and should not be used by non Cursorless developers
mod.setting(
"private_cursorless_use_preferred_scope",
type=bool,
default=False,
desc="Use preferred scope instead of containing scope for all scopes by default (EXPERIMENTAL)",
)
@mod.capture(
rule=(
"[{user.cursorless_every_scope_modifier} | {user.cursorless_ancestor_scope_modifier}+] "
"<user.cursorless_scope_type>"
),
)
def cursorless_simple_scope_modifier(m) -> dict[str, Any]:
"""Containing scope, every scope, etc"""
if hasattr(m, "cursorless_every_scope_modifier"):
return {
"type": "everyScope",
"scopeType": m.cursorless_scope_type,
}
if hasattr(m, "cursorless_ancestor_scope_modifier"):
return {
"type": "containingScope",
"scopeType": m.cursorless_scope_type,
"ancestorIndex": len(m.cursorless_ancestor_scope_modifier_list),
}
if settings.get("user.private_cursorless_use_preferred_scope"):
return {
"type": "preferredScope",
"scopeType": m.cursorless_scope_type,
}
return {
"type": "containingScope",
"scopeType": m.cursorless_scope_type,
}
+20
View File
@@ -0,0 +1,20 @@
"""
DEPRECATED @ 2024-12-21
This file allows us to use a custom `number_small` capture. See #1021 for more info.
"""
from talon import Module, app, registry
mod = Module()
mod.tag("cursorless_custom_number_small", "DEPRECATED!")
def on_ready():
if "user.cursorless_custom_number_small" in registry.tags:
print(
"WARNING tag: 'user.cursorless_custom_number_small' is deprecated and should not be used anymore, as Cursorless now uses community number_small"
)
app.register("ready", on_ready)
@@ -0,0 +1,56 @@
from talon import Module
mod = Module()
mod.list(
"cursorless_wrapper_only_paired_delimiter",
desc="A paired delimiter that can only be used as a wrapper",
)
mod.list(
"cursorless_selectable_only_paired_delimiter",
desc="A paired delimiter that can only be used as a scope type",
)
mod.list(
"cursorless_wrapper_selectable_paired_delimiter",
desc="A paired delimiter that can be used as a scope type and as a wrapper",
)
mod.list(
"cursorless_selectable_only_paired_delimiter_plural",
desc="Plural form of a paired delimiter that can only be used as a scope type",
)
mod.list(
"cursorless_wrapper_selectable_paired_delimiter_plural",
desc="Plural form of a paired delimiter that can be used as a scope type and as a wrapper",
)
# Maps from the id we use in the spoken form csv to the delimiter strings
paired_delimiters = {
"curlyBrackets": ["{", "}"],
"angleBrackets": ["<", ">"],
"escapedDoubleQuotes": ['\\"', '\\"'],
"escapedSingleQuotes": ["\\'", "\\'"],
"escapedParentheses": ["\\(", "\\)"],
"escapedSquareBrackets": ["\\[", "\\]"],
"doubleQuotes": ['"', '"'],
"parentheses": ["(", ")"],
"backtickQuotes": ["`", "`"],
"whitespace": [" ", " "],
"squareBrackets": ["[", "]"],
"singleQuotes": ["'", "'"],
"any": ["", ""],
}
@mod.capture(
rule=(
"{user.cursorless_wrapper_only_paired_delimiter} |"
"{user.cursorless_wrapper_selectable_paired_delimiter}"
)
)
def cursorless_wrapper_paired_delimiter(m) -> list[str]:
try:
id = m.cursorless_wrapper_only_paired_delimiter
except AttributeError:
id = m.cursorless_wrapper_selectable_paired_delimiter
return paired_delimiters[id]
@@ -0,0 +1,47 @@
from typing import Any
from ..actions.bring_move import BringMoveTargets
from ..actions.swap import SwapTargets
from ..targets.target_types import (
ImplicitDestination,
ImplicitTarget,
ListDestination,
ListTarget,
PrimitiveDestination,
PrimitiveTarget,
RangeTarget,
)
def extract_decorated_marks(capture: Any) -> list[Any]:
match capture:
case PrimitiveTarget(mark=mark):
if mark is None or mark["type"] != "decoratedSymbol":
return []
return [mark]
case ImplicitTarget():
return []
case RangeTarget(anchor=anchor, active=active):
return extract_decorated_marks(anchor) + extract_decorated_marks(active)
case ListTarget(elements=elements):
return [
mark for target in elements for mark in extract_decorated_marks(target)
]
case PrimitiveDestination(target=target):
return extract_decorated_marks(target)
case ImplicitDestination():
return []
case ListDestination(destinations=destinations):
return [
mark
for destination in destinations
for mark in extract_decorated_marks(destination)
]
case BringMoveTargets(source=source, destination=destination):
return extract_decorated_marks(source) + extract_decorated_marks(
destination
)
case SwapTargets(target1=target1, target2=target2):
return extract_decorated_marks(target1) + extract_decorated_marks(target2)
case _:
raise TypeError(f"Unknown capture type: {type(capture)}")
@@ -0,0 +1,68 @@
from typing import Any, Optional, Union
from talon import Module, actions
from ..targets.target_types import (
CursorlessTarget,
ListTarget,
PrimitiveTarget,
RangeTarget,
)
from .extract_decorated_marks import extract_decorated_marks
mod = Module()
@mod.action_class
class MiscActions:
def cursorless_private_extract_decorated_marks(capture: Any) -> list[dict]:
"""Cursorless private api: Extract all decorated marks from a Talon capture"""
return extract_decorated_marks(capture)
@mod.action_class
class TargetBuilderActions:
"""Cursorless private api low-level target builder actions"""
def cursorless_private_build_primitive_target(
modifiers: list[dict], # pyright: ignore [reportGeneralTypeIssues]
mark: Optional[dict],
) -> PrimitiveTarget:
"""Cursorless private api low-level target builder: Create a primitive target"""
return PrimitiveTarget(mark, modifiers)
def cursorless_private_build_list_target(
elements: list[Union[PrimitiveTarget, RangeTarget]], # pyright: ignore [reportGeneralTypeIssues]
) -> Union[PrimitiveTarget, RangeTarget, ListTarget]:
"""Cursorless private api low-level target builder: Create a list target"""
if len(elements) == 1:
return elements[0]
return ListTarget(elements)
@mod.action_class
class TargetActions:
def cursorless_private_target_nothing() -> PrimitiveTarget:
"""Cursorless private api: Creates the "nothing" target"""
return PrimitiveTarget({"type": "nothing"}, [])
@mod.action_class
class ActionActions:
def cursorless_private_action_highlight(
target: CursorlessTarget, # pyright: ignore [reportGeneralTypeIssues]
highlightId: Optional[str] = None,
) -> None:
"""Cursorless private api: Highlights a target"""
payload = {
"name": "highlight",
"target": target,
}
if highlightId is not None:
payload["highlightId"] = highlightId
actions.user.private_cursorless_command_and_wait(
payload,
)
+42
View File
@@ -0,0 +1,42 @@
from typing import Any, Optional
from talon import Module, actions
from .targets.target_types import (
CursorlessDestination,
InsertionMode,
ListTarget,
PrimitiveDestination,
PrimitiveTarget,
RangeTarget,
)
mod = Module()
@mod.action_class
class Actions:
def cursorless_create_destination(
target: ListTarget | RangeTarget | PrimitiveTarget, # pyright: ignore [reportGeneralTypeIssues]
insertion_mode: InsertionMode = "to",
) -> CursorlessDestination:
"""Cursorless: Create destination from target"""
return PrimitiveDestination(insertion_mode, target)
@mod.action_class
class CommandActions:
def cursorless_x_custom_command(
content: str, # pyright: ignore [reportGeneralTypeIssues]
arg1: Optional[Any] = None,
arg2: Optional[Any] = None,
arg3: Optional[Any] = None,
):
"""Cursorless: Run custom parsed command"""
actions.user.private_cursorless_command_and_wait(
{
"name": "parsed",
"content": content,
"arguments": [arg for arg in [arg1, arg2, arg3] if arg is not None],
}
)
@@ -0,0 +1,27 @@
from talon import Module, actions
mod = Module()
mod.list("cursorless_show_scope_visualizer", desc="Show scope visualizer")
mod.list("cursorless_hide_scope_visualizer", desc="Hide scope visualizer")
mod.list(
"cursorless_visualization_type",
desc='Cursorless visualization type, e.g. "removal" or "iteration"',
)
@mod.action_class
class Actions:
def private_cursorless_show_scope_visualizer(
scope_type: dict, # pyright: ignore [reportGeneralTypeIssues]
visualization_type: str,
):
"""Shows scope visualizer"""
actions.user.private_cursorless_run_rpc_command_no_wait(
"cursorless.showScopeVisualizer", scope_type, visualization_type
)
def private_cursorless_hide_scope_visualizer():
"""Hides scope visualizer"""
actions.user.private_cursorless_run_rpc_command_no_wait(
"cursorless.hideScopeVisualizer"
)
@@ -0,0 +1,110 @@
from dataclasses import dataclass
from ..targets.target_types import CursorlessDestination, CursorlessTarget
# Scope types
@dataclass
class ScopeType:
type: str
def to_scope_types(scope_types: str | list[str]) -> list[ScopeType]:
if isinstance(scope_types, str):
return [ScopeType(scope_types)]
return [ScopeType(st) for st in scope_types]
# Community types
@dataclass
class CommunityInsertionSnippet:
body: str
languages: list[str] | None = None
scopes: list[str] | None = None
@dataclass
class CommunityWrapperSnippet:
body: str
variable_name: str
languages: list[str] | None
scope: str | None
# Insertion snippets
@dataclass
class CustomInsertionSnippet:
type = "custom"
body: str
scopeTypes: list[ScopeType] | None
languages: list[str] | None
substitutions: dict[str, str] | None
@staticmethod
def create(
snippet: CommunityInsertionSnippet,
substitutions: dict[str, str] | None = None,
):
return CustomInsertionSnippet(
snippet.body,
to_scope_types(snippet.scopes) if snippet.scopes else None,
# languages will be missing if the user has an older version of community
snippet.languages if hasattr(snippet, "languages") else None,
substitutions=substitutions,
)
@dataclass
class ListInsertionSnippet:
type = "list"
fallbackLanguage: str | None
substitutions: dict[str, str] | None
snippets: list[CustomInsertionSnippet]
@dataclass
class InsertSnippetAction:
name = "insertSnippet"
snippetDescription: CustomInsertionSnippet | ListInsertionSnippet
destination: CursorlessDestination
# Wrapper snippets
@dataclass
class CustomWrapperSnippet:
type = "custom"
body: str
variableName: str | None
scopeType: ScopeType | None
languages: list[str] | None
@staticmethod
def create(snippet: CommunityWrapperSnippet):
return CustomWrapperSnippet(
snippet.body,
snippet.variable_name,
ScopeType(snippet.scope) if snippet.scope else None,
# languages will be missing if the user has an older version of community
snippet.languages if hasattr(snippet, "languages") else None,
)
@dataclass
class ListWrapperSnippet:
type = "list"
fallbackLanguage: str | None
snippets: list[CustomWrapperSnippet]
@dataclass
class WrapperSnippetAction:
name = "wrapWithSnippet"
snippetDescription: CustomWrapperSnippet | ListWrapperSnippet
target: CursorlessTarget
@@ -0,0 +1,129 @@
from typing import Optional, Union
from talon import Context, Module, actions
from ..targets.target_types import (
CursorlessDestination,
CursorlessTarget,
ImplicitDestination,
)
from .snippet_types import (
CustomInsertionSnippet,
CustomWrapperSnippet,
InsertSnippetAction,
ScopeType,
WrapperSnippetAction,
to_scope_types,
)
from .snippets_get import (
get_insertion_snippet,
get_list_insertion_snippet,
get_list_wrapper_snippet,
get_wrapper_snippet,
)
mod = Module()
ctx = Context()
ctx.matches = r"""
tag: user.cursorless
and not tag: user.code_language_forced
"""
mod.list("cursorless_insert_snippet_action", desc="Cursorless insert snippet action")
@mod.action_class
class Actions:
def cursorless_insert_snippet(
body: str, # pyright: ignore [reportGeneralTypeIssues]
destination: CursorlessDestination = ImplicitDestination(),
scope_type: Optional[Union[str, list[str]]] = None,
):
"""Cursorless: Insert custom snippet <body>"""
snippet = CustomInsertionSnippet(
body,
to_scope_types(scope_type) if scope_type else None,
languages=None,
substitutions=None,
)
action = InsertSnippetAction(snippet, destination)
actions.user.private_cursorless_command_and_wait(action)
def cursorless_wrap_with_snippet(
body: str, # pyright: ignore [reportGeneralTypeIssues]
target: CursorlessTarget,
variable_name: Optional[str] = None,
scope: Optional[str] = None,
):
"""Cursorless: Wrap target with custom snippet <body>"""
snippet = CustomWrapperSnippet(
body,
variable_name,
ScopeType(scope) if scope else None,
languages=None,
)
action = WrapperSnippetAction(snippet, target)
actions.user.private_cursorless_command_and_wait(action)
# These actions use a single custom snippets since a language mode is forced
def private_cursorless_insert_community_snippet(
name: str, # pyright: ignore [reportGeneralTypeIssues]
destination: CursorlessDestination,
):
"""Cursorless: Insert community snippet <name>"""
action = InsertSnippetAction(
get_insertion_snippet(name),
destination,
)
actions.user.private_cursorless_command_and_wait(action)
def private_cursorless_wrap_with_community_snippet(
name: str, # pyright: ignore [reportGeneralTypeIssues]
target: CursorlessTarget,
):
"""Cursorless: Wrap target with community snippet <name>"""
action = WrapperSnippetAction(
get_wrapper_snippet(name),
target,
)
actions.user.private_cursorless_command_and_wait(action)
@ctx.action_class("user")
class UserActions:
# Since we don't have a forced language mode, these actions send all the snippets.
# (note that this is the default mode of action, as most of the time the user will not
# have a forced language mode)
def insert_snippet_by_name(
name: str, # pyright: ignore [reportGeneralTypeIssues]
# Don't add optional: we need to match the type in community
substitutions: dict[str, str] = None, # type: ignore
):
action = InsertSnippetAction(
get_list_insertion_snippet(name, substitutions),
ImplicitDestination(),
)
actions.user.private_cursorless_command_and_wait(action)
def private_cursorless_insert_community_snippet(
name: str, # pyright: ignore [reportGeneralTypeIssues]
destination: CursorlessDestination,
):
action = InsertSnippetAction(
get_list_insertion_snippet(name),
destination,
)
actions.user.private_cursorless_command_and_wait(action)
def private_cursorless_wrap_with_community_snippet(
name: str, # pyright: ignore [reportGeneralTypeIssues]
target: CursorlessTarget,
):
action = WrapperSnippetAction(
get_list_wrapper_snippet(name),
target,
)
actions.user.private_cursorless_command_and_wait(action)
@@ -0,0 +1,54 @@
from typing import Any
from talon import Module, app, registry
mod = Module()
# DEPRECATED @ 2025-02-01
tags = [
"cursorless_experimental_snippets",
"cursorless_use_community_snippets",
]
lists = [
"cursorless_insertion_snippet_no_phrase",
"cursorless_insertion_snippet_single_phrase",
"cursorless_wrapper_snippet",
"cursorless_phrase_terminator",
]
for tag in tags:
mod.tag(tag, desc="DEPRECATED")
for list in lists:
mod.list(list, desc="DEPRECATED")
@mod.action_class
class Actions:
def cursorless_insert_snippet_by_name(name: str): # pyright: ignore [reportGeneralTypeIssues]
"""[DEPRECATED] Cursorless: Insert named snippet <name>"""
raise NotImplementedError(
"Cursorless snippets are deprecated. Please use community snippets. Update to latest cursorless-talon and say 'cursorless migrate snippets'."
)
def cursorless_wrap_with_snippet_by_name(
name: str, # pyright: ignore [reportGeneralTypeIssues]
variable_name: str,
target: Any,
):
"""[DEPRECATED] Cursorless: Wrap target with a named snippet <name>"""
raise NotImplementedError(
"Cursorless snippets are deprecated. Please use community snippets. Update to latest cursorless-talon and say 'cursorless migrate snippets'."
)
def on_ready():
for tag in tags:
name = f"user.{tag}"
if name in registry.tags:
print(f"WARNING tag: '{name}' is deprecated and should not be used anymore")
app.register("ready", on_ready)
@@ -0,0 +1,62 @@
from talon import actions
from .snippet_types import (
CustomInsertionSnippet,
CustomWrapperSnippet,
ListInsertionSnippet,
ListWrapperSnippet,
)
def get_insertion_snippet(
name: str, substitutions: dict[str, str] | None = None
) -> CustomInsertionSnippet:
return CustomInsertionSnippet.create(
actions.user.get_insertion_snippet(name),
substitutions,
)
def get_list_insertion_snippet(
name: str,
substitutions: dict[str, str] | None = None,
) -> ListInsertionSnippet | CustomInsertionSnippet:
try:
snippets = actions.user.get_insertion_snippets(name)
except Exception as e:
# Raised if the user has an older version of community
if isinstance(e, KeyError):
return get_insertion_snippet(name, substitutions)
raise
return ListInsertionSnippet(
get_fallback_language(),
substitutions,
[CustomInsertionSnippet.create(s) for s in snippets],
)
def get_wrapper_snippet(name: str) -> CustomWrapperSnippet:
return CustomWrapperSnippet.create(actions.user.get_wrapper_snippet(name))
def get_list_wrapper_snippet(name: str) -> ListWrapperSnippet | CustomWrapperSnippet:
try:
snippets = actions.user.get_wrapper_snippets(name)
except Exception as e:
# Raised if the user has an older version of community
if isinstance(e, KeyError):
return get_wrapper_snippet(name)
raise
return ListWrapperSnippet(
get_fallback_language(),
[CustomWrapperSnippet.create(s) for s in snippets],
)
def get_fallback_language():
language = actions.code.language()
if language and isinstance(language, str):
return language
return None
+268
View File
@@ -0,0 +1,268 @@
{
"NOTE FOR USERS": "Please don't edit this json file; see https://www.cursorless.org/docs/user/customization",
"actions.csv": {
"simple_action": {
"append post": "addSelectionAfter",
"append pre": "addSelectionBefore",
"append": "addSelection",
"bottom": "scrollToBottom",
"break point": "toggleLineBreakpoint",
"break": "breakLine",
"carve": "cutToClipboard",
"center": "scrollToCenter",
"change": "clearAndSetSelection",
"chuck": "remove",
"clone up": "insertCopyBefore",
"clone": "insertCopyAfter",
"comment": "toggleLineComment",
"copy": "copyToClipboard",
"crown": "scrollToTop",
"decrement": "decrement",
"dedent": "outdentLine",
"define": "revealDefinition",
"drink": "editNewLineBefore",
"drop": "insertEmptyLineBefore",
"extract": "extractVariable",
"flash": "flashTargets",
"float": "insertEmptyLineAfter",
"fold": "foldRegion",
"follow split": "followLinkAside",
"follow": "followLink",
"git accept": "gitAccept",
"git stage": "gitStage",
"git unstage": "gitUnstage",
"git revert": "gitRevert",
"give": "deselect",
"highlight": "highlight",
"hover": "showHover",
"increment": "increment",
"indent": "indentLine",
"inspect": "showDebugHover",
"join": "joinLines",
"post": "setSelectionAfter",
"pour": "editNewLineAfter",
"pre": "setSelectionBefore",
"puff": "insertEmptyLinesAround",
"quick fix": "showQuickFix",
"reference": "showReferences",
"rename": "rename",
"reverse": "reverseTargets",
"scout all": "findInWorkspace",
"scout": "findInDocument",
"shuffle": "randomizeTargets",
"snip make": "generateSnippet",
"sort": "sortTargets",
"take": "setSelection",
"type deaf": "revealTypeDefinition",
"unfold": "unfoldRegion"
},
"callback_action": {
"phones": "nextHomophone"
},
"paste_action": { "paste": "pasteFromClipboard" },
"bring_move_action": {
"bring": "replaceWithTarget",
"move": "moveToTarget"
},
"swap_action": { "swap": "swapTargets" },
"wrap_action": { "wrap": "wrapWithPairedDelimiter", "repack": "rewrap" },
"insert_snippet_action": { "snip": "insertSnippet" },
"reformat_action": { "format": "applyFormatter" },
"call_action": { "call": "callAsFunction" }
},
"target_connectives.csv": {
"range_connective": {
"between": "rangeExclusive",
"past": "rangeInclusive",
"-": "rangeExcludingStart",
"until": "rangeExcludingEnd"
},
"list_connective": { "and": "listConnective" },
"swap_connective": { "with": "swapConnective" },
"insertion_mode_to": { "to": "sourceDestinationConnective" }
},
"modifiers.csv": {
"simple_modifier": {
"bounds": "excludeInterior",
"just": "toRawSelection",
"leading": "leading",
"trailing": "trailing",
"content": "keepContentFilter",
"empty": "keepEmptyFilter",
"its": "inferPreviousMark",
"visible": "visible"
},
"every_scope_modifier": { "every": "every" },
"ancestor_scope_modifier": { "grand": "ancestor" },
"interior_modifier": {
"inside": "interiorOnly"
},
"head_tail_modifier": {
"head": "extendThroughStartOf",
"tail": "extendThroughEndOf"
},
"range_type": {
"slice": "verticalRange"
},
"first_modifier": { "first": "first" },
"last_modifier": { "last": "last" },
"previous_next_modifier": { "previous": "previous", "next": "next" },
"forward_backward_modifier": {
"forward": "forward",
"backward": "backward"
}
},
"positions.csv": {
"position": {
"start of": "start",
"end of": "end"
},
"insertion_mode_before_after": {
"before": "before",
"after": "after"
}
},
"modifier_scope_types.csv": {
"scope_type": {
"arg": "argumentOrParameter",
"arg list": "argumentList",
"attribute": "attribute",
"call": "functionCall",
"callee": "functionCallee",
"class name": "className",
"class": "class",
"comment": "comment",
"funk name": "functionName",
"funk": "namedFunction",
"if state": "ifStatement",
"instance": "instance",
"item": "collectionItem",
"key": "collectionKey",
"lambda": "anonymousFunction",
"list": "list",
"map": "map",
"name": "name",
"regex": "regularExpression",
"section": "section",
"-one section": "sectionLevelOne",
"-two section": "sectionLevelTwo",
"-three section": "sectionLevelThree",
"-four section": "sectionLevelFour",
"-five section": "sectionLevelFive",
"-six section": "sectionLevelSix",
"selector": "selector",
"state": "statement",
"branch": "branch",
"type": "type",
"value": "value",
"condition": "condition",
"unit": "unit",
"element": "xmlElement",
"tags": "xmlBothTags",
"start tag": "xmlStartTag",
"end tag": "xmlEndTag",
"part": "part",
"chapter": "chapter",
"subsection": "subSection",
"subsubsection": "subSubSection",
"paragraph": "namedParagraph",
"subparagraph": "subParagraph",
"environment": "environment",
"command": "command",
"char": "character",
"sub": "word",
"token": "token",
"identifier": "identifier",
"line": "line",
"sentence": "sentence",
"block": "paragraph",
"file": "document",
"paint": "nonWhitespaceSequence",
"short paint": "boundedNonWhitespaceSequence",
"short block": "boundedParagraph",
"link": "url",
"cell": "notebookCell"
},
"surrounding_pair_scope_type": {
"string": "string"
},
"glyph_scope_type": {
"glyph": "glyph"
}
},
"paired_delimiters.csv": {
"selectable_only_paired_delimiter": { "pair": "any" },
"wrapper_only_paired_delimiter": { "void": "whitespace" },
"wrapper_selectable_paired_delimiter": {
"curly": "curlyBrackets",
"diamond": "angleBrackets",
"escaped quad": "escapedDoubleQuotes",
"escaped twin": "escapedSingleQuotes",
"escaped round": "escapedParentheses",
"escaped box": "escapedSquareBrackets",
"quad": "doubleQuotes",
"round": "parentheses",
"skis": "backtickQuotes",
"box": "squareBrackets",
"twin": "singleQuotes"
}
},
"special_marks.csv": {
"simple_mark": {
"this": "currentSelection",
"that": "previousTarget",
"source": "previousSource",
"nothing": "nothing"
},
"unknown_symbol": { "special": "unknownSymbol" },
"line_direction": {
"row": "lineNumberModulo100",
"up": "lineNumberRelativeUp",
"down": "lineNumberRelativeDown"
}
},
"scope_visualizer.csv": {
"show_scope_visualizer": { "visualize": "showScopeVisualizer" },
"hide_scope_visualizer": { "visualize nothing": "hideScopeVisualizer" },
"visualization_type": {
"removal": "removal",
"iteration": "iteration"
}
},
"experimental/experimental_actions.csv": {
"experimental_action": {
"from": "experimental.setInstanceReference"
}
},
"experimental/wrapper_snippets.csv": {},
"experimental/insertion_snippets.csv": {},
"experimental/insertion_snippets_single_phrase.csv": {},
"experimental/miscellaneous.csv": {
"phrase_terminator": { "over": "phraseTerminator" }
},
"experimental/actions_custom.csv": {},
"experimental/regex_scope_types.csv": {},
"hat_styles.csv": {
"hat_color": {
"blue": "blue",
"green": "green",
"red": "red",
"pink": "pink",
"yellow": "yellow",
"navy": "userColor1",
"apricot": "userColor2"
},
"hat_shape": {
"ex": "ex",
"fox": "fox",
"wing": "wing",
"hole": "hole",
"frame": "frame",
"curve": "curve",
"eye": "eye",
"play": "play",
"cross": "crosshairs",
"bolt": "bolt"
}
}
}
+248
View File
@@ -0,0 +1,248 @@
import json
from pathlib import Path
from typing import Callable, Concatenate, ParamSpec, TypeVar
from talon import app, cron, fs, registry
from .actions.actions import ACTION_LIST_NAMES
from .csv_overrides import (
SPOKEN_FORM_HEADER,
ListToSpokenForms,
SpokenFormEntry,
init_csv_and_watch_changes,
)
from .get_grapheme_spoken_form_entries import (
get_grapheme_spoken_form_entries,
get_graphemes_talon_list,
grapheme_capture_name,
)
from .marks.decorated_mark import init_hats
from .spoken_forms_output import SpokenFormsOutput
from .spoken_scope_forms import init_scope_spoken_forms
JSON_FILE = Path(__file__).parent / "spoken_forms.json"
disposables: list[Callable] = []
P = ParamSpec("P")
R = TypeVar("R")
def auto_construct_defaults(
spoken_forms: dict[str, ListToSpokenForms],
handle_new_values: Callable[[str, list[SpokenFormEntry]], None],
f: Callable[
Concatenate[str, ListToSpokenForms, Callable[[list[SpokenFormEntry]], None], P],
R,
],
):
"""
Decorator that automatically constructs the default values for the
`default_values` parameter of `f` based on the spoken forms in
`spoken_forms`, by extracting the value at the key given by the csv
filename.
Note that we only ever pass `init_csv_and_watch_changes` as `f`. The
reason we have this decorator is so that we can destructure the kwargs
of `init_csv_and_watch_changes` to remove the `default_values` parameter.
Args:
spoken_forms (dict[str, ListToSpokenForms]): The spoken forms
handle_new_values (Callable[[ListToSpokenForms], None]): A callback to be called when the lists are updated
f (Callable[Concatenate[str, ListToSpokenForms, P], R]): Will always be `init_csv_and_watch_changes`
"""
def ret(filename: str, *args: P.args, **kwargs: P.kwargs) -> R:
default_values = spoken_forms[filename]
return f(
filename,
default_values,
lambda new_values: handle_new_values(filename, new_values),
*args,
**kwargs,
)
return ret
# Maps from Talon list name to the type of the value in that list, e.g.
# `pairedDelimiter` or `simpleScopeTypeType`
# FIXME: This is a hack until we generate spoken_forms.json from Typescript side
# At that point we can just include its type as part of that file
LIST_TO_TYPE_MAP = {
"wrapper_selectable_paired_delimiter": "pairedDelimiter",
"selectable_only_paired_delimiter": "pairedDelimiter",
"wrapper_only_paired_delimiter": "pairedDelimiter",
"surrounding_pair_scope_type": "pairedDelimiter",
"scope_type": "simpleScopeTypeType",
"glyph_scope_type": "complexScopeTypeType",
"custom_regex_scope_type": "customRegex",
**{
action_list_name: "action"
for action_list_name in ACTION_LIST_NAMES
if action_list_name != "custom_action"
},
"custom_action": "customAction",
}
def update():
global disposables
for disposable in disposables:
disposable()
with open(JSON_FILE, encoding="utf-8") as file:
spoken_forms = json.load(file)
initialized = False
# Maps from csv name to list of SpokenFormEntry
custom_spoken_forms: dict[str, list[SpokenFormEntry]] = {}
spoken_forms_output = SpokenFormsOutput()
spoken_forms_output.init()
graphemes_talon_list = get_graphemes_talon_list()
def update_spoken_forms_output():
spoken_forms_output.write(
[
*[
{
"type": LIST_TO_TYPE_MAP[entry.list_name],
"id": entry.id,
"spokenForms": entry.spoken_forms,
}
for spoken_form_list in custom_spoken_forms.values()
for entry in spoken_form_list
if entry.list_name in LIST_TO_TYPE_MAP
],
*get_grapheme_spoken_form_entries(graphemes_talon_list),
]
)
def handle_new_values(csv_name: str, values: list[SpokenFormEntry]):
custom_spoken_forms[csv_name] = values
if initialized:
# On first run, we just do one update at the end, so we suppress
# writing until we get there
init_scope_spoken_forms(graphemes_talon_list)
update_spoken_forms_output()
handle_csv = auto_construct_defaults(
spoken_forms,
handle_new_values,
init_csv_and_watch_changes,
)
disposables = [
handle_csv("actions.csv"),
handle_csv("target_connectives.csv"),
handle_csv("modifiers.csv"),
handle_csv("positions.csv"),
handle_csv(
"paired_delimiters.csv",
pluralize_lists=[
"selectable_only_paired_delimiter",
"wrapper_selectable_paired_delimiter",
],
),
handle_csv("special_marks.csv"),
handle_csv("scope_visualizer.csv"),
handle_csv("experimental/experimental_actions.csv"),
handle_csv(
"modifier_scope_types.csv",
pluralize_lists=[
"scope_type",
"glyph_scope_type",
"surrounding_pair_scope_type",
],
extra_allowed_values=[
"private.fieldAccess",
"textFragment",
"disqualifyDelimiter",
"pairDelimiter",
"interior",
],
default_list_name="scope_type",
),
# DEPRECATED @ 2025-02-01
handle_csv(
"experimental/wrapper_snippets.csv",
deprecated=True,
allow_unknown_values=True,
default_list_name="wrapper_snippet",
),
handle_csv(
"experimental/insertion_snippets.csv",
deprecated=True,
allow_unknown_values=True,
default_list_name="insertion_snippet_no_phrase",
),
handle_csv(
"experimental/insertion_snippets_single_phrase.csv",
deprecated=True,
allow_unknown_values=True,
default_list_name="insertion_snippet_single_phrase",
),
handle_csv(
"experimental/miscellaneous.csv",
deprecated=True,
),
# ---
handle_csv(
"experimental/actions_custom.csv",
headers=[SPOKEN_FORM_HEADER, "VSCode command"],
allow_unknown_values=True,
default_list_name="custom_action",
),
handle_csv(
"experimental/regex_scope_types.csv",
headers=[SPOKEN_FORM_HEADER, "Regex"],
allow_unknown_values=True,
default_list_name="custom_regex_scope_type",
pluralize_lists=["custom_regex_scope_type"],
),
init_hats(
spoken_forms["hat_styles.csv"]["hat_color"],
spoken_forms["hat_styles.csv"]["hat_shape"],
),
]
init_scope_spoken_forms(graphemes_talon_list)
update_spoken_forms_output()
initialized = True
def on_watch(path, flags):
if JSON_FILE.match(path):
update()
update_captures_cron = None
def update_captures_debounced(updated_captures: set[str]):
if grapheme_capture_name not in updated_captures:
return
global update_captures_cron
cron.cancel(update_captures_cron)
update_captures_cron = cron.after("100ms", update_captures)
def update_captures():
global update_captures_cron
update_captures_cron = None
update()
def on_ready():
update()
registry.register("update_captures", update_captures_debounced)
fs.watch(JSON_FILE.parent, on_watch)
app.register("ready", on_ready)
@@ -0,0 +1,48 @@
import json
from pathlib import Path
from typing import TypedDict
from talon import app
SPOKEN_FORMS_OUTPUT_PATH = Path.home() / ".cursorless" / "state.json"
STATE_JSON_VERSION_NUMBER = 0
class SpokenFormOutputEntry(TypedDict):
type: str
id: str
spokenForms: list[str]
class SpokenFormsOutput:
"""
Writes spoken forms to a json file for use by the Cursorless vscode extension
"""
def init(self):
try:
SPOKEN_FORMS_OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
except Exception:
error_message = (
f"Error creating spoken form dir {SPOKEN_FORMS_OUTPUT_PATH.parent}"
)
print(error_message)
app.notify(error_message)
def write(self, spoken_forms: list[SpokenFormOutputEntry]):
with open(SPOKEN_FORMS_OUTPUT_PATH, "w", encoding="UTF-8") as out:
try:
out.write(
json.dumps(
{
"version": STATE_JSON_VERSION_NUMBER,
"spokenForms": spoken_forms,
}
)
)
except Exception:
error_message = (
f"Error writing spoken form json {SPOKEN_FORMS_OUTPUT_PATH}"
)
print(error_message)
app.notify(error_message)
@@ -0,0 +1,49 @@
from talon import Context, scope
from .csv_overrides import csv_get_ctx, csv_get_normalized_ctx
def init_scope_spoken_forms(graphemes_talon_list: dict[str, str]):
create_flattened_talon_list(csv_get_ctx(), graphemes_talon_list)
if is_cursorless_test_mode():
create_flattened_talon_list(csv_get_normalized_ctx(), graphemes_talon_list)
def create_flattened_talon_list(ctx: Context, graphemes_talon_list: dict[str, str]):
lists_to_merge = {
"cursorless_scope_type": "simple",
"cursorless_selectable_only_paired_delimiter": "surroundingPair",
"cursorless_wrapper_selectable_paired_delimiter": "surroundingPair",
"cursorless_surrounding_pair_scope_type": "surroundingPair",
}
# If the user have no custom regex scope type, then that list is missing from the context
if "user.cursorless_custom_regex_scope_type" in ctx.lists.keys(): # noqa: SIM118
lists_to_merge["cursorless_custom_regex_scope_type"] = "customRegex"
scope_types_singular: dict[str, str] = {}
scope_types_plural: dict[str, str] = {}
for list_name, prefix in lists_to_merge.items():
for key, value in ctx.lists[f"user.{list_name}"].items():
scope_types_singular[key] = f"{prefix}.{value}"
for key, value in ctx.lists[f"user.{list_name}_plural"].items():
scope_types_plural[key] = f"{prefix}.{value}"
glyph_singular_spoken_forms = ctx.lists["user.cursorless_glyph_scope_type"]
glyph_plural_spoken_forms = ctx.lists["user.cursorless_glyph_scope_type_plural"]
for grapheme_key, grapheme_value in graphemes_talon_list.items():
value = f"glyph.{grapheme_value}"
for glyph in glyph_singular_spoken_forms:
key = f"{glyph} {grapheme_key}"
scope_types_singular[key] = value
for glyph in glyph_plural_spoken_forms:
key = f"{glyph} {grapheme_key}"
scope_types_plural[key] = value
ctx.lists["user.cursorless_scope_type_flattened"] = scope_types_singular
ctx.lists["user.cursorless_scope_type_flattened_plural"] = scope_types_plural
def is_cursorless_test_mode():
return "user.cursorless_spoken_form_test" in scope.get("mode")
@@ -0,0 +1,43 @@
from typing import Union
from talon import Module
from .target_types import ListDestination, PrimitiveDestination
mod = Module()
mod.list(
"cursorless_insertion_mode_before_after",
desc="Cursorless insertion mode before/after",
)
mod.list("cursorless_insertion_mode_to", desc="Cursorless insertion mode to")
@mod.capture(
rule="{user.cursorless_insertion_mode_before_after} | {user.cursorless_insertion_mode_to}",
)
def cursorless_insertion_mode(m) -> str:
try:
return m.cursorless_insertion_mode_before_after
except AttributeError:
return "to"
@mod.capture(
rule=(
"<user.cursorless_insertion_mode> <user.cursorless_target> "
"({user.cursorless_list_connective} <user.cursorless_insertion_mode> <user.cursorless_target>)*"
)
)
def cursorless_destination(m) -> Union[ListDestination, PrimitiveDestination]:
destinations = [
PrimitiveDestination(insertion_mode, target)
for insertion_mode, target in zip(
m.cursorless_insertion_mode_list, m.cursorless_target_list
)
]
if len(destinations) == 1:
return destinations[0]
return ListDestination(destinations)
@@ -0,0 +1,25 @@
from talon import Module
from .target_types import PrimitiveTarget
mod = Module()
@mod.capture(
rule=(
"<user.cursorless_modifier>+ [<user.cursorless_mark>] | <user.cursorless_mark>"
)
)
def cursorless_primitive_target(m) -> PrimitiveTarget:
mark = getattr(m, "cursorless_mark", None)
modifiers = getattr(m, "cursorless_modifier_list", None)
# for grammar performance reasons, the literal modifier is exposed to Talon as a mark,
# but is converted to a modifier in the engine.
if mark is not None and mark["type"] == "literal":
if modifiers is None:
modifiers = []
modifiers.append(mark["modifier"])
mark = None
return PrimitiveTarget(mark, modifiers)
@@ -0,0 +1,66 @@
from dataclasses import dataclass
from typing import Optional
from talon import Module
from .target_types import ImplicitTarget, PrimitiveTarget, RangeTarget, RangeTargetType
mod = Module()
mod.list(
"cursorless_range_connective",
desc="A range joiner that indicates whether to include or exclude anchor and active",
)
@dataclass
class RangeConnective:
excludeAnchor: bool
excludeActive: bool
@dataclass
class RangeConnectiveWithType:
connective: RangeConnective
type: Optional[RangeTargetType]
@mod.capture(rule="{user.cursorless_range_connective}")
def cursorless_range_connective(m) -> RangeConnective:
return RangeConnective(
m.cursorless_range_connective in ["rangeExclusive", "rangeExcludingStart"],
m.cursorless_range_connective in ["rangeExclusive", "rangeExcludingEnd"],
)
@mod.capture(
rule="[<user.cursorless_range_type>] <user.cursorless_range_connective> | <user.cursorless_range_type>"
)
def cursorless_range_connective_with_type(m) -> RangeConnectiveWithType:
return RangeConnectiveWithType(
getattr(m, "cursorless_range_connective", RangeConnective(False, False)),
getattr(m, "cursorless_range_type", None),
)
@mod.capture(
rule=(
"[<user.cursorless_primitive_target>] <user.cursorless_range_connective_with_type> <user.cursorless_primitive_target>"
)
)
def cursorless_range_target(m) -> RangeTarget:
primitive_targets: list[PrimitiveTarget] = m.cursorless_primitive_target_list
range_connective_with_type: RangeConnectiveWithType = (
m.cursorless_range_connective_with_type
)
range_connective = range_connective_with_type.connective
anchor = ImplicitTarget() if len(primitive_targets) == 1 else primitive_targets[0]
return RangeTarget(
anchor,
primitive_targets[-1],
range_connective.excludeAnchor,
range_connective.excludeActive,
range_connective_with_type.type,
)
@@ -0,0 +1,20 @@
from talon import Module
mod = Module()
mod.list(
"cursorless_range_type",
desc="A range modifier that indicates the specific type of the range",
)
# Maps from the id we use in the spoken form csv to the modifier type
# expected by Cursorless extension
range_type_map = {
"verticalRange": "vertical",
}
@mod.capture(rule="{user.cursorless_range_type}")
def cursorless_range_type(m) -> str:
"""Range type modifier"""
return range_type_map[m.cursorless_range_type]
@@ -0,0 +1,35 @@
from typing import Union
from talon import Module
from .target_types import ListTarget, PrimitiveTarget, RangeTarget
mod = Module()
mod.list(
"cursorless_list_connective",
desc="A list joiner",
)
@mod.capture(
rule=("<user.cursorless_range_target> | <user.cursorless_primitive_target>")
)
def cursorless_primitive_or_range_target(m) -> Union[RangeTarget, PrimitiveTarget]:
return m[0]
@mod.capture(
rule=(
"<user.cursorless_primitive_or_range_target> "
"({user.cursorless_list_connective} <user.cursorless_primitive_or_range_target>)*"
)
)
def cursorless_target(m) -> Union[ListTarget, RangeTarget, PrimitiveTarget]:
targets = m.cursorless_primitive_or_range_target_list
if len(targets) == 1:
return targets[0]
return ListTarget(targets)
@@ -0,0 +1,74 @@
from dataclasses import dataclass
from typing import Any, Literal, Optional, Union
from ..marks.mark_types import Mark
RangeTargetType = Literal["vertical"]
@dataclass
class PrimitiveTarget:
type = "primitive"
mark: Optional[Mark]
modifiers: Optional[list[dict[str, Any]]]
@dataclass
class ImplicitTarget:
type = "implicit"
@dataclass
class RangeTarget:
type = "range"
anchor: Union[PrimitiveTarget, ImplicitTarget]
active: PrimitiveTarget
excludeAnchor: bool
excludeActive: bool
rangeType: Optional[RangeTargetType]
@dataclass
class ListTarget:
type = "list"
elements: list[Union[PrimitiveTarget, RangeTarget]]
CursorlessTarget = Union[
ListTarget,
RangeTarget,
PrimitiveTarget,
ImplicitTarget,
]
CursorlessExplicitTarget = Union[
ListTarget,
RangeTarget,
PrimitiveTarget,
]
InsertionMode = Literal["to", "before", "after"]
@dataclass
class PrimitiveDestination:
type = "primitive"
insertionMode: InsertionMode
target: Union[ListTarget, RangeTarget, PrimitiveTarget]
@dataclass
class ImplicitDestination:
type = "implicit"
@dataclass
class ListDestination:
type = "list"
destinations: list[PrimitiveDestination]
CursorlessDestination = Union[
ListDestination,
PrimitiveDestination,
ImplicitDestination,
]
+21
View File
@@ -0,0 +1,21 @@
"""
Stores terms that are used in many different places
"""
from talon import Context, Module
mod = Module()
ctx = Context()
mod.list(
"cursorless_homophone",
"Various alternative pronunciations of 'cursorless' to improve accuracy",
)
# FIXME: Remove type ignore once Talon supports list types
# See https://github.com/talonvoice/talon/issues/654
ctx.lists["user.cursorless_homophone"] = [ # pyright: ignore [reportArgumentType]
"cursorless",
"cursor less",
"cursor list",
]
View File
+175
View File
@@ -0,0 +1,175 @@
# From https://github.com/jpvanhal/inflection/blob/b00d4d348b32ef5823221b20ee4cbd1d2d924462/inflection/__init__.py
# License https://github.com/jpvanhal/inflection/blob/b00d4d348b32ef5823221b20ee4cbd1d2d924462/LICENSE
import re
PLURALS = [
(r"(?i)(quiz)$", r"\1zes"),
(r"(?i)^(oxen)$", r"\1"),
(r"(?i)^(ox)$", r"\1en"),
(r"(?i)(m|l)ice$", r"\1ice"),
(r"(?i)(m|l)ouse$", r"\1ice"),
(r"(?i)(passer)s?by$", r"\1sby"),
(r"(?i)(matr|vert|ind)(?:ix|ex)$", r"\1ices"),
(r"(?i)(x|ch|ss|sh)$", r"\1es"),
(r"(?i)([^aeiouy]|qu)y$", r"\1ies"),
(r"(?i)(hive)$", r"\1s"),
(r"(?i)([lr])f$", r"\1ves"),
(r"(?i)([^f])fe$", r"\1ves"),
(r"(?i)sis$", "ses"),
(r"(?i)([ti])a$", r"\1a"),
(r"(?i)([ti])um$", r"\1a"),
(r"(?i)(buffal|potat|tomat)o$", r"\1oes"),
(r"(?i)(bu)s$", r"\1ses"),
(r"(?i)(alias|status)$", r"\1es"),
(r"(?i)(octop|vir)i$", r"\1i"),
(r"(?i)(octop|vir)us$", r"\1i"),
(r"(?i)^(ax|test)is$", r"\1es"),
(r"(?i)s$", "s"),
(r"$", "s"),
]
SINGULARS = [
(r"(?i)(database)s$", r"\1"),
(r"(?i)(quiz)zes$", r"\1"),
(r"(?i)(matr)ices$", r"\1ix"),
(r"(?i)(vert|ind)ices$", r"\1ex"),
(r"(?i)(passer)sby$", r"\1by"),
(r"(?i)^(ox)en", r"\1"),
(r"(?i)(alias|status)(es)?$", r"\1"),
(r"(?i)(octop|vir)(us|i)$", r"\1us"),
(r"(?i)^(a)x[ie]s$", r"\1xis"),
(r"(?i)(cris|test)(is|es)$", r"\1is"),
(r"(?i)(shoe)s$", r"\1"),
(r"(?i)(o)es$", r"\1"),
(r"(?i)(bus)(es)?$", r"\1"),
(r"(?i)(m|l)ice$", r"\1ouse"),
(r"(?i)(x|ch|ss|sh)es$", r"\1"),
(r"(?i)(m)ovies$", r"\1ovie"),
(r"(?i)(s)eries$", r"\1eries"),
(r"(?i)([^aeiouy]|qu)ies$", r"\1y"),
(r"(?i)([lr])ves$", r"\1f"),
(r"(?i)(tive)s$", r"\1"),
(r"(?i)(hive)s$", r"\1"),
(r"(?i)([^f])ves$", r"\1fe"),
(r"(?i)(t)he(sis|ses)$", r"\1hesis"),
(r"(?i)(s)ynop(sis|ses)$", r"\1ynopsis"),
(r"(?i)(p)rogno(sis|ses)$", r"\1rognosis"),
(r"(?i)(p)arenthe(sis|ses)$", r"\1arenthesis"),
(r"(?i)(d)iagno(sis|ses)$", r"\1iagnosis"),
(r"(?i)(b)a(sis|ses)$", r"\1asis"),
(r"(?i)(a)naly(sis|ses)$", r"\1nalysis"),
(r"(?i)([ti])a$", r"\1um"),
(r"(?i)(n)ews$", r"\1ews"),
(r"(?i)(ss)$", r"\1"),
(r"(?i)s$", ""),
]
UNCOUNTABLES = {
"equipment",
"fish",
"information",
"jeans",
"money",
"rice",
"series",
"sheep",
"species",
}
def _irregular(singular: str, plural: str) -> None:
"""
A convenience function to add appropriate rules to plurals and singular
for irregular words.
:param singular: irregular word in singular form
:param plural: irregular word in plural form
"""
def caseinsensitive(string: str) -> str:
return "".join("[" + char + char.upper() + "]" for char in string)
if singular[0].upper() == plural[0].upper():
PLURALS.insert(0, (rf"(?i)({singular[0]}){singular[1:]}$", r"\1" + plural[1:]))
PLURALS.insert(0, (rf"(?i)({plural[0]}){plural[1:]}$", r"\1" + plural[1:]))
SINGULARS.insert(0, (rf"(?i)({plural[0]}){plural[1:]}$", r"\1" + singular[1:]))
else:
PLURALS.insert(
0,
(
rf"{singular[0].upper()}{caseinsensitive(singular[1:])}$",
plural[0].upper() + plural[1:],
),
)
PLURALS.insert(
0,
(
rf"{singular[0].lower()}{caseinsensitive(singular[1:])}$",
plural[0].lower() + plural[1:],
),
)
PLURALS.insert(
0,
(
rf"{plural[0].upper()}{caseinsensitive(plural[1:])}$",
plural[0].upper() + plural[1:],
),
)
PLURALS.insert(
0,
(
rf"{plural[0].lower()}{caseinsensitive(plural[1:])}$",
plural[0].lower() + plural[1:],
),
)
SINGULARS.insert(
0,
(
rf"{plural[0].upper()}{caseinsensitive(plural[1:])}$",
singular[0].upper() + singular[1:],
),
)
SINGULARS.insert(
0,
(
rf"{plural[0].lower()}{caseinsensitive(plural[1:])}$",
singular[0].lower() + singular[1:],
),
)
def pluralize(word: str) -> str:
"""
Return the plural form of a word.
Examples::
>>> pluralize("posts")
'posts'
>>> pluralize("octopus")
'octopi'
>>> pluralize("sheep")
'sheep'
>>> pluralize("CamelOctopus")
'CamelOctopi'
"""
if not word or word.lower() in UNCOUNTABLES:
return word
else:
for rule, replacement in PLURALS:
if re.search(rule, word):
return re.sub(rule, replacement, word)
return word
_irregular("person", "people")
_irregular("man", "men")
_irregular("human", "humans")
_irregular("child", "children")
_irregular("sex", "sexes")
_irregular("move", "moves")
_irregular("cow", "kine")
_irregular("zombie", "zombies")
+137
View File
@@ -0,0 +1,137 @@
# From https://github.com/linjackson78/jstyleson/blob/8c47cc9e665b3b1744cccfaa7a650de5f3c575dd/jstyleson.py
# License https://github.com/linjackson78/jstyleson/blob/8c47cc9e665b3b1744cccfaa7a650de5f3c575dd/LICENSE
import json
def dispose(json_str):
"""Clear all comments in json_str.
Clear JS-style comments like // and /**/ in json_str.
Accept a str or unicode as input.
Args:
json_str: A json string of str or unicode to clean up comment
Returns:
str: The str without comments (or unicode if you pass in unicode)
"""
result_str = list(json_str)
escaped = False
normal = True
sl_comment = False
ml_comment = False
quoted = False
a_step_from_comment = False
a_step_from_comment_away = False
former_index = None
for index, char in enumerate(json_str):
if escaped: # We have just met a '\'
escaped = False
continue
if a_step_from_comment: # We have just met a '/'
if char != "/" and char != "*":
a_step_from_comment = False
normal = True
continue
if a_step_from_comment_away: # We have just met a '*'
if char != "/":
a_step_from_comment_away = False
if char == '"':
if normal and not escaped:
# We are now in a string
quoted = True
normal = False
elif quoted and not escaped:
# We are now out of a string
quoted = False
normal = True
elif char == "\\":
# '\' should not take effect in comment
if normal or quoted:
escaped = True
elif char == "/":
if a_step_from_comment:
# Now we are in single line comment
a_step_from_comment = False
sl_comment = True
normal = False
former_index = index - 1
elif a_step_from_comment_away:
# Now we are out of comment
a_step_from_comment_away = False
normal = True
ml_comment = False
for i in range(former_index, index + 1):
result_str[i] = ""
elif normal:
# Now we are just one step away from comment
a_step_from_comment = True
normal = False
elif char == "*":
if a_step_from_comment:
# We are now in multi-line comment
a_step_from_comment = False
ml_comment = True
normal = False
former_index = index - 1
elif ml_comment:
a_step_from_comment_away = True
elif char == "\n":
if sl_comment:
sl_comment = False
normal = True
for i in range(former_index, index + 1):
result_str[i] = ""
elif char == "]" or char == "}":
if normal:
_remove_last_comma(result_str, index)
# To remove single line comment which is the last line of json
if sl_comment:
sl_comment = False
normal = True
for i in range(former_index, len(json_str)):
result_str[i] = ""
# Show respect to original input if we are in python2
return ("" if isinstance(json_str, str) else "").join(result_str)
# There may be performance suffer backtracking the last comma
def _remove_last_comma(str_list, before_index):
i = before_index - 1
while str_list[i].isspace() or not str_list[i]:
i -= 1
# This is the first none space char before before_index
if str_list[i] == ",":
str_list[i] = ""
# Below are just some wrapper function around the standard json module.
def loads(text, **kwargs):
return json.loads(dispose(text), **kwargs)
def load(fp, **kwargs):
return loads(fp.read(), **kwargs)
def dumps(obj, **kwargs):
return json.dumps(obj, **kwargs)
def dump(obj, fp, **kwargs):
json.dump(obj, fp, **kwargs)
+1
View File
@@ -0,0 +1 @@
COMMAND_VERSION = 7