set up vscode and some related talon

This commit is contained in:
Kate 2024-11-17 14:24:34 -07:00
parent 9008367ef1
commit c8ead81d0f
84 changed files with 5249 additions and 0 deletions

View file

@ -161,6 +161,7 @@
./nixos/configs/flatpak.nix
./nixos/configs/fonts-linux.nix
./nixos/configs/music-server.nix
./nixos/configs/dotfiles/vscode.nix
#./nixos/overlays/fixup-signal
./nixos/overlays/fixup-imhex.nix

View file

@ -0,0 +1,68 @@
#
# Visual Studio Code + Dance (kakoune mode) Experiments
#
{ pkgs, deprekages, lib, ... }: {
programs.vscode = {
enable = true;
#
# Core config.
#
# Extensions to include with vscode.
extensions = with (pkgs.vscode-extensions // deprekages.vscode-extensions); [
# Requires a qualified name due to the leading '1'.
pkgs.vscode-extensions."1Password".op-vscode
# appearance
brandonkirbyson.solarized-palenight
# behavior
gregoire.dance
editorconfig.editorconfig
# general add-ons
ms-vscode.hexeditor
ms-vscode.cmake-tools
ms-vscode.makefile-tools
# rust
serayuzgur.crates
rust-lang.rust-analyzer
njpwerner.autodocstring
# c/c++
ms-vscode.cpptools
# misc languages
golang.go
graphql.vscode-graphql
graphql.vscode-graphql-syntax
# accessibility / talon, for when our neuro condition is wonk
pokey.talon
pokey.cursorless
];
# VSCode settings; runtime settings are immutable.
userSettings = {
# Theming.
"workbench.colorTheme" = lib.mkForce "Solarized-Palenight";
};
#
# For now, let Nix manage everything.
#
enableUpdateCheck = false;
enableExtensionUpdateCheck = false;
mutableExtensionsDir = false;
};
}

View file

@ -0,0 +1,9 @@
#
# Entry-point for vscode systems.
#
{ normalizeModule, ... }:
{
imports = [
(normalizeModule ./vscode.hm.nix)
];
}

View file

@ -99,6 +99,9 @@ flake-utils.lib.eachDefaultSystem (
# kakoune
kak-tree-sitter = callPackage ./packages/kak-tree-sitter { };
# vscode
vscode-extensions = callPackage ./packages/vscode-extensions.nix { };
# xonsh and xontribs
xonsh-with-xontribs = pkgs.xonsh.override {
extraPackages = pythonPackages: [

View file

@ -0,0 +1,36 @@
#
# VSCode extensions we use.
#
{ vscode-utils, ... }:
let
# Helper for creating simple marketplace extesnsions.
quickMarketplaceExtension = args: vscode-utils.buildVscodeMarketplaceExtension { mktplcRef = args; };
in
{
#
# Various simple extension definitions.
#
gregoire.dance = quickMarketplaceExtension {
name = "dance";
publisher = "gregoire";
version = "0.5.15001";
hash = "sha256-gGTpeOQeIQj2ObyC6504+lzLFUS35RNw5z2/isPRpyM=";
};
pokey.talon = quickMarketplaceExtension {
name = "talon";
publisher = "pokey";
version = "0.2.0";
hash = "sha256-BPc0jGGoKctANP4m305hoG9dgrhjxZtFdCdkTeWh/Xk=";
};
pokey.cursorless = quickMarketplaceExtension {
name = "cursorless";
publisher = "pokey";
version = "0.29.1295";
hash = "sha256-QIfAu76QhIII8Xnt5lCCVsZAaa57OHszC4ZQuq67MZs=";
};
}

View file

@ -0,0 +1 @@
* @pokey

View file

@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Create issue on cursorless-dev/cursorless
url: https://github.com/cursorless-dev/cursorless/issues/new
about: Please file issues on the main Cursorless repository

4
talon/cursorless-talon/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
*.flac
data/
.vscode/settings.json
.DS_Store

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 Brandon Virgil Rule
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,32 @@
<h1 align="center">Welcome to Cursorless!</h1>
<p align="center">
<a href="https://marketplace.visualstudio.com/items?itemName=pokey.cursorless&ssr=false#review-details" target="_blank">
<img alt="Rating" src="https://img.shields.io/visual-studio-marketplace/stars/pokey.cursorless?logo=visualstudiocode" />
</a>
<a href="https://www.cursorless.org/docs/" target="_blank">
<img alt="Documentation" src="https://img.shields.io/badge/documentation-yes-brightgreen.svg?logo=data:image/svg%2bxml;base64,PHN2ZyBhcmlhLWhpZGRlbj0idHJ1ZSIgZm9jdXNhYmxlPSJmYWxzZSIgZGF0YS1wcmVmaXg9ImZhcyIgZGF0YS1pY29uPSJib29rIiBjbGFzcz0ic3ZnLWlubGluZS0tZmEgZmEtYm9vayBmYS13LTE0IiByb2xlPSJpbWciCiAgICB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0NDggNTEyIj4KICAgIDxwYXRoIGZpbGw9IndoaXRlIiBkPSJNNDQ4IDM2MFYyNGMwLTEzLjMtMTAuNy0yNC0yNC0yNEg5NkM0MyAwIDAgNDMgMCA5NnYzMjBjMCA1MyA0MyA5NiA5NiA5NmgzMjhjMTMuMyAwIDI0LTEwLjcgMjQtMjR2LTE2YzAtNy41LTMuNS0xNC4zLTguOS0xOC43LTQuMi0xNS40LTQuMi01OS4zIDAtNzQuNyA1LjQtNC4zIDguOS0xMS4xIDguOS0xOC42ek0xMjggMTM0YzAtMy4zIDIuNy02IDYtNmgyMTJjMy4zIDAgNiAyLjcgNiA2djIwYzAgMy4zLTIuNyA2LTYgNkgxMzRjLTMuMyAwLTYtMi43LTYtNnYtMjB6bTAgNjRjMC0zLjMgMi43LTYgNi02aDIxMmMzLjMgMCA2IDIuNyA2IDZ2MjBjMCAzLjMtMi43IDYtNiA2SDEzNGMtMy4zIDAtNi0yLjctNi02di0yMHptMjUzLjQgMjUwSDk2Yy0xNy43IDAtMzItMTQuMy0zMi0zMiAwLTE3LjYgMTQuNC0zMiAzMi0zMmgyODUuNGMtMS45IDE3LjEtMS45IDQ2LjkgMCA2NHoiPjwvcGF0aD4KPC9zdmc+" />
</a>
<a href="https://github.com/cursorless-dev/cursorless/actions/workflows/test.yml?query=branch%3Amain" target="_blank">
<img alt="Tests" src="https://img.shields.io/github/actions/workflow/status/cursorless-dev/cursorless-vscode/test.yml?branch=main&logo=github&label=tests" />
</a>
<a href="https://github.com/cursorless-dev/cursorless/graphs/contributors" target="_blank">
<img alt="Maintenance" src="https://img.shields.io/maintenance/yes/2024.svg?logo=data:image/svg%2bxml;base64,PHN2ZyBhcmlhLWhpZGRlbj0idHJ1ZSIgZm9jdXNhYmxlPSJmYWxzZSIgZGF0YS1wcmVmaXg9ImZhcyIgZGF0YS1pY29uPSJ3cmVuY2giIGNsYXNzPSJzdmctaW5saW5lLS1mYSBmYS13cmVuY2ggZmEtdy0xNiIgcm9sZT0iaW1nIgogICAgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB2aWV3Qm94PSIwIDAgNTEyIDUxMiI+CiAgICA8cGF0aCBmaWxsPSJ3aGl0ZSIgZD0iTTUwNy43MyAxMDkuMWMtMi4yNC05LjAzLTEzLjU0LTEyLjA5LTIwLjEyLTUuNTFsLTc0LjM2IDc0LjM2LTY3Ljg4LTExLjMxLTExLjMxLTY3Ljg4IDc0LjM2LTc0LjM2YzYuNjItNi42MiAzLjQzLTE3LjktNS42Ni0yMC4xNi00Ny4zOC0xMS43NC05OS41NS45MS0xMzYuNTggMzcuOTMtMzkuNjQgMzkuNjQtNTAuNTUgOTcuMS0zNC4wNSAxNDcuMkwxOC43NCA0MDIuNzZjLTI0Ljk5IDI0Ljk5LTI0Ljk5IDY1LjUxIDAgOTAuNSAyNC45OSAyNC45OSA2NS41MSAyNC45OSA5MC41IDBsMjEzLjIxLTIxMy4yMWM1MC4xMiAxNi43MSAxMDcuNDcgNS42OCAxNDcuMzctMzQuMjIgMzcuMDctMzcuMDcgNDkuNy04OS4zMiAzNy45MS0xMzYuNzN6TTY0IDQ3MmMtMTMuMjUgMC0yNC0xMC43NS0yNC0yNCAwLTEzLjI2IDEwLjc1LTI0IDI0LTI0czI0IDEwLjc0IDI0IDI0YzAgMTMuMjUtMTAuNzUgMjQtMjQgMjR6Ij48L3BhdGg+Cjwvc3ZnPg==" />
</a>
<a href="https://github.com/cursorless-dev/cursorless/blob/main/LICENSE" target="_blank">
<img alt="License: MIT" src="https://img.shields.io/github/license/cursorless-dev/cursorless-vscode?color=success&logo=data:image/svg%2bxml;base64,PHN2ZyBhcmlhLWhpZGRlbj0idHJ1ZSIgZm9jdXNhYmxlPSJmYWxzZSIgZGF0YS1wcmVmaXg9ImZhcyIgZGF0YS1pY29uPSJiYWxhbmNlLXNjYWxlIiBjbGFzcz0ic3ZnLWlubGluZS0tZmEgZmEtYmFsYW5jZS1zY2FsZSBmYS13LTIwIiByb2xlPSJpbWciCiAgICB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NDAgNTEyIj4KICAgIDxwYXRoIGZpbGw9IndoaXRlIiBkPSJNMjU2IDMzNmgtLjAyYzAtMTYuMTggMS4zNC04LjczLTg1LjA1LTE4MS41MS0xNy42NS0zNS4yOS02OC4xOS0zNS4zNi04NS44NyAwQy0yLjA2IDMyOC43NS4wMiAzMjAuMzMuMDIgMzM2SDBjMCA0NC4xOCA1Ny4zMSA4MCAxMjggODBzMTI4LTM1LjgyIDEyOC04MHpNMTI4IDE3Nmw3MiAxNDRINTZsNzItMTQ0em01MTEuOTggMTYwYzAtMTYuMTggMS4zNC04LjczLTg1LjA1LTE4MS41MS0xNy42NS0zNS4yOS02OC4xOS0zNS4zNi04NS44NyAwLTg3LjEyIDE3NC4yNi04NS4wNCAxNjUuODQtODUuMDQgMTgxLjUxSDM4NGMwIDQ0LjE4IDU3LjMxIDgwIDEyOCA4MHMxMjgtMzUuODIgMTI4LTgwaC0uMDJ6TTQ0MCAzMjBsNzItMTQ0IDcyIDE0NEg0NDB6bTg4IDEyOEgzNTJWMTUzLjI1YzIzLjUxLTEwLjI5IDQxLjE2LTMxLjQ4IDQ2LjM5LTU3LjI1SDUyOGM4Ljg0IDAgMTYtNy4xNiAxNi0xNlY0OGMwLTguODQtNy4xNi0xNi0xNi0xNkgzODMuNjRDMzY5LjA0IDEyLjY4IDM0Ni4wOSAwIDMyMCAwcy00OS4wNCAxMi42OC02My42NCAzMkgxMTJjLTguODQgMC0xNiA3LjE2LTE2IDE2djMyYzAgOC44NCA3LjE2IDE2IDE2IDE2aDEyOS42MWM1LjIzIDI1Ljc2IDIyLjg3IDQ2Ljk2IDQ2LjM5IDU3LjI1VjQ0OEgxMTJjLTguODQgMC0xNiA3LjE2LTE2IDE2djMyYzAgOC44NCA3LjE2IDE2IDE2IDE2aDQxNmM4Ljg0IDAgMTYtNy4xNiAxNi0xNnYtMzJjMC04Ljg0LTcuMTYtMTYtMTYtMTZ6Ij48L3BhdGg+Cjwvc3ZnPg==" />
</a>
</p>
Cursorless is a spoken language for structural code editing, enabling developers to code by voice at speeds not possible with a keyboard. Cursorless decorates every token on the screen and defines a spoken language for rapid, high-level semantic manipulation of structured text.
This repository holds the Talon side of Cursorless. If you've arrived here as part of the [Cursorless installation process](https://www.cursorless.org/docs/user/installation/), then you're in the right place!
# Contributing
If you're looking to improve Cursorless, note that Cursorless is now maintained as a monorepo, so if you're here in a browser, and your address bar points to https://github.com/cursorless-dev/cursorless-talon, then you're probably in the wrong place. The monorepo is hosted at [`cursorless`](https://github.com/cursorless-dev/cursorless), and the source of truth for these talon files is in the [`cursorless-talon`](https://github.com/cursorless-dev/cursorless/tree/main/cursorless-talon) subdirectory.
See [the contributor docs](https://www.cursorless.org/docs/contributing/) to get started.
# Cursorless talon
This directory contains the talon side of [Cursorless](https://marketplace.visualstudio.com/items?itemName=pokey.cursorless).

View file

@ -0,0 +1 @@
Cursorless is now monorepo 🙌. The docs now live at https://www.cursorless.org/docs/.

View file

@ -0,0 +1 @@
Cursorless is now monorepo 🙌. This document now lives at https://www.cursorless.org/docs/user/customization/.

View file

@ -0,0 +1 @@
Cursorless is now monorepo 🙌. This document now lives at https://www.cursorless.org/docs/user/experimental/

View file

@ -0,0 +1,142 @@
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 = [
"generateSnippet",
"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 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)

View file

@ -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,
}
)

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,
}
)

View file

@ -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,
}
)

View file

@ -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,
}
)

View file

@ -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

View file

@ -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,
}
)

View file

@ -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)

View file

@ -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,
}
)

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,
}
)

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)

View file

@ -0,0 +1,26 @@
from talon import Context, actions
ctx = Context()
ctx.matches = r"""
app: vscode
"""
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"
)

View file

@ -0,0 +1,120 @@
import os
import traceback
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)
traceback.print_exc()
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",
]
)

View file

@ -0,0 +1,161 @@
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",
],
"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(),
},
]
}

View file

@ -0,0 +1,92 @@
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:
"""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.
"""
return next(
spoken_form for spoken_form, v in get_raw_list(list_name).items() if v == value
)
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,
)

View file

@ -0,0 +1,138 @@
from ...actions.actions import ACTION_LIST_NAMES
from ..get_list import get_raw_list, make_dict_readable
def get_actions():
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_connective = list(get_raw_list("swap_connective").keys())[0]
return [
*make_dict_readable(
"action",
simple_actions,
{
"editNewLineAfter": "Edit new line/scope after",
"editNewLineBefore": "Edit new line/scope before",
},
),
{
"id": "replaceWithTarget",
"type": "action",
"variations": [
{
"spokenForm": f"{complex_actions['replaceWithTarget']} <target> <destination>",
"description": "Copy <target> to <destination>",
},
{
"spokenForm": f"{complex_actions['replaceWithTarget']} <target>",
"description": "Insert copy of <target> at cursor",
},
],
},
{
"id": "pasteFromClipboard",
"type": "action",
"variations": [
{
"spokenForm": f"{complex_actions['pasteFromClipboard']} <destination>",
"description": "Paste from clipboard at <destination>",
}
],
},
{
"id": "moveToTarget",
"type": "action",
"variations": [
{
"spokenForm": f"{complex_actions['moveToTarget']} <target> <destination>",
"description": "Move <target> to <destination>",
},
{
"spokenForm": f"{complex_actions['moveToTarget']} <target>",
"description": "Move <target> to cursor position",
},
],
},
{
"id": "swapTargets",
"type": "action",
"variations": [
{
"spokenForm": f"{complex_actions['swapTargets']} <target 1> {swap_connective} <target 2>",
"description": "Swap <target 1> with <target 2>",
},
{
"spokenForm": f"{complex_actions['swapTargets']} {swap_connective} <target>",
"description": "Swap selection with <target>",
},
],
},
{
"id": "applyFormatter",
"type": "action",
"variations": [
{
"spokenForm": f"{complex_actions['applyFormatter']} <formatter> at <target>",
"description": "Reformat <target> as <formatter>",
}
],
},
{
"id": "callAsFunction",
"type": "action",
"variations": [
{
"spokenForm": f"{complex_actions['callAsFunction']} <target>",
"description": "Call <target> on selection",
},
{
"spokenForm": f"{complex_actions['callAsFunction']} <target 1> on <target 2>",
"description": "Call <target 1> on <target 2>",
},
],
},
{
"id": "wrapWithPairedDelimiter",
"type": "action",
"variations": [
{
"spokenForm": f"<pair> {complex_actions['wrapWithPairedDelimiter']} <target>",
"description": "Wrap <target> with <pair>",
}
],
},
{
"id": "rewrap",
"type": "action",
"variations": [
{
"spokenForm": f"<pair> {complex_actions['rewrap']} <target>",
"description": "Rewrap <target> with <pair>",
}
],
},
]

View file

@ -0,0 +1,53 @@
from ..get_list import 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_connective_term = get_spoken_form_from_list(
"list_connective", "listConnective"
)
vertical_range_term = get_spoken_form_from_list("range_type", "verticalRange")
return [
{
"id": "listConnective",
"type": "compoundTargetConnective",
"variations": [
{
"spokenForm": f"<target 1> {list_connective_term} <target 2>",
"description": "<target 1> and <target 2>",
},
],
},
*[
get_entry(spoken_form, id)
for spoken_form, id in get_raw_list("range_connective").items()
],
get_entry(vertical_range_term, "verticalRange"),
]
def get_entry(spoken_form, id):
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>"),
},
],
}

View file

@ -0,0 +1,28 @@
from ..get_list import get_raw_list
def get_destinations():
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()
]

View file

@ -0,0 +1,37 @@
from ..get_list import get_list, get_raw_list, make_readable
def get_scope_visualizer():
show_scope_visualizer = list(get_raw_list("show_scope_visualizer").keys())[0]
visualization_types = get_raw_list("visualization_type")
return [
*get_list("hide_scope_visualizer", "command"),
{
"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()
],
],
},
{
"id": "show_scope_sidebar",
"type": "command",
"variations": [
{
"spokenForm": "bar cursorless",
"description": "Show cursorless sidebar",
},
],
},
]

View file

@ -0,0 +1,216 @@
from itertools import chain
from typing import TypedDict
from ..get_list import 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",
]
def get_modifiers():
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
}
return [
*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",
},
),
{
"id": "extendThroughStartOf",
"type": "modifier",
"variations": [
{
"spokenForm": complex_modifiers["extendThroughStartOf"],
"description": "Extend through start of line",
},
{
"spokenForm": f"{complex_modifiers['extendThroughStartOf']} <modifier>",
"description": "Extend through start of <modifier>",
},
],
},
{
"id": "extendThroughEndOf",
"type": "modifier",
"variations": [
{
"spokenForm": complex_modifiers["extendThroughEndOf"],
"description": "Extend through end of line",
},
{
"spokenForm": f"{complex_modifiers['extendThroughEndOf']} <modifier>",
"description": "Extend through end of <modifier>",
},
],
},
{
"id": "containingScope",
"type": "modifier",
"variations": [
{
"spokenForm": "<scope>",
"description": "Containing instance of <scope>",
},
],
},
{
"id": "every",
"type": "modifier",
"variations": [
{
"spokenForm": f"{complex_modifiers['every']} <scope>",
"description": "Every instance of <scope>",
},
],
},
{
"id": "ancestor",
"type": "modifier",
"variations": [
{
"spokenForm": f"{complex_modifiers['ancestor']} <scope>",
"description": "Grandparent containing instance of <scope>",
},
],
},
{
"id": "relativeScope",
"type": "modifier",
"variations": [
{
"spokenForm": f"{complex_modifiers['previous']} <scope>",
"description": "Previous instance of <scope>",
},
{
"spokenForm": f"{complex_modifiers['next']} <scope>",
"description": "Next instance of <scope>",
},
{
"spokenForm": f"<ordinal> {complex_modifiers['previous']} <scope>",
"description": "<ordinal> instance of <scope> before target",
},
{
"spokenForm": f"<ordinal> {complex_modifiers['next']} <scope>",
"description": "<ordinal> instance of <scope> after target",
},
{
"spokenForm": f"<scope> {complex_modifiers['backward']}",
"description": "single instance of <scope> including target, going backwards",
},
{
"spokenForm": f"<scope> {complex_modifiers['forward']}",
"description": "single instance of <scope> including target, going forwards",
},
*generateOptionalEvery(
complex_modifiers["every"],
{
"spokenForm": f"<number> <scope>s {complex_modifiers['backward']}",
"description": "<number> instances of <scope> including target, going backwards",
},
{
"spokenForm": "<number> <scope>s",
"description": "<number> instances of <scope> including target, going forwards",
},
{
"spokenForm": f"{complex_modifiers['previous']} <number> <scope>s",
"description": "previous <number> instances of <scope>",
},
{
"spokenForm": f"{complex_modifiers['next']} <number> <scope>s",
"description": "next <number> instances of <scope>",
},
),
],
},
{
"id": "ordinalScope",
"type": "modifier",
"variations": [
{
"spokenForm": "<ordinal> <scope>",
"description": "<ordinal> instance of <scope> in iteration scope",
},
{
"spokenForm": f"<ordinal> {complex_modifiers['last']} <scope>",
"description": "<ordinal>-to-last instance of <scope> in iteration scope",
},
*generateOptionalEvery(
complex_modifiers["every"],
{
"spokenForm": f"{complex_modifiers['first']} <number> <scope>s",
"description": "first <number> instances of <scope> in iteration scope",
},
{
"spokenForm": f"{complex_modifiers['last']} <number> <scope>s",
"description": "last <number> instances of <scope> in iteration scope",
},
),
],
},
]
class Entry(TypedDict):
spokenForm: str
description: str
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
)
)

View file

@ -0,0 +1,26 @@
from ..get_list import get_lists, get_spoken_form_from_list
def get_scopes():
glyph_spoken_form = get_spoken_form_from_list("glyph_scope_type", "glyph")
return [
*get_lists(
["scope_type"],
"scopeType",
{
"argumentOrParameter": "Argument",
"boundedNonWhitespaceSequence": "Non-whitespace sequence bounded by surrounding pair delimeters",
"boundedParagraph": "Paragraph bounded by surrounding pair delimeters",
},
),
{
"id": "glyph",
"type": "scopeType",
"variations": [
{
"spokenForm": f"{glyph_spoken_form} <character>",
"description": "Instance of single character <character>",
},
],
},
]

View file

@ -0,0 +1,20 @@
from ..get_list import get_lists, get_raw_list, make_dict_readable
def get_special_marks():
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,
]

View file

@ -0,0 +1,83 @@
def get_tutorial_entries():
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",
},
],
},
]

View file

@ -0,0 +1,107 @@
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)
},
**value.__dict__,
}
return {k: make_serializable(v) for k, v in items.items() if v is not None}
return value

View file

@ -0,0 +1,2 @@
def get_cursorless_list_name(name: str):
return f"user.cursorless_{name}"

View file

@ -0,0 +1,479 @@
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,
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)
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(str(file_path.parent), on_watch)
if file_path.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(str(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]["list"]
warning = f"WARNING ({filename}): Value `{value}` duplicated between lists '{existing_list_name}' and '{list_name}'"
print(warning)
app.notify(warning)
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: Optional[str],
pluralize_lists: list[str],
handle_new_values: Optional[Callable[[list[SpokenFormEntry]], 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

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"
)

View file

@ -0,0 +1,49 @@
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_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 <user.private_cursorless_number_small>:
user.private_cursorless_tutorial_start_by_number(private_cursorless_number_small)

View file

@ -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)

View file

@ -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()

View file

@ -0,0 +1,111 @@
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 = {
"getText": lambda: [actions.edit.selected_text()],
"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,
}
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"]
)
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}")

View file

@ -0,0 +1,105 @@
import re
import typing
from collections import defaultdict
from typing import Iterator, Mapping
from uu import Error
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 Error:
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 Error:
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

View file

@ -0,0 +1,176 @@
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 = actions.user.vscode_settings_path().resolve()
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)
)
fs.watch(str(vscode_settings_path), on_watch)
def unsubscribe():
fs.unwatch(str(vscode_settings_path), on_watch)
if unsubscribe_hat_styles is not None:
unsubscribe_hat_styles()
return unsubscribe

View file

@ -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} <user.private_cursorless_number_small> "
"[<user.cursorless_range_connective> <user.private_cursorless_number_small>]"
)
)
def cursorless_line_number(m) -> LineNumber:
direction = directions_map[m.cursorless_line_direction]
numbers: list[int] = m.private_cursorless_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,
}

View file

@ -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)

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]

View file

@ -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]

View file

@ -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],
}

View file

@ -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

View file

@ -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,
}

View file

@ -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"}}

View file

@ -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]

View file

@ -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 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.ordinal_or_last> [<user.cursorless_range_connective> <user.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.ordinal_or_last_list[0]
)
if len(m.ordinal_or_last_list) > 1:
active = create_ordinal_scope_modifier(
m.cursorless_scope_type, m.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}) "
"<user.private_cursorless_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.private_cursorless_number_small,
is_every,
)
return create_ordinal_scope_modifier(
m.cursorless_scope_type_plural,
-m.private_cursorless_number_small,
m.private_cursorless_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

View file

@ -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"}

View file

@ -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> <user.private_cursorless_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.private_cursorless_number_small,
m.cursorless_relative_direction,
hasattr(m, "cursorless_every_scope_modifier"),
)
@mod.capture(
rule="[{user.cursorless_every_scope_modifier}] <user.private_cursorless_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.private_cursorless_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

View file

@ -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}")

View file

@ -0,0 +1,54 @@
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",
bool,
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": 1,
}
if settings.get("user.private_cursorless_use_preferred_scope", False):
return {
"type": "preferredScope",
"scopeType": m.cursorless_scope_type,
}
return {
"type": "containingScope",
"scopeType": m.cursorless_scope_type,
}

View file

@ -0,0 +1,46 @@
"""
This file allows us to use a custom `number_small` capture. See #1021 for more
info.
"""
from talon import Context, Module
mod = Module()
mod.tag(
"cursorless_custom_number_small",
"This tag causes Cursorless to use the global <number_small> capture",
)
ctx = Context()
ctx.matches = """
not tag: user.cursorless_custom_number_small
"""
@mod.capture(rule="<number_small>")
def private_cursorless_number_small(m) -> int:
return m.number_small
digit_list = "zero one two three four five six seven eight nine".split()
teens = "ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen".split()
tens = "twenty thirty forty fifty sixty seventy eighty ninety".split()
number_small_list = [*digit_list, *teens]
for ten in tens:
number_small_list.append(ten)
number_small_list.extend(f"{ten} {digit}" for digit in digit_list[1:])
number_small_map = {n: i for i, n in enumerate(number_small_list)}
mod.list("private_cursorless_number_small", desc="List of small numbers")
# FIXME: Remove type ignore once Talon supports list types
# See https://github.com/talonvoice/talon/issues/654
ctx.lists["self.private_cursorless_number_small"] = number_small_map.keys() # pyright: ignore [reportArgumentType]
@ctx.capture(
"user.private_cursorless_number_small",
rule="{user.private_cursorless_number_small}",
)
def override_private_cursorless_number_small(m) -> int:
return number_small_map[m.private_cursorless_number_small]

View file

@ -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]

View file

@ -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)}")

View file

@ -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,
)

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_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],
}
)

View file

@ -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"
)

View file

@ -0,0 +1,14 @@
mode: command
mode: user.cursorless_spoken_form_test
tag: user.cursorless
and not tag: user.cursorless_use_community_snippets
-
{user.cursorless_insert_snippet_action} <user.cursorless_insertion_snippet>:
user.private_cursorless_insert_snippet(cursorless_insertion_snippet)
{user.cursorless_insert_snippet_action} {user.cursorless_insertion_snippet_single_phrase} <user.text> [{user.cursorless_phrase_terminator}]:
user.private_cursorless_insert_snippet_with_phrase(cursorless_insertion_snippet_single_phrase, text)
{user.cursorless_wrapper_snippet} {user.cursorless_wrap_action} <user.cursorless_target>:
user.private_cursorless_wrap_with_snippet(cursorless_wrap_action, cursorless_target, cursorless_wrapper_snippet)

View file

@ -0,0 +1,224 @@
from dataclasses import dataclass
from typing import Any, Optional, Union
from talon import Module, actions
from .targets.target_types import (
CursorlessDestination,
CursorlessTarget,
ImplicitDestination,
)
@dataclass
class InsertionSnippet:
name: str
destination: CursorlessDestination
@dataclass
class CommunityInsertionSnippet:
body: str
scopes: list[str] | None = None
@dataclass
class CommunityWrapperSnippet:
body: str
variable_name: str
scope: str | None = None
mod = Module()
mod.list("cursorless_insert_snippet_action", desc="Cursorless insert snippet action")
# Deprecated tag; we should probably remove this and notify users that they
# should get rid of it, but I don't think it's worth the effort right now
mod.tag(
"cursorless_experimental_snippets",
desc="tag for enabling experimental snippet support",
)
mod.tag(
"cursorless_use_community_snippets",
"If active use community snippets instead of Cursorless snippets",
)
mod.list("cursorless_wrapper_snippet", desc="Cursorless wrapper snippet")
mod.list(
"cursorless_insertion_snippet_no_phrase",
desc="Cursorless insertion snippets that don't accept a phrase",
)
mod.list(
"cursorless_insertion_snippet_single_phrase",
desc="Cursorless insertion snippet that can accept a single phrase",
)
mod.list("cursorless_phrase_terminator", "Contains term used to terminate a phrase")
@mod.capture(
rule="({user.cursorless_insertion_snippet_no_phrase} | {user.cursorless_insertion_snippet_single_phrase}) [<user.cursorless_destination>]"
)
def cursorless_insertion_snippet(m) -> InsertionSnippet:
try:
name = m.cursorless_insertion_snippet_no_phrase
except AttributeError:
name = m.cursorless_insertion_snippet_single_phrase.split(".")[0]
try:
destination = m.cursorless_destination
except AttributeError:
destination = ImplicitDestination()
return InsertionSnippet(name, destination)
def wrap_with_snippet(snippet_description: dict, target: CursorlessTarget):
actions.user.private_cursorless_command_and_wait(
{
"name": "wrapWithSnippet",
"snippetDescription": snippet_description,
"target": target,
},
)
def insert_snippet(snippet_description: dict, destination: CursorlessDestination):
actions.user.private_cursorless_command_and_wait(
{
"name": "insertSnippet",
"snippetDescription": snippet_description,
"destination": destination,
},
)
def insert_named_snippet(
name: str,
destination: CursorlessDestination,
substitutions: Optional[dict] = None,
):
snippet: dict = {
"type": "named",
"name": name,
}
if substitutions is not None:
snippet["substitutions"] = substitutions
insert_snippet(snippet, destination)
def insert_custom_snippet(
body: str,
destination: CursorlessDestination,
scope_types: Optional[list[dict]] = None,
):
snippet: dict = {
"type": "custom",
"body": body,
}
if scope_types:
snippet["scopeTypes"] = scope_types
insert_snippet(snippet, destination)
@mod.action_class
class Actions:
def private_cursorless_insert_snippet(insertion_snippet: InsertionSnippet): # pyright: ignore [reportGeneralTypeIssues]
"""Execute Cursorless insert snippet action"""
insert_named_snippet(
insertion_snippet.name,
insertion_snippet.destination,
)
def private_cursorless_insert_snippet_with_phrase(
snippet_description: str, # pyright: ignore [reportGeneralTypeIssues]
text: str,
):
"""Cursorless: Insert snippet <snippet_description> with phrase <text>"""
snippet_name, snippet_variable = snippet_description.split(".")
insert_named_snippet(
snippet_name,
ImplicitDestination(),
{snippet_variable: text},
)
def cursorless_insert_snippet_by_name(name: str): # pyright: ignore [reportGeneralTypeIssues]
"""Cursorless: Insert named snippet <name>"""
insert_named_snippet(
name,
ImplicitDestination(),
)
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>"""
if isinstance(scope_type, str):
scope_type = [scope_type]
if scope_type is not None:
scope_types = [{"type": st} for st in scope_type]
else:
scope_types = None
insert_custom_snippet(body, destination, scope_types)
def cursorless_wrap_with_snippet_by_name(
name: str, # pyright: ignore [reportGeneralTypeIssues]
variable_name: str,
target: CursorlessTarget,
):
"""Cursorless: Wrap target with a named snippet <name>"""
wrap_with_snippet(
{
"type": "named",
"name": name,
"variableName": variable_name,
},
target,
)
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_arg: dict[str, Any] = {
"type": "custom",
"body": body,
}
if scope is not None:
snippet_arg["scopeType"] = {"type": scope}
if variable_name is not None:
snippet_arg["variableName"] = variable_name
wrap_with_snippet(
snippet_arg,
target,
)
def private_cursorless_insert_community_snippet(
name: str, # pyright: ignore [reportGeneralTypeIssues]
destination: CursorlessDestination,
):
"""Cursorless: Insert community snippet <name>"""
snippet: CommunityInsertionSnippet = actions.user.get_insertion_snippet(name)
actions.user.cursorless_insert_snippet(
snippet.body, destination, snippet.scopes
)
def private_cursorless_wrap_with_community_snippet(
name: str, # pyright: ignore [reportGeneralTypeIssues]
target: CursorlessTarget,
):
"""Cursorless: Wrap target with community snippet <name>"""
snippet: CommunityWrapperSnippet = actions.user.get_wrapper_snippet(name)
actions.user.cursorless_wrap_with_snippet(
snippet.body, target, snippet.variable_name, snippet.scope
)

View file

@ -0,0 +1,13 @@
mode: command
mode: user.cursorless_spoken_form_test
tag: user.cursorless
and tag: user.cursorless_use_community_snippets
-
# These snippets are defined in community
{user.cursorless_insert_snippet_action} {user.snippet} <user.cursorless_destination>:
user.private_cursorless_insert_community_snippet(snippet, cursorless_destination)
{user.snippet_wrapper} {user.cursorless_wrap_action} <user.cursorless_target>:
user.private_cursorless_wrap_with_community_snippet(snippet_wrapper, cursorless_target)

View file

@ -0,0 +1,279 @@
{
"NOTE FOR USERS": "Please don't edit this json file; see https://www.cursorless.org/docs/user/customization",
"actions.csv": {
"simple_action": {
"bottom": "scrollToBottom",
"break": "breakLine",
"break point": "toggleLineBreakpoint",
"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",
"float": "insertEmptyLineAfter",
"fold": "foldRegion",
"follow": "followLink",
"follow split": "followLinkAside",
"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": "findInDocument",
"scout all": "findInWorkspace",
"shuffle": "randomizeTargets",
"snippet 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": { "snippet": "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",
"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": {
"wrapper_snippet": {
"else": "ifElseStatement.alternative",
"funk": "functionDeclaration.body",
"if else": "ifElseStatement.consequence",
"if": "ifStatement.consequence",
"try": "tryCatchStatement.body",
"link": "link.text"
}
},
"experimental/insertion_snippets.csv": {
"insertion_snippet_no_phrase": {
"if": "ifStatement",
"if else": "ifElseStatement",
"try": "tryCatchStatement"
}
},
"experimental/insertion_snippets_single_phrase.csv": {
"insertion_snippet_single_phrase": {
"funk": "functionDeclaration.name",
"link": "link.text"
}
},
"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"
}
}
}

View file

@ -0,0 +1,237 @@
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("experimental/miscellaneous.csv"),
handle_csv(
"modifier_scope_types.csv",
pluralize_lists=[
"scope_type",
"glyph_scope_type",
"surrounding_pair_scope_type",
],
extra_allowed_values=[
"private.fieldAccess",
"private.switchStatementSubject",
"textFragment",
"disqualifyDelimiter",
],
default_list_name="scope_type",
),
handle_csv(
"experimental/wrapper_snippets.csv",
allow_unknown_values=True,
default_list_name="wrapper_snippet",
),
handle_csv(
"experimental/insertion_snippets.csv",
allow_unknown_values=True,
default_list_name="insertion_snippet_no_phrase",
),
handle_csv(
"experimental/insertion_snippets_single_phrase.csv",
allow_unknown_values=True,
default_list_name="insertion_snippet_single_phrase",
),
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(str(JSON_FILE.parent), on_watch)
app.register("ready", on_ready)

View file

@ -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)

View file

@ -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")

View file

@ -0,0 +1,70 @@
from typing import Union
from talon import Context, Module, actions
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.tag(
"cursorless_disable_legacy_destination",
desc="Disabled the Cursorless legacy destination(to after) support",
)
ctx = Context()
ctx.matches = r"""
tag: user.cursorless_disable_legacy_destination
"""
# DEPRECATED @ 2023-08-01
@mod.capture(
rule="([{user.cursorless_insertion_mode_to}] {user.cursorless_insertion_mode_before_after}) | {user.cursorless_insertion_mode_to}"
)
def cursorless_insertion_mode(m) -> str:
try:
before_after = m.cursorless_insertion_mode_before_after
if hasattr(m, "cursorless_insertion_mode_to"):
words = m._unmapped
actions.app.notify(
f"'{' '.join(words)}' is deprecated. Please just say '{words[-1]}'"
)
return before_after
except AttributeError:
return "to"
@ctx.capture(
"user.cursorless_insertion_mode",
rule="{user.cursorless_insertion_mode_before_after} | {user.cursorless_insertion_mode_to}",
)
def cursorless_insertion_mode_ctx(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)

View file

@ -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)

View file

@ -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,
)

View file

@ -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]

View file

@ -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)

View file

@ -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,
]

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

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")

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)

View file

@ -0,0 +1 @@
COMMAND_VERSION = 7