init commit

This commit is contained in:
unknown
2025-08-19 08:06:37 -04:00
commit 2957b5515a
743 changed files with 45495 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
# core
This folder contains `edit_settings.talon`, which has a command to open various [settings](https://github.com/talonhub/community?tab=readme-ov-file#settings) files. As an overview of what commands the subfolders contain:
- `abbreviate` has a command for the use of abbreviations
- `app_switcher` does not have commands but has the implementation of functions that allow for switching between applications
- `contacts` has captures for inserting contact information including name and email.
- `edit` has commands for navigating and editing text with copy, paste, etc., as well as commands for zooming in and out
- `file_extension` has a command for simpler spoken forms of file and website name extensions
- `help` has commands to open various help menus, as described in the top level [README](https://github.com/talonhub/community?tab=readme-ov-file#getting-started-with-talon)
- `homophones` has commands to replace words with their homophones
- `keys` has commands for [pressing keys](https://github.com/talonhub/community?tab=readme-ov-file#keys)
- `modes` has commands for switching between dictation, command, and sleep mode, as well as for forcing a certain [programming language](https://github.com/talonhub/community?tab=readme-ov-file#programming-languages) mode
- `mouse_grid` has commands to use a grid on the screen to click at a specific location
- `numbers` has the command for writing a number
- `screens` has a command for talon to show the index associated with each of your computer screens for the sake of moving windows to different screens
- `snippets` has commands for inserting snippets of code for various languages
- `text` has commands for inserting and reformatting text
- `vocabulary` has commands for adding new words to be recognized and for having certain words automatically by replaced by others
- `websites_and_search_engines` has commands for opening websites, following links, and making browser searches
- `windows_and_tabs` has commands for tab and [window management](https://github.com/talonhub/community?tab=readme-ov-file#window-management), launching and switching between different applications, and snapping application windows to different locations on the screen
+478
View File
@@ -0,0 +1,478 @@
import re
from talon import Context, Module
from ..user_settings import track_csv_list
mod = Module()
ctx = Context()
mod.list("abbreviation", desc="Common abbreviation")
abbreviations_list = {}
abbreviations = {
"J peg": "jpg",
"abbreviate": "abbr",
"abort": "abrt",
"acknowledge": "ack",
"address": "addr",
"addresses": "addrs",
"administrator": "admin",
"administrators": "admins",
"advance": "adv",
"advanced": "adv",
"alberta": "ab",
"allocate": "alloc",
"alternative": "alt",
"apple": "appl",
"application": "app",
"applications": "apps",
"argument": "arg",
"arguments": "args",
"as far as i can tell": "afaict",
"as far as i know": "afaik",
"assembly": "asm",
"asynchronous": "async",
"at the moment": "atm",
"attribute": "attr",
"attributes": "attrs",
"authenticate": "auth",
"authentication": "authn",
"authorization": "authz",
"auto group": "augroup",
"average": "avg",
"away from keyboard": "afk",
"backup": "bkp",
"be right back": "brb",
"binary": "bin",
"block": "blk",
"boolean": "bool",
"bottom": "bot",
"break point": "bp",
"break points": "bps",
"british columbia": "bc",
"buffer": "buf",
"button": "btn",
"by the way": "btw",
"calculate": "calc",
"calculator": "calc",
"camera": "cam",
"canada": "ca",
"centimeter": "cm",
"char": "chr",
"character": "char",
"check": "chk",
"child": "chld",
"china": "cn",
"class": "cls",
"client": "cli",
"column": "col",
"command": "cmd",
"commands": "cmds",
"comment": "cmt",
"communication": "comm",
"communications": "comms",
"compare": "cmp",
"condition": "cond",
"conference": "conf",
"config": "cfg",
"configuration": "config",
"configurations": "configs",
"connection": "conn",
"constant": "const",
"contribute": "contrib",
"constructor": "ctor",
"context": "ctx",
"control flow graph": "cfg",
"control": "ctrl",
"coordinate": "coord",
"coordinates": "coords",
"copy": "cpy",
"count": "cnt",
"counter": "ctr",
"credential": "cred",
"credentials": "creds",
"cross reference": "xref",
"cross references": "xrefs",
"cuddle": "ctl",
"current": "cur",
"cute": "qt",
"database": "db",
"date format": "yyyy-mm-dd",
"debian": "deb",
"debug": "dbg",
"decimal": "dec",
"declaration": "decl",
"declare": "decl",
"decode": "dec",
"decrement": "dec",
"define": "def",
"definition": "def",
"degree": "deg",
"delete": "del",
"depend": "dep",
"depends": "deps",
"description": "desc",
"dest": "dst",
"destination": "dest",
"develop": "dev",
"development": "dev",
"device": "dev",
"diagnostic": "diag",
"dictation": "dict",
"dictionary": "dict",
"direction": "dir",
"directories": "dirs",
"directory": "dir",
"display": "disp",
"distance": "dist",
"distribution": "dist",
"document": "doc",
"documents": "docs",
"doing": "ing", # some way to add 'ing' to verbs
"double ended queue": "deque",
"double": "dbl",
"dupe": "dup",
"duplicate": "dup",
"dynamic": "dyn",
"elastic": "elast",
"element": "elem",
"elements": "elems",
"encode": "enc",
"end of day": "eod",
"end of month": "eom",
"end of quarter": "eoq",
"end of week": "eow",
"end of year": "eoy",
"entry": "ent",
"enumerate": "enum",
"environment": "env",
"error": "err",
"escape": "esc",
"etcetera": "etc",
"ethernet": "eth",
"evaluate": "eval",
"example": "ex",
"exception": "exc",
"executable": "exe",
"executables": "exes",
"execute": "exec",
"experience": "exp",
"exponent": "exp",
"expression": "expr",
"expressions": "exprs",
"extend": "ext",
"extension": "ext",
"external": "extern",
"eye dent": "id",
"eye octal": "ioctl",
"eye three": "i3",
"feature": "feat",
"file system": "fs",
"fingerprint": "fp",
"for what": "fwiw",
"format": "fmt",
"fortigate": "fgt",
"framework": "fw",
"frequency": "freq",
"function": "func",
"functions": "funcs",
"funny": "lol",
"fuzzy": "fzy",
"generate": "gen",
"generic": "gen",
"hardware": "hw",
"header": "hdr",
"hello": "helo",
"history": "hist",
"hypertext": "http",
"identity": "id",
"ignore": "ign",
"image": "img",
"implement": "impl",
"import address table": "iat",
"import table": "iat",
"in real life": "irl",
"increment": "inc",
"index": "idx",
"information": "info",
"infrastructure": "infra",
"initialize": "init",
"initializer": "init",
"inode": "ino",
"insert": "ins",
"instance": "inst",
"instruction": "insn",
"integer": "int",
"interpreter": "interp",
"interrupt": "int",
"iterate": "iter",
"jason": "json",
"jason five": "json5",
"java archive": "jar",
"javascript": "js",
"jiff": "gif",
"journal cuttle": "journalctl",
"jump": "jmp",
"just in time": "jit",
"kay": "kk",
"kernel": "krnl",
"key cuttle": "keyctl",
"keyboard": "kbd",
"keyword arguments": "kwargs",
"keyword": "kw",
"kilogram": "kg",
"kilometer": "km",
"language": "lang",
"laugh out loud": "lol",
"length": "len",
"lib see": "libc",
"library": "lib",
"lisp": "lsp",
"looks good to me": "lgtm",
"mail": "smtp",
"make": "mk",
"management": "mgmt",
"manager": "mgr",
"manitoba": "mb",
"markdown": "md",
"maximum": "max",
"memory": "mem",
"message": "msg",
"meta sploit framework": "msf",
"meta sploit": "msf",
"microphone": "mic",
"middle": "mid",
"milligram": "mg",
"millisecond": "ms",
"minimum viable product": "mvp",
"minimum": "min",
"miscellaneous": "misc",
"modify": "mod",
"module": "mod",
"modules": "mods",
"monitor": "mon",
"mount": "mnt",
"multiple": "multi",
"muscle": "musl",
"mutate": "mut",
"nano second": "ns",
"neo vim": "nvim",
"new brunswick": "nb",
"nova scotia": "ns",
"number": "num",
"numbers": "nums",
"object": "obj",
"objects": "objs",
"offset": "off",
"offsets": "offs",
"okay": "ok",
"ontario": "on",
"operating system": "os",
"operation": "op",
"operations": "ops",
"option": "opt",
"options": "opts",
"original": "orig",
"out of bounds": "oob",
"package build": "pkgbuild",
"package": "pkg",
"packages": "pkgs",
"packet": "pkt",
"packets": "pkts",
"parameter": "param",
"parameters": "params",
"password": "passwd",
"performance": "perf",
"physical": "phys",
"physical address": "paddr",
"pick": "pic",
"pico second": "ps",
"pie": "py",
"ping": "png",
"pixel": "px",
"point": "pt",
"pointer": "ptr",
"pointers": "ptrs",
"pone": "pwn",
"position independent code": "pic",
"position independent executable": "pie",
"position": "pos",
"pound bag": "pwndbg",
"preference": "pref",
"preferences": "prefs",
"previous": "prev",
"private": "priv",
"process": "proc",
"processor": "cpu",
"production": "prod",
"program": "prog",
"programs": "progs",
"properties": "props",
"property": "prop",
"protocol": "proto",
"protocol buffers": "protobuf",
"public": "pub",
"python": "py",
"quebec": "qc",
"query string": "qs",
"radian": "rad",
"random": "rand",
"read right ex": "rwx",
"receipt": "rcpt",
"receive": "recv",
"record": "rec",
"recording": "rec",
"rectangle": "rect",
"ref count": "refcnt",
"reference": "ref",
"references": "refs",
"register": "reg",
"registers": "regs",
"registery": "reg",
"regular expression": "regex",
"regular expressions": "regex",
"remove": "rm",
"repel": "repl",
"repetitive strain injury": "rsi",
"repository": "repo",
"represent": "repr",
"representation": "repr",
"request": "req",
"requests": "reqs",
"resources": "rsrcs",
"response": "resp",
"result": "res",
"return": "ret",
"revision": "rev",
"round": "rnd",
"ruby": "rb",
"rust": "rs",
"samba D": "smbd",
"samba": "smb",
"saskatchewan": "sk",
"schedule": "sched",
"scheduler": "sched",
"screen": "scr",
"scuzzy": "scsi",
"see": "C",
"segment": "seg",
"select": "sel",
"semaphore": "sem",
"send": "snd",
"sequel": "sql",
"sequence": "seq",
"service pack": "sp",
"session id": "sid",
"shell": "sh",
"shellcode": "sc",
"signal": "sig",
"size": "sz",
"snipped": "[...]",
"some": "sum",
"source": "src",
"sources": "srcs",
"special": "spec",
"specific": "spec",
"specification": "spec",
"specify": "spec",
"standard error": "stderr",
"standard in": "stdin",
"standard out": "stdout",
"standard": "std",
"start of day": "sod",
"start of month": "som",
"start of quarter": "soq",
"start of week": "sow",
"start of year": "soy",
"statement": "stmt",
"statistic": "stat",
"statistics": "stats",
"string": "str",
"structure": "struct",
"structures": "structs",
"symbol": "sym",
"symbolic link": "symlink",
"symbols": "syms",
"synchronize": "sync",
"synchronous": "sync",
"sys cuttle": "sysctl",
"system call": "syscall",
"system cuddle": "systemctl",
"system": "sys",
"table of contents": "toc",
"table": "tbl",
"taiwan": "tw",
"talk": "toc",
"technology": "tech",
"temp": "tmp",
"temperature": "temp",
"temporary": "tmp",
"terminal": "term",
"text": "txt",
"time format": "hh:mm:ss",
"time of check time of use": "toctou",
"time to live": "ttl",
"token": "tok",
"transaction": "txn",
"typescript": "ts",
"ultimate": "ulti",
"unique id": "uuid",
"unknown": "unk",
"user id": "uid",
"user": "usr",
"utilities": "utils",
"utility": "util",
"value": "val",
"values": "vals",
"variable": "var",
"variables": "vars",
"vector": "vec",
"verify": "vrfy",
"version": "ver",
"versus": "vs",
"video": "vid",
"videos": "vids",
"virtual machine": "vm",
"virtual": "virt",
"virtual address": "vaddr",
"visual studio": "msvc",
"visual": "vis",
"volume": "vol",
"vulnerable": "vuln",
"wave": "wav",
"web": "www",
"what the fuck": "wtf",
"wind": "wnd",
"window": "win",
"windows kernel": "ntoskrnl",
"work in progress": "wip",
}
@mod.capture(rule="brief {user.abbreviation}")
def abbreviation(m) -> str:
return m.abbreviation
@track_csv_list(
"abbreviations.csv", headers=("Abbreviation", "Spoken Form"), default=abbreviations
)
def on_abbreviations(values):
global abbreviations_list
# note: abbreviations_list is imported by the create_spoken_forms module
abbreviations_list = values
# Matches letters and spaces, as currently, Talon doesn't accept other characters in spoken forms.
PATTERN = re.compile(r"^[a-zA-Z ]+$")
abbreviation_values = {
v: v for v in abbreviations_list.values() if PATTERN.match(v) is not None
}
# Allows the abbreviated/short form to be used as spoken phrase. eg "brief app" -> app
abbreviations_list_with_values = {
**{v: v for v in abbreviation_values.values()},
**abbreviations_list,
}
ctx.lists["user.abbreviation"] = abbreviations_list_with_values
+12
View File
@@ -0,0 +1,12 @@
from talon import Module, ui
mod = Module()
@mod.scope
def scope():
return {"running": {app.name.lower() for app in ui.apps()}}
ui.register("app_launch", scope.update)
ui.register("app_close", scope.update)
@@ -0,0 +1,4 @@
Spoken form, App name (or list an app name by itself on a line to exclude it)
grip, DataGrip
py, jetbrains-pycharm-ce
terminal, Gnome-terminal
1 Spoken form App name (or list an app name by itself on a line to exclude it)
2 grip DataGrip
3 py jetbrains-pycharm-ce
4 terminal Gnome-terminal
@@ -0,0 +1,4 @@
Spoken form, App name (or list an app name by itself on a line to exclude it)
grip, DataGrip
term, iTerm2
one note, ONENOTE
1 Spoken form App name (or list an app name by itself on a line to exclude it)
2 grip DataGrip
3 term iTerm2
4 one note ONENOTE
@@ -0,0 +1,9 @@
Spoken form, App name/.exe (or list an app name/.exe by itself on a line to exclude it)
grip, DataGrip
term, WindowsTerminal.exe
one note, ONENOTE
lock, slack.exe
app, slack.exe
lockapp, slack.exe
pycharm, pycharm64.exe
webstorm, webstorm64.exe
1 Spoken form App name/.exe (or list an app name/.exe by itself on a line to exclude it)
2 grip DataGrip
3 term WindowsTerminal.exe
4 one note ONENOTE
5 lock slack.exe
6 app slack.exe
7 lockapp slack.exe
8 pycharm pycharm64.exe
9 webstorm webstorm64.exe
+461
View File
@@ -0,0 +1,461 @@
import os
import shlex
import subprocess
import time
from pathlib import Path
import talon
from talon import Context, Module, actions, app, fs, imgui, ui
# Construct a list of spoken form overrides for application names (similar to how homophone list is managed)
# These overrides are used *instead* of the generated spoken forms for the given app name or .exe (on Windows)
# CSV files contain lines of the form:
# <spoken form>,<app name or .exe> - to add a spoken form override for the app, or
# <app name or .exe> - to exclude the app from appearing in "running list" or "focus <app>"
# TODO: Consider moving overrides to settings directory
overrides_directory = os.path.dirname(os.path.realpath(__file__))
override_file_name = f"app_name_overrides.{talon.app.platform}.csv"
override_file_path = os.path.normcase(
os.path.join(overrides_directory, override_file_name)
)
mod = Module()
mod.list("running", desc="all running applications")
mod.list("launch", desc="all launchable applications")
ctx = Context()
# a list of the current overrides
overrides = {}
# apps to exclude from running list
excludes = set()
# a list of the currently running application names
running_application_dict = {}
words_to_exclude = [
"zero",
"one",
"two",
"three",
"for",
"four",
"five",
"six",
"seven",
"eight",
"nine",
"and",
"dot",
"exe",
"help",
"install",
"installer",
"microsoft",
"nine",
"readme",
"studio",
"terminal",
"visual",
"windows",
]
# on Windows, WindowsApps are not like normal applications, so
# we use the shell:AppsFolder to populate the list of applications
# rather than via e.g. the start menu. This way, all apps, including "modern" apps are
# launchable. To easily retrieve the apps this makes available, navigate to shell:AppsFolder in Explorer
if app.platform == "windows":
import ctypes
import os
from ctypes import wintypes
import pywintypes
from win32com.propsys import propsys, pscon
from win32com.shell import shell, shellcon
# KNOWNFOLDERID
# https://msdn.microsoft.com/en-us/library/dd378457
# win32com defines most of these, except the ones added in Windows 8.
FOLDERID_AppsFolder = pywintypes.IID("{1e87508d-89c2-42f0-8a7e-645a0f50ca58}")
# win32com is missing SHGetKnownFolderIDList, so use ctypes.
_ole32 = ctypes.OleDLL("ole32")
_shell32 = ctypes.OleDLL("shell32")
_REFKNOWNFOLDERID = ctypes.c_char_p
_PPITEMIDLIST = ctypes.POINTER(ctypes.c_void_p)
_ole32.CoTaskMemFree.restype = None
_ole32.CoTaskMemFree.argtypes = (wintypes.LPVOID,)
_shell32.SHGetKnownFolderIDList.argtypes = (
_REFKNOWNFOLDERID, # rfid
wintypes.DWORD, # dwFlags
wintypes.HANDLE, # hToken
_PPITEMIDLIST,
) # ppidl
def get_known_folder_id_list(folder_id, htoken=None):
if isinstance(folder_id, pywintypes.IIDType):
folder_id = bytes(folder_id)
pidl = ctypes.c_void_p()
try:
_shell32.SHGetKnownFolderIDList(folder_id, 0, htoken, ctypes.byref(pidl))
return shell.AddressAsPIDL(pidl.value)
except OSError as e:
if e.winerror & 0x80070000 == 0x80070000:
# It's a WinAPI error, so re-raise it, letting Python
# raise a specific exception such as FileNotFoundError.
raise ctypes.WinError(e.winerror & 0x0000FFFF)
raise
finally:
if pidl:
_ole32.CoTaskMemFree(pidl)
def enum_known_folder(folder_id, htoken=None):
id_list = get_known_folder_id_list(folder_id, htoken)
folder_shell_item = shell.SHCreateShellItem(None, None, id_list)
items_enum = folder_shell_item.BindToHandler(
None, shell.BHID_EnumItems, shell.IID_IEnumShellItems
)
yield from items_enum
def list_known_folder(folder_id, htoken=None):
result = []
for item in enum_known_folder(folder_id, htoken):
result.append(item.GetDisplayName(shellcon.SIGDN_NORMALDISPLAY))
result.sort(key=lambda x: x.upper())
return result
def get_apps():
items = {}
for item in enum_known_folder(FOLDERID_AppsFolder):
try:
property_store = item.BindToHandler(
None, shell.BHID_PropertyStore, propsys.IID_IPropertyStore
)
app_user_model_id = property_store.GetValue(
pscon.PKEY_AppUserModel_ID
).ToString()
except pywintypes.error:
continue
name = item.GetDisplayName(shellcon.SIGDN_NORMALDISPLAY)
# exclude anything with install/uninstall...
# 'cause I don't think we don't want 'em
if "install" not in name.lower():
items[name] = app_user_model_id
return items
elif app.platform == "linux":
import configparser
import re
linux_application_directories = [
"/usr/share/applications",
"/usr/local/share/applications",
f"{Path.home()}/.local/share/applications",
"/var/lib/flatpak/exports/share/applications",
"/var/lib/snapd/desktop/applications",
]
xdg_data_dirs = os.environ.get("XDG_DATA_DIRS")
if xdg_data_dirs is not None:
for directory in xdg_data_dirs.split(":"):
linux_application_directories.append(f"{directory}/applications")
linux_application_directories = list(set(linux_application_directories))
def get_apps():
# app shortcuts in program menu are contained in .desktop files. This function parses those files for the app name and command
items = {}
# find field codes in exec key with regex
# https://specifications.freedesktop.org/desktop-entry-spec/desktop-entry-spec-latest.html#exec-variables
args_pattern = re.compile(r"\%[UufFcik]")
for base in linux_application_directories:
if os.path.isdir(base):
for entry in os.scandir(base):
if entry.name.endswith(".desktop"):
try:
config = configparser.ConfigParser(interpolation=None)
config.read(entry.path)
# only parse shortcuts that are not hidden
if not config.has_option("Desktop Entry", "NoDisplay"):
name_key = config["Desktop Entry"]["Name"]
exec_key = config["Desktop Entry"]["Exec"]
# remove extra quotes from exec
if exec_key[0] == '"' and exec_key[-1] == '"':
exec_key = re.sub('"', "", exec_key)
# remove field codes and add full path if necessary
if exec_key[0] == "/":
items[name_key] = re.sub(args_pattern, "", exec_key)
else:
exec_path = (
subprocess.check_output(
["which", exec_key.split()[0]],
stderr=subprocess.DEVNULL,
)
.decode("utf-8")
.strip()
)
items[name_key] = (
exec_path
+ " "
+ re.sub(
args_pattern,
"",
" ".join(exec_key.split()[1:]),
)
)
except Exception:
print(
"linux get_apps(): skipped parsing application file ",
entry.name,
)
return items
elif app.platform == "mac":
mac_application_directories = [
"/Applications",
"/Applications/Utilities",
"/System/Applications",
"/System/Applications/Utilities",
f"{Path.home()}/Applications",
f"{Path.home()}/.nix-profile/Applications",
]
def get_apps():
items = {}
for base in mac_application_directories:
base = os.path.expanduser(base)
if os.path.isdir(base):
for name in os.listdir(base):
path = os.path.join(base, name)
name = name.rsplit(".", 1)[0].lower()
items[name] = path
return items
@mod.capture(rule="{self.running}") # | <user.text>)")
def running_applications(m) -> str:
"Returns a single application name"
try:
return m.running
except AttributeError:
return m.text
@mod.capture(rule="{self.launch}")
def launch_applications(m) -> str:
"Returns a single application name"
return m.launch
def update_running_list():
global running_application_dict
running_application_dict = {}
running = {}
foreground_apps = ui.apps(background=False)
for cur_app in foreground_apps:
running_application_dict[cur_app.name.lower()] = cur_app.name
if app.platform == "windows":
exe = os.path.basename(cur_app.exe)
running_application_dict[exe.lower()] = exe
override_apps = excludes.union(overrides.values())
running = actions.user.create_spoken_forms_from_list(
[
curr_app.name
for curr_app in ui.apps(background=False)
if curr_app.name.lower() not in override_apps
and curr_app.exe.lower() not in override_apps
and os.path.basename(curr_app.exe).lower() not in override_apps
],
words_to_exclude=words_to_exclude,
generate_subsequences=True,
)
for running_name, full_application_name in overrides.items():
if running_app_name := running_application_dict.get(full_application_name):
running[running_name] = running_app_name
ctx.lists["self.running"] = running
def update_overrides(name, flags):
"""Updates the overrides and excludes lists"""
global overrides, excludes
if name is None or os.path.normcase(name) == override_file_path:
overrides = {}
excludes = set()
# print("update_overrides")
with open(override_file_path) as f:
for line in f:
line = line.rstrip().lower()
line = line.split(",")
if len(line) == 2 and line[0] != "Spoken form":
overrides[line[0]] = line[1].strip()
if len(line) == 1:
excludes.add(line[0].strip())
update_running_list()
@mod.action_class
class Actions:
def get_running_app(name: str) -> ui.App:
"""Get the first available running app with `name`."""
# We should use the capture result directly if it's already in the list
# of running applications. Otherwise, name is from <user.text> and we
# can be a bit fuzzier
if name.lower() not in running_application_dict:
if len(name) < 3:
raise RuntimeError(
f'Skipped getting app: "{name}" has less than 3 chars.'
)
for running_name, full_application_name in ctx.lists[
"self.running"
].items():
if running_name == name or running_name.lower().startswith(
name.lower()
):
name = full_application_name
break
for application in ui.apps(background=False):
if application.name == name or (
app.platform == "windows"
and os.path.basename(application.exe).lower() == name
):
return application
raise RuntimeError(f'App not running: "{name}"')
def switcher_focus(name: str):
"""Focus a new application by name"""
app = actions.user.get_running_app(name)
# Focus next window on same app
if app == ui.active_app():
actions.app.window_next()
# Focus new app
else:
actions.user.switcher_focus_app(app)
def switcher_focus_app(app: ui.App):
"""Focus application and wait until switch is made"""
app.focus()
t1 = time.perf_counter()
while ui.active_app() != app:
if time.perf_counter() - t1 > 1:
raise RuntimeError(f"Can't focus app: {app.name}")
actions.sleep(0.1)
def switcher_focus_last():
"""Focus last window/application"""
def switcher_focus_window(window: ui.Window):
"""Focus window and wait until switch is made"""
window.focus()
t1 = time.perf_counter()
while ui.active_window() != window:
if time.perf_counter() - t1 > 1:
raise RuntimeError(f"Can't focus window: {window.title}")
actions.sleep(0.1)
def switcher_launch(path: str):
"""Launch a new application by path (all OSes), or AppUserModel_ID path on Windows"""
if app.platform == "mac":
ui.launch(path=path)
elif app.platform == "linux":
# Could potentially be merged with OSX code. Done in this explicit
# way for expediency around the 0.4 release.
cmd = shlex.split(path)[0]
args = shlex.split(path)[1:]
ui.launch(path=cmd, args=args)
elif app.platform == "windows":
is_valid_path = False
try:
current_path = Path(path)
is_valid_path = current_path.is_file()
except:
is_valid_path = False
if is_valid_path:
ui.launch(path=path)
else:
cmd = f"explorer.exe shell:AppsFolder\\{path}"
subprocess.Popen(cmd, shell=False)
else:
print("Unhandled platform in switcher_launch: " + app.platform)
def switcher_menu():
"""Open a menu of running apps to switch to"""
if app.platform == "windows":
actions.key("alt-ctrl-tab")
elif app.platform == "mac":
# MacOS equivalent is "Mission Control"
actions.user.dock_send_notification("com.apple.expose.awake")
else:
print("Persistent Switcher Menu not supported on " + app.platform)
def switcher_toggle_running():
"""Shows/hides all running applications"""
if gui_running.showing:
gui_running.hide()
else:
gui_running.show()
def switcher_hide_running():
"""Hides list of running applications"""
gui_running.hide()
@imgui.open()
def gui_running(gui: imgui.GUI):
gui.text("Running applications (with spoken forms)")
gui.line()
running_apps = sorted(
(v.lower(), k, v) for k, v in ctx.lists["self.running"].items()
)
for _, running_name, full_application_name in running_apps:
gui.text(f"{full_application_name}: {running_name}")
gui.spacer()
if gui.button("Running close"):
actions.user.switcher_hide_running()
def update_launch_list():
launch = get_apps()
# actions.user.talon_pretty_print(launch)
ctx.lists["self.launch"] = actions.user.create_spoken_forms_from_map(
launch, words_to_exclude
)
def ui_event(event, arg):
if event in ("app_launch", "app_close"):
update_running_list()
# Talon starts faster if you don't use the `talon.ui` module during launch
def on_ready():
update_overrides(None, None)
fs.watch(overrides_directory, update_overrides)
update_launch_list()
update_running_list()
ui.register("", ui_event)
app.register("ready", on_ready)
+76
View File
@@ -0,0 +1,76 @@
from talon import Module
mod = Module()
apps = mod.apps
# apple specific apps
apps.datagrip = """
os: mac
and app.name: DataGrip
"""
apps.finder = """
os: mac
and app.bundle: com.apple.finder
"""
apps.rstudio = """
os: mac
and app.name: RStudio
"""
apps.apple_terminal = """
os: mac
and app.bundle: com.apple.Terminal
"""
# linux specific apps
apps.keepass = """
os: linux
and app.name: KeePassX2
os: linux
and app.name: KeePassXC
os: linux
and app.name: KeepassX2
os: linux
and app.name: keepassx2
os: linux
and app.name: keepassxc
os: linux
and app.name: Keepassxc"""
apps.signal = """
os: linux
and app.name: Signal
os: linux
and app.name: signal
"""
apps.termite = """
os: linux
and app.name: /termite/
"""
apps.windows_command_processor = r"""
os: windows
and app.name: Windows Command Processor
os: windows
and app.exe: /^cmd\.exe$/i
"""
apps.windows_terminal = r"""
os: windows
and app.exe: /^windowsterminal\.exe$/i
"""
mod.apps.windows_power_shell = r"""
os: windows
and app.exe: /^powershell\.exe$/i
"""
apps.vim = """
win.title:/VIM/
"""
+16
View File
@@ -0,0 +1,16 @@
# Talon command client
This directory contains the client code for communicating with the [VSCode command server](https://marketplace.visualstudio.com/items?itemName=pokey.command-server).
## Contributing
The source of truth is in https://github.com/talonhub/community/tree/main/core/command_client, but the code is also maintained as a subtree at https://github.com/cursorless-dev/talon-command-client.
To contribute, first open a PR on `community`.
Once the PR is merged, you can push the changes to the subtree by running the following commands on an up-to-date `community` main: (need write access)
```sh
git subtree split --prefix=core/command_client --annotate="[split] " -b split
git push git@github.com:cursorless-dev/talon-command-client.git split:main
```
@@ -0,0 +1,199 @@
from pathlib import Path
from typing import Any
from talon import Context, Module, actions, speech_system
from .rpc_client.get_communication_dir_path import get_communication_dir_path
# Indicates whether a pre-phrase signal was emitted during the course of the
# current phrase
did_emit_pre_phrase_signal = False
mod = Module()
ctx = Context()
mac_ctx = Context()
ctx.matches = r"""
tag: user.command_client
"""
mac_ctx.matches = r"""
os: mac
tag: user.command_client
"""
class NotSet:
def __repr__(self):
return "<argument not set>"
def run_command(
command_id: str,
*args,
wait_for_finish: bool = False,
return_command_output: bool = False,
):
"""Runs a command, using command server if available
Args:
command_id (str): The ID of the command to run.
args: The arguments to the command.
wait_for_finish (bool, optional): Whether to wait for the command to finish before returning. Defaults to False.
return_command_output (bool, optional): Whether to return the output of the command. Defaults to False.
Raises:
Exception: If there is an issue with the file-based communication, or
application raises an exception
Returns:
Object: The response from the command, if requested.
"""
# NB: This is a hack to work around the fact that talon doesn't support
# variable argument lists
args = [x for x in args if x is not NotSet]
return actions.user.rpc_client_run_command(
actions.user.command_server_directory(),
actions.user.trigger_command_server_command_execution,
command_id,
args,
wait_for_finish,
return_command_output,
)
@mod.action_class
class Actions:
def run_rpc_command(
command_id: str,
arg1: Any = NotSet,
arg2: Any = NotSet,
arg3: Any = NotSet,
arg4: Any = NotSet,
arg5: Any = NotSet,
):
"""Execute command via RPC."""
run_command(
command_id,
arg1,
arg2,
arg3,
arg4,
arg5,
)
def run_rpc_command_and_wait(
command_id: str,
arg1: Any = NotSet,
arg2: Any = NotSet,
arg3: Any = NotSet,
arg4: Any = NotSet,
arg5: Any = NotSet,
):
"""Execute command via application command server and wait for command to finish."""
run_command(
command_id,
arg1,
arg2,
arg3,
arg4,
arg5,
wait_for_finish=True,
)
def run_rpc_command_get(
command_id: str,
arg1: Any = NotSet,
arg2: Any = NotSet,
arg3: Any = NotSet,
arg4: Any = NotSet,
arg5: Any = NotSet,
) -> Any:
"""Execute command via application command server and return command output."""
return run_command(
command_id,
arg1,
arg2,
arg3,
arg4,
arg5,
return_command_output=True,
)
def command_server_directory() -> str:
"""Return the directory of the command server"""
def trigger_command_server_command_execution():
"""Issue keystroke to trigger command server to execute command that
was written to the file. For internal use only"""
actions.key("ctrl-shift-f17")
def emit_pre_phrase_signal() -> bool:
"""
If in an application supporting the command client, returns True
and touches a file to indicate that a phrase is beginning execution.
Otherwise does nothing and returns False.
"""
return False
def did_emit_pre_phrase_signal() -> bool:
"""Indicates whether the pre-phrase signal was emitted at the start of this phrase"""
# NB: This action is used by cursorless; please don't delete it :)
return did_emit_pre_phrase_signal
@mac_ctx.action_class("user")
class MacUserActions:
def trigger_command_server_command_execution():
actions.key("cmd-shift-f17")
@ctx.action_class("user")
class UserActions:
def emit_pre_phrase_signal():
get_signal_path("prePhrase").touch()
return True
class MissingCommunicationDir(Exception):
pass
def get_signal_path(name: str) -> Path:
"""
Get the path to a signal in the signal subdirectory.
Args:
name (str): The name of the signal
Returns:
Path: The signal path
"""
dir_name = actions.user.command_server_directory()
communication_dir_path = get_communication_dir_path(dir_name)
if not communication_dir_path.exists():
raise MissingCommunicationDir()
signal_dir = communication_dir_path / "signals"
signal_dir.mkdir(parents=True, exist_ok=True)
return signal_dir / name
def pre_phrase(_: Any):
try:
global did_emit_pre_phrase_signal
did_emit_pre_phrase_signal = actions.user.emit_pre_phrase_signal()
except MissingCommunicationDir:
pass
def post_phrase(_: Any):
global did_emit_pre_phrase_signal
did_emit_pre_phrase_signal = False
speech_system.register("pre:phrase", pre_phrase)
speech_system.register("post:phrase", post_phrase)
@@ -0,0 +1,20 @@
from talon import Module
mod = Module()
mod.tag(
"command_client", desc="For applications which implement file-based RPC with Talon"
)
@mod.action_class
class Actions:
def command_server_directory() -> str:
"""
The dirctory which contains the files required for communication between
the application and Talon. This is the only function which absolutely
must be implemented for any application using the command-client. Each
application that supports file-based RPC should use its own directory
name. Note that this action should only return a name; the parent
directory is determined by the core command client code.
"""
@@ -0,0 +1,21 @@
import os
from pathlib import Path
from tempfile import gettempdir
def get_communication_dir_path(name: str) -> Path:
"""Returns directory that is used by command-server for communication
Args:
name (str): The name of the communication dir
Returns:
Path: The path to the communication dir
"""
suffix = ""
# NB: We don't suffix on Windows, because the temp dir is user-specific
# anyways
if hasattr(os, "getuid"):
suffix = f"-{os.getuid()}"
return Path(gettempdir()) / f"{name}{suffix}"
@@ -0,0 +1,52 @@
import json
import time
from pathlib import Path
from typing import Any
from talon import actions
# The amount of time to wait for application to perform a command, in seconds
RPC_COMMAND_TIMEOUT_SECONDS = 3.0
# When doing exponential back off waiting for application to perform a command, how
# long to sleep the first time
MINIMUM_SLEEP_TIME_SECONDS = 0.0005
def read_json_with_timeout(path: Path) -> Any:
"""Repeatedly tries to read a json object from the given path, waiting
until there is a trailing new line indicating that the write is complete
Args:
path (str): The path to read from
Raises:
Exception: If we timeout waiting for a response
Returns:
Any: The json-decoded contents of the file
"""
timeout_time = time.perf_counter() + RPC_COMMAND_TIMEOUT_SECONDS
sleep_time = MINIMUM_SLEEP_TIME_SECONDS
while True:
try:
raw_text = path.read_text()
if raw_text.endswith("\n"):
break
except FileNotFoundError:
# If not found, keep waiting
pass
actions.sleep(sleep_time)
time_left = timeout_time - time.perf_counter()
if time_left < 0:
raise Exception("Timed out waiting for response")
# NB: We use minimum sleep time here to ensure that we don't spin with
# small sleeps due to clock slip
sleep_time = max(min(sleep_time * 2, time_left), MINIMUM_SLEEP_TIME_SECONDS)
return json.loads(raw_text)
@@ -0,0 +1,25 @@
from pathlib import Path
from uuid import uuid4
def robust_unlink(path: Path):
"""Unlink the given file if it exists, and if we're on windows and it is
currently in use, just rename it
Args:
path (Path): The path to unlink
"""
try:
path.unlink(missing_ok=True)
except OSError as e:
if hasattr(e, "winerror") and e.winerror == 32:
graveyard_dir = path.parent / "graveyard"
graveyard_dir.mkdir(parents=True, exist_ok=True)
graveyard_path = graveyard_dir / str(uuid4())
print(
f"WARNING: File {path} was in use when we tried to delete it; "
f"moving to graveyard at path {graveyard_path}"
)
path.rename(graveyard_path)
else:
raise e
@@ -0,0 +1,106 @@
import logging
from typing import Any, Callable
from uuid import uuid4
from talon import Module, actions
from .get_communication_dir_path import get_communication_dir_path
from .read_json_with_timeout import read_json_with_timeout
from .robust_unlink import robust_unlink
from .types import NoFileServerException, Request
from .write_request import write_request
logger = logging.getLogger(__name__)
mod = Module()
@mod.action_class
class Actions:
def rpc_client_run_command(
dir_name: str,
trigger_command_execution: Callable,
command_id: str,
args: list[Any],
wait_for_finish: bool = False,
return_command_output: bool = False,
):
"""Runs a command, using command server if available
Args:
dir_name (str): The name of the directory to use for communication.
trigger_command_execution (Callable): The function to call to trigger command execution.
command_id (str): The ID of the command to run.
args: The arguments to the command.
wait_for_finish (bool, optional): Whether to wait for the command to finish before returning. Defaults to False.
return_command_output (bool, optional): Whether to return the output of the command. Defaults to False.
Raises:
Exception: If there is an issue with the file-based communication, or
application raises an exception
Returns:
Object: The response from the command, if requested.
"""
communication_dir_path = get_communication_dir_path(dir_name)
if not communication_dir_path.exists():
logger.warning(
f"Communication directory not found at: {communication_dir_path}"
)
if args or return_command_output:
raise Exception(
"Communication directory not found. Must use command-server extension for advanced commands"
)
raise NoFileServerException("Communication directory not found")
request_path = communication_dir_path / "request.json"
response_path = communication_dir_path / "response.json"
# Generate uuid that will be mirrored back to us by command server for
# sanity checking
uuid = str(uuid4())
request = Request(
command_id=command_id,
args=args,
wait_for_finish=wait_for_finish,
return_command_output=return_command_output,
uuid=uuid,
)
# First, write the request to the request file, which makes us the sole
# owner because all other processes will try to open it with 'x'
write_request(request, request_path)
# We clear the response file if it does exist, though it shouldn't
if response_path.exists():
print("WARNING: Found old response file")
robust_unlink(response_path)
# Then, perform keystroke telling application to execute the command in the
# request file. Because only the active application instance will accept
# keypresses, we can be sure that the active application instance will be the
# one to execute the command.
trigger_command_execution()
try:
decoded_contents = read_json_with_timeout(response_path)
finally:
# NB: We remove response file first because we want to do this while we
# still own the request file
robust_unlink(response_path)
robust_unlink(request_path)
if decoded_contents["uuid"] != uuid:
raise Exception("uuids did not match")
for warning in decoded_contents["warnings"]:
print(f"WARNING: {warning}")
if decoded_contents["error"] is not None:
raise Exception(decoded_contents["error"])
actions.sleep("25ms")
return decoded_contents["returnValue"]
@@ -0,0 +1,24 @@
from dataclasses import dataclass
from typing import Any
@dataclass
class Request:
command_id: str
args: list[Any]
wait_for_finish: bool
return_command_output: bool
uuid: str
def to_dict(self):
return {
"commandId": self.command_id,
"args": self.args,
"waitForFinish": self.wait_for_finish,
"returnCommandOutput": self.return_command_output,
"uuid": self.uuid,
}
class NoFileServerException(Exception):
pass
@@ -0,0 +1,60 @@
import json
import time
from pathlib import Path
from typing import Any
from .robust_unlink import robust_unlink
from .types import Request
# How old a request file needs to be before we declare it stale and are willing
# to remove it
STALE_TIMEOUT_MS = 60_000
def write_request(request: Request, path: Path):
"""Converts the given request to json and writes it to the file, failing if
the file already exists unless it is stale in which case it replaces it
Args:
request (Request): The request to serialize
path (Path): The path to write to
Raises:
Exception: If another process has an active request file
"""
try:
write_json_exclusive(path, request.to_dict())
request_file_exists = False
except FileExistsError:
request_file_exists = True
if request_file_exists:
handle_existing_request_file(path)
write_json_exclusive(path, request.to_dict())
def write_json_exclusive(path: Path, body: Any):
"""Writes jsonified object to file, failing if the file already exists
Args:
path (Path): The path of the file to write
body (Any): The object to convert to json and write
"""
with path.open("x") as out_file:
out_file.write(json.dumps(body))
def handle_existing_request_file(path):
stats = path.stat()
modified_time_ms = stats.st_mtime_ns / 1e6
current_time_ms = time.time() * 1e3
time_difference_ms = abs(modified_time_ms - current_time_ms)
if time_difference_ms < STALE_TIMEOUT_MS:
raise Exception(
"Found recent request file; another Talon process is probably running"
)
print("Removing stale request file")
robust_unlink(path)
+63
View File
@@ -0,0 +1,63 @@
# Contacts
This directory provides a versatile `<user.prose_contact>` capture that can be
used to insert names and email addresses using a suffix. This functionality is
exposed through other captures such as `<user.text>` and `<user.prose>`, not
directly as commands. The contact list may be provided in the private directory
via the `contacts.json` file, the `contacts.csv` file, or both.
Here is an example contacts.json:
```json
[
{
"email": "john.doe@example.com",
"full_name": "Jonathan Doh: Jonathan Doe",
"nicknames": ["Jon", "Jah Nee: Jonny"]
}
]
```
Note that for either full_name or nicknames, pronunciation can be provided via
the standard Talon list format of "[pronunciation]: [name]". Pronunciation for
the first name is automatically extracted from pronunciation for the full name,
if there are the same number of name parts in each. Pronunciation can be
overridden for the first name by adding a nickname with matching written form.
To refer to this contact, you could say:
- Jonathan Doh email -> john.doe@example.com
- Jonathan email -> john.doe@example.com
- Jah Nee email -> john.doe@example.com
- Jah Nee name -> Jonny
- Jonathan Doh name -> Jonathan Doe
- Jon last name -> Doe
- Jon full name -> Jonathan Doe
- Jon names -> Jon's
- Jon full names -> Jonathan Doe's
The CSV format provides only email and full name functionality:
```csv
Name,Email
John Doe,jon.doe@example.com
Jane Doe,jane.doe@example.com
```
The advantage of the CSV format is that it is easily exported. If both the CSV
and JSON are present, they will be merged based on email addresses. This makes
it easy to use an exported CSV and maintain nicknames in the JSON. For example,
to export from Gmail, go to https://contacts.google.com/, then click "Frequently
contacted", then "Export". Then run:
```bash
cat contacts.csv | python -c "import csv; import sys; w=csv.writer(sys.stdout); [w.writerow([row['First Name'] + ' ' + row['Last Name'], row['E-mail 1 - Value']]) for row in csv.DictReader(sys.stdin)]"
```
In case of name conflicts (e.g. two people named John), the first instance will
be preferred, with all JSON contacts taking precedence over CSV. If you wish to
refer to both, use the pronunciation to differentiate, using a nickname to
override the first name pronunciation if desired. For example, you might add
"John S: John" and "John D: John" as nicknames to the two different Johns. This
is also an effective way to handle name homophones such as John and Jon, which
would otherwise be resolved arbitrarily by the speech engine.
+293
View File
@@ -0,0 +1,293 @@
import json
import logging
from dataclasses import dataclass
from talon import Context, Module
from ..user_settings import track_csv_list, track_file
mod = Module()
ctx = Context()
mod.list("contact_names", desc="Contact first names, full names, and nicknames.")
mod.list("contact_emails", desc="Maps names to email addresses.")
mod.list("contact_full_names", desc="Maps names to full names.")
@dataclass
class Contact:
email: str
full_name: str
nicknames: list[str]
pronunciations: dict[str, str]
@classmethod
def from_json(cls, contact):
email = contact.get("email")
if not email:
logging.error(f"Skipping contact missing email: {contact}")
return None
# Handle full name with potential pronunciation
full_name_raw = contact.get("full_name", "")
pronunciations = {}
if ":" in full_name_raw:
pronunciation, full_name = [x.strip() for x in full_name_raw.split(":", 1)]
if (
full_name in pronunciations
and pronunciations[full_name] != pronunciation
):
logging.info(
f"Multiple pronunciations found for '{full_name}'; using '{pronunciation}'"
)
pronunciations[full_name] = pronunciation
# Add pronunciation for each component of the name.
pron_parts = pronunciation.split()
name_parts = full_name.split()
if len(pron_parts) == len(name_parts):
for pron, name in zip(pron_parts, name_parts):
if name in pronunciations and pronunciations[name] != pron:
logging.info(
f"Multiple different pronunciations found for '{name}' in "
f"{full_name_raw}; using '{pron}'"
)
pronunciations[name] = pron
else:
logging.info(
f"Pronunciation parts don't match name parts for '{full_name_raw}; skipping them.'"
)
else:
full_name = full_name_raw
# Handle nicknames with potential pronunciations
nicknames = []
for nickname_raw in contact.get("nicknames", []):
if ":" in nickname_raw:
pronunciation, nickname = [
x.strip() for x in nickname_raw.split(":", 1)
]
if (
nickname in pronunciations
and pronunciations[nickname] != pronunciation
):
logging.info(
f"Multiple different pronunciations found for '{nickname}' in "
f"contact {email}; using '{pronunciation}'"
)
pronunciations[nickname] = pronunciation
nicknames.append(nickname)
else:
nicknames.append(nickname_raw)
return Contact(
email=email,
full_name=full_name,
nicknames=nicknames,
pronunciations=pronunciations,
)
csv_contacts: list[Contact] = []
json_contacts: list[Contact] = []
@track_csv_list("contacts.csv", headers=("Name", "Email"), default={}, private=True)
def on_contacts_csv(values):
global csv_contacts
csv_contacts = []
for email, full_name in values.items():
if not email:
logging.error(f"Skipping contact missing email: {full_name}")
continue
csv_contacts.append(
Contact(email=email, full_name=full_name, nicknames=[], pronunciations={})
)
reload_contacts()
@track_file("contacts.json", default="[]", private=True)
def on_contacts_json(f):
global json_contacts
try:
contacts = json.load(f)
except Exception:
logging.exception("Error parsing contacts.json")
return
json_contacts = []
for contact in contacts:
try:
parsed_contact = Contact.from_json(contact)
if parsed_contact:
json_contacts.append(parsed_contact)
except Exception:
logging.exception(f"Error parsing contact: {contact}")
reload_contacts()
def create_pronunciation_to_name_map(contact):
result = {}
if contact.full_name:
result[contact.pronunciations.get(contact.full_name, contact.full_name)] = (
contact.full_name
)
# Add pronunciation mapping for first name only
first_name = contact.full_name.split()[0]
result[contact.pronunciations.get(first_name, first_name)] = first_name
for nickname in contact.nicknames:
result[contact.pronunciations.get(nickname, nickname)] = nickname
return result
def reload_contacts():
csv_by_email = {contact.email: contact for contact in csv_contacts}
json_by_email = {contact.email: contact for contact in json_contacts}
# Merge the CSV and JSON contacts. Maintain order of contacts with JSON first.
merged_contacts = []
for email in json_by_email | csv_by_email:
csv_contact = csv_by_email.get(email)
json_contact = json_by_email.get(email)
if csv_contact and json_contact:
# Prefer JSON data but use CSV name if JSON name is empty
full_name = json_contact.full_name or csv_contact.full_name
merged_contacts.append(
Contact(
email=email,
full_name=full_name,
nicknames=json_contact.nicknames,
pronunciations=json_contact.pronunciations,
)
)
else:
# Use whichever contact exists
merged_contacts.append(json_contact or csv_contact)
contact_names = {}
contact_emails = {}
contact_full_names = {}
# Iterate in reverse so that the first contact with a name is used.
for contact in reversed(merged_contacts):
pronunciation_map = create_pronunciation_to_name_map(contact)
for pronunciation, name in pronunciation_map.items():
contact_names[pronunciation] = name
contact_emails[pronunciation] = contact.email
if contact.full_name:
contact_full_names[pronunciation] = contact.full_name
ctx.lists["user.contact_names"] = contact_names
ctx.lists["user.contact_emails"] = contact_emails
ctx.lists["user.contact_full_names"] = contact_full_names
def first_name_from_full_name(full_name: str):
return full_name.split(" ")[0]
def last_name_from_full_name(full_name: str):
return full_name.split(" ")[-1]
def username_from_email(email: str):
return email.split("@")[0]
def make_name_possessive(name: str):
return f"{name}'s"
@mod.capture(
rule="{user.contact_names} name",
)
def prose_name(m) -> str:
return m.contact_names
@mod.capture(
rule="{user.contact_names} names",
)
def prose_name_possessive(m) -> str:
return make_name_possessive(m.contact_names)
@mod.capture(
rule="{user.contact_emails} email [address]",
)
def prose_email(m) -> str:
return m.contact_emails
@mod.capture(
rule="{user.contact_emails} (username | L dap)",
)
def prose_username(m) -> str:
return username_from_email(m.contact_emails)
@mod.capture(
rule="{user.contact_full_names} full name",
)
def prose_full_name(m) -> str:
return m.contact_full_names
@mod.capture(
rule="{user.contact_full_names} full names",
)
def prose_full_name_possessive(m) -> str:
return make_name_possessive(m.contact_full_names)
@mod.capture(
rule="{user.contact_full_names} first name",
)
def prose_first_name(m) -> str:
return first_name_from_full_name(m.contact_full_names)
@mod.capture(
rule="{user.contact_full_names} first names",
)
def prose_first_name_possessive(m) -> str:
return make_name_possessive(first_name_from_full_name(m.contact_full_names))
@mod.capture(
rule="{user.contact_full_names} last name",
)
def prose_last_name(m) -> str:
return last_name_from_full_name(m.contact_full_names)
@mod.capture(
rule="{user.contact_full_names} last names",
)
def prose_last_name_possessive(m) -> str:
return make_name_possessive(last_name_from_full_name(m.contact_full_names))
@mod.capture(
rule="(hi | high) {user.contact_names} [name]",
)
def prose_contact_snippet(m) -> str:
return f"hi {m.contact_names}"
@mod.capture(
rule=(
"<user.prose_name> "
"| <user.prose_name_possessive> "
"| <user.prose_email> "
"| <user.prose_username> "
"| <user.prose_full_name> "
"| <user.prose_full_name_possessive> "
"| <user.prose_first_name> "
"| <user.prose_first_name_possessive> "
"| <user.prose_last_name>"
"| <user.prose_last_name_possessive>"
"| <user.prose_contact_snippet>"
),
)
def prose_contact(m) -> str:
return m[0]
+539
View File
@@ -0,0 +1,539 @@
import itertools
import re
from collections import defaultdict
from dataclasses import dataclass
from typing import Any, List, Mapping, Optional
from talon import Module, actions
from .keys.symbols import symbols_for_create_spoken_forms
from .numbers.numbers import digits_map, scales, teens, tens
from .user_settings import track_csv_list
mod = Module()
DEFAULT_MINIMUM_TERM_LENGTH = 2
EXPLODE_MAX_LEN = 3
FANCY_REGULAR_EXPRESSION = r"[A-Z]?[a-z]+|[A-Z]+(?![a-z])|[0-9]+"
SYMBOLS_REGEX = "|".join(
re.escape(symbol) for symbol in set(symbols_for_create_spoken_forms.values())
)
FILE_EXTENSIONS_REGEX = r"^\b$"
file_extensions = {}
def update_regex():
global REGEX_NO_SYMBOLS
global REGEX_WITH_SYMBOLS
REGEX_NO_SYMBOLS = re.compile(
"|".join(
[
FANCY_REGULAR_EXPRESSION,
FILE_EXTENSIONS_REGEX,
]
)
)
REGEX_WITH_SYMBOLS = re.compile(
"|".join([FANCY_REGULAR_EXPRESSION, FILE_EXTENSIONS_REGEX, SYMBOLS_REGEX])
)
update_regex()
@track_csv_list("file_extensions.csv", headers=("File extension", "Name"))
def on_extensions(values):
global FILE_EXTENSIONS_REGEX
global file_extensions
file_extensions = values
FILE_EXTENSIONS_REGEX = "|".join(
re.escape(file_extension.strip()) + "$" for file_extension in values.values()
)
update_regex()
abbreviations_list = {}
@track_csv_list("abbreviations.csv", headers=("Abbreviation", "Spoken Form"))
def on_abbreviations(values):
global abbreviations_list
abbreviations_list = values
REVERSE_PRONUNCIATION_MAP = {
**{str(value): key for key, value in digits_map.items()},
**{value: key for key, value in symbols_for_create_spoken_forms.items()},
}
# begin: create the lists etc necessary for create_spoken_word_for_number
# by convention, each entry in the list has an append space... until I clean up the function
# the algorithm's expectation is slightly different from numbers.py
# ["", "one ", "two ",... "nine "] or equivalents
ones = [""] + [
REVERSE_PRONUNCIATION_MAP[str(index)] for index in range(10) if index != 0
]
# ["","","twenty","thirty","forty",..."ninety"]
# or equivalent
twenties = ["", ""] + list(tens)
# print("twenties = " + str(twenties))
thousands = [""] + [val for index, val in enumerate(scales) if index != 0]
# print("thousands = " + thousands)
# end: create the lists necessary for create_spoken_word_for_number
def create_spoken_form_for_number(num: int):
"""Creates a spoken form for an integer"""
n3 = []
r1 = ""
# create numeric string
ns = str(num)
for k in range(3, 33, 3):
r = ns[-k:]
q = len(ns) - k
# break if end of ns has been reached
if q < -2:
break
else:
if q >= 0:
n3.append(int(r[:3]))
elif q >= -1:
n3.append(int(r[:2]))
elif q >= -2:
n3.append(int(r[:1]))
r1 = r
# break each group of 3 digits into
# ones, tens/twenties, hundreds
words = []
for i, x in enumerate(n3):
b1 = x % 10
b2 = (x % 100) // 10
b3 = (x % 1000) // 100
if x == 0:
continue # skip
else:
t = thousands[i]
# print(str(b1) + ", " + str(b2) + ", " + str(b3))
if b2 == 0:
words = [ones[b1], t] + words
elif b2 == 1:
words = [teens[b1], t] + words
elif b2 > 1:
words = [twenties[b2], ones[b1], t] + words
if b3 > 0:
words = [ones[b3], scales[0]] + words
# filter out the empty strings and join
return " ".join(filter(None, words))
def create_spoken_form_years(num: str):
"""Creates spoken forms for numbers 1000 <= num <= 9999. Returns None if not supported"""
val = int(num)
if val > 9999 or val < 1000:
return None
centuries = val // 100
remainder = val % 100
words = []
if centuries % 10 != 0:
words.append(create_spoken_form_for_number(centuries))
# 1900 -> nineteen hundred
if remainder == 0:
words.append(scales[0])
else:
# 200X -> two thousand x
if remainder < 9:
words.append(REVERSE_PRONUNCIATION_MAP[str(centuries // 10)])
words.append(scales[1])
# 20XX => twenty xx
else:
words.append(create_spoken_form_for_number(str(centuries)))
if remainder != 0:
# 1906 => "nineteen six"
if remainder < 10:
# todo: decide if we want nineteen oh five"
# todo: decide if we want "and"
# both seem like a waste
# if centuries % 10 != 0:
# words.append("oh")
words.append(REVERSE_PRONUNCIATION_MAP[str(remainder)])
else:
words.append(create_spoken_form_for_number(remainder))
return " ".join(words)
# # ---------- create_spoken_form_years (uncomment to run) ----------
# def test_year(year: str, expected: str):
# result = create_spoken_form_years(year)
# print(f"test_year: test string = {year}, result = {result}, expected = {expected}")
# assert create_spoken_form_years(year) == expected
# print("************* test_year tests ******************")
# test_year("1100", "eleven hundred")
# test_year("1905", "nineteen five")
# test_year("1910", "nineteen ten")
# test_year("1925", "nineteen twenty five")
# test_year("2000", "two thousand")
# test_year("2005", "two thousand five")
# test_year("2020", "twenty twenty")
# test_year("2019", "twenty nineteen")
# test_year("2085", "twenty eighty five")
# test_year("2100", "twenty one hundred")
# test_year("2105", "twenty one five")
# test_year("9999", "ninety nine ninety nine")
# print("************* test_year tests done**************")
def create_single_spoken_form(source: str):
"""
Returns a spoken form of a string
(1) Returns the value from REVERSE_PRONUNCIATION_MAP if it exists
(2) Splits allcaps into separate letters ("ABC" -> A B C)
(3) Otherwise, returns the lower case source.
"""
normalized_source = source.lower()
try:
mapped_source = REVERSE_PRONUNCIATION_MAP[normalized_source]
except KeyError:
# Leave completely uppercase words alone, as we can deal with them later.
# Otherwise normalized the rest to help with subsequent abbreviation lookups,
# etc.
if source.isupper():
mapped_source = source
else:
mapped_source = source.lower()
return mapped_source
def create_exploded_forms(spoken_forms: List[str]):
"""Exploded common packed words into separate words"""
# TODO: This could be moved somewhere else, possibly seeded from something like
# words to replace...
packed_words = {"readme": "read me"}
new_spoken_forms = []
for line in spoken_forms:
exploded_form = []
# ex: "vm" or "usb" explodes into "V M" or "U S B"
if (
" " not in line
and line.islower()
and len(line) > 1
and len(line) <= EXPLODE_MAX_LEN
):
new_spoken_forms.append(line) # Keep a regular copy (ie: "nas")
new_spoken_forms.append(" ".join(line.upper()))
# ex: "readme" explodes into "read me"
else:
for word in line.split(" "):
if word in packed_words.keys():
exploded_form.append(packed_words[word])
else:
exploded_form.append(word)
new_spoken_forms.append(" ".join(exploded_form))
return new_spoken_forms
def create_extension_forms(spoken_forms: List[str]):
"""Add extension forms"""
new_spoken_forms = []
file_extensions_map = {v.strip(): k for k, v in file_extensions.items()}
for line in spoken_forms:
have_file_extension = False
file_extension_forms = []
dotted_extension_form = []
truncated_forms = []
for substring in line.split(" "):
# NOTE: If we ever run in to file extensions in the middle of file name, the
# truncated form is going to be busted. ie: foo.md.disabled
if substring in file_extensions_map.keys():
file_extension_forms.append(file_extensions_map[substring])
dotted_extension_form.append(REVERSE_PRONUNCIATION_MAP["."])
dotted_extension_form.append(file_extensions_map[substring])
have_file_extension = True
# purposefully down update truncated
else:
file_extension_forms.append(substring)
dotted_extension_form.append(substring)
truncated_forms.append(substring)
# print(file_extension_forms)
if have_file_extension:
new_spoken_forms.append(" ".join(file_extension_forms))
new_spoken_forms.append(" ".join(dotted_extension_form))
new_spoken_forms.append(" ".join(truncated_forms))
return set(dict.fromkeys(new_spoken_forms))
def create_cased_forms(spoken_forms: List[str]):
"""Add lower and upper case forms"""
new_spoken_forms = []
for line in spoken_forms:
lower_forms = []
upper_forms = []
# print(line)
for substring in line.split(" "):
if substring.isupper():
lower_forms.append(substring.lower())
upper_forms.append(" ".join(substring))
else:
lower_forms.append(substring)
upper_forms.append(substring)
new_spoken_forms.append(" ".join(lower_forms))
new_spoken_forms.append(" ".join(upper_forms))
return set(dict.fromkeys(new_spoken_forms))
def create_abbreviated_forms(spoken_forms: List[str]):
"""Add abbreviated case forms"""
new_spoken_forms = []
swapped_abbreviation_map = {v: k for k, v in abbreviations_list.items()}
for line in spoken_forms:
unabbreviated_forms = []
abbreviated_forms = []
for substring in line.split(" "):
if substring in swapped_abbreviation_map.keys():
abbreviated_forms.append(swapped_abbreviation_map[substring])
else:
abbreviated_forms.append(substring)
unabbreviated_forms.append(substring)
new_spoken_forms.append(" ".join(abbreviated_forms))
new_spoken_forms.append(" ".join(unabbreviated_forms))
return set(dict.fromkeys(new_spoken_forms))
def create_spoken_number_forms(source: List[str]):
"""
Create a list of spoken forms by transforming numbers in source into spoken forms.
This creates a first pass of spoken forms with numbers translated, but will go
through multiple other passes.
"""
# list of spoken forms returned
spoken_forms = []
# contains the pieces for the spoken form with individual digits
full_form_digit_wise = []
# contains the pieces for the spoken form with the spoken version of the number
full_form_fancy_numbers = []
# contains the pieces for the spoken form for years like "1900" => nineteen hundred
full_form_spoken_form_years = []
# indicates whether or not we processed created a version with the full number (>10) translated
has_fancy_number_version = False
# indicates whether or not we processed created a version with the year-like ("1900" => nineteen hundred) numbers
has_spoken_form_years = False
for substring in source:
# for piece in pieces:
# substring = piece.group(0)
length = len(substring)
# the length is currently capped at 31 digits
if length > 1 and length <= 31 and substring.isnumeric():
has_fancy_number_version = True
val = int(substring)
spoken_form_years = create_spoken_form_years(val)
spoken_form = create_spoken_form_for_number(val)
if spoken_form_years:
has_spoken_form_years = True
full_form_spoken_form_years.append(spoken_form_years)
else:
full_form_spoken_form_years.append(spoken_form)
full_form_fancy_numbers.append(spoken_form)
# build the serial digit version
for digit in substring:
full_form_digit_wise.append(create_single_spoken_form(digit))
else:
spoken_form = create_single_spoken_form(substring)
full_form_digit_wise.append(spoken_form)
full_form_fancy_numbers.append(spoken_form)
full_form_spoken_form_years.append(spoken_form)
if has_fancy_number_version:
spoken_forms.append(" ".join(full_form_fancy_numbers))
if has_spoken_form_years:
result = " ".join(full_form_spoken_form_years)
if result not in spoken_forms:
spoken_forms.append(result)
spoken_forms.append(" ".join(full_form_digit_wise))
return set(dict.fromkeys(spoken_forms))
def create_spoken_forms_from_regex(source: str, pattern: re.Pattern):
"""
Creates a list of spoken forms for source using the provided regex pattern.
For numeric pieces detected by the regex, generates both digit-wise and full
spoken forms for the numbers where appropriate.
"""
source_without_apostrophes = source.replace("'", "")
pieces = list(pattern.finditer(source_without_apostrophes))
spoken_forms = list(map(lambda x: x.group(0), pieces))
# NOTE: Order is sometimes important
transforms = [
create_spoken_number_forms,
create_extension_forms,
create_cased_forms,
create_exploded_forms,
create_abbreviated_forms,
create_extension_forms,
]
for func in transforms:
spoken_forms = func(spoken_forms)
return list(dict.fromkeys(spoken_forms))
def generate_string_subsequences(
source: str,
words_to_exclude: list[str],
minimum_term_length: int,
):
# Includes (lower-cased):
# 1. Each word in source, eg "foo bar baz" -> "foo", "bar", "baz".
# 2. Each leading subsequence of words from source,
# eg "foo bar baz" -> "foo", "foo bar", "foo bar baz"
# (but not "bar baz" - TODO: is this intentional?)
#
# Except for:
# 3. strings shorter than minimum_term_length
# 4. strings in words_to_exclude.
term_sequence = source.split(" ")
terms = {
# WARNING: This .lower() version creates unwanted duplication of broken up
# uppercase words, eg 'R E A D M E' -> 'r e a d m e'. Everything else should be
# lower case already
# term.lower().strip()
term.strip()
for term in (
term_sequence
+ list(itertools.accumulate([f"{term} " for term in term_sequence]))
)
}
return [
term
for term in terms
if (term not in words_to_exclude and len(term) >= minimum_term_length)
]
@dataclass
class SpeakableItem:
name: str
value: Any
@mod.action_class
class Actions:
def create_spoken_forms(
source: str,
words_to_exclude: Optional[list[str]] = None,
minimum_term_length: int = DEFAULT_MINIMUM_TERM_LENGTH,
generate_subsequences: bool = True,
) -> list[str]:
"""Create spoken forms for a given source"""
spoken_forms_without_symbols = create_spoken_forms_from_regex(
source, REGEX_NO_SYMBOLS
)
# todo: this could probably be optimized out if there's no symbols
spoken_forms_with_symbols = create_spoken_forms_from_regex(
source, REGEX_WITH_SYMBOLS
)
# some may be identical, so ensure the list is reduced
spoken_forms = set(spoken_forms_with_symbols + spoken_forms_without_symbols)
# only generate the subsequences if requested
if generate_subsequences:
# todo: do we care about the subsequences that are excluded.
# the only one that seems relevant are the full spoken form for
spoken_forms.update(
generate_string_subsequences(
spoken_forms_without_symbols[-1],
words_to_exclude or [],
minimum_term_length,
)
)
# Avoid empty spoken forms.
return [x for x in spoken_forms if x]
def create_spoken_forms_from_list(
sources: list[str],
words_to_exclude: Optional[list[str]] = None,
minimum_term_length: int = DEFAULT_MINIMUM_TERM_LENGTH,
generate_subsequences: bool = True,
) -> dict[str, str]:
"""Create spoken forms for all sources in a list, doing conflict resolution"""
return actions.user.create_spoken_forms_from_map(
{source: source for source in sources},
words_to_exclude,
minimum_term_length,
generate_subsequences,
)
def create_spoken_forms_from_map(
sources: Mapping[str, Any],
words_to_exclude: Optional[list[str]] = None,
minimum_term_length: int = DEFAULT_MINIMUM_TERM_LENGTH,
generate_subsequences: bool = True,
) -> dict[str, Any]:
"""Create spoken forms for all sources in a map, doing conflict resolution"""
all_spoken_forms: defaultdict[str, list[SpeakableItem]] = defaultdict(list)
for name, value in sources.items():
spoken_forms = actions.user.create_spoken_forms(
name, words_to_exclude, minimum_term_length, generate_subsequences
)
for spoken_form in spoken_forms:
all_spoken_forms[spoken_form].append(SpeakableItem(name, value))
final_spoken_forms = {}
for spoken_form, spoken_form_sources in all_spoken_forms.items():
if len(spoken_form_sources) > 1:
final_spoken_forms[spoken_form] = min(
spoken_form_sources,
key=lambda speakable_item: len(speakable_item.name),
).value
else:
final_spoken_forms[spoken_form] = spoken_form_sources[0].value
return final_spoken_forms
+30
View File
@@ -0,0 +1,30 @@
from talon import Module, actions, speech_system
delay_mod = Module()
delayed_enabled = False
def do_disable(e):
speech_system.unregister("post:phrase", do_disable)
actions.speech.disable()
@delay_mod.action_class
class DelayedSpeechOffActions:
def delayed_speech_on():
"""Activates a "temporary speech" mode that can be disabled lazily,
so that the actual disable command happens after whatever phrase
finishes next."""
global delayed_enabled
if not actions.speech.enabled():
delayed_enabled = True
actions.speech.enable()
def delayed_speech_off():
"""Disables "temporary speech" mode lazily, meaning that the next
phrase that finishes will turn speech off."""
global delayed_enabled
if delayed_enabled:
delayed_enabled = False
speech_system.register("post:phrase", do_disable)
+183
View File
@@ -0,0 +1,183 @@
"""
Helpers for deprecating voice commands, actions, and captures. Since Talon can
be an important part of people's workflows providing a warning before removing
functionality is encouraged.
The normal deprecation process in `community` is as follows:
1. For 6 months from deprecation a deprecated action or command should
continue working. Put an entry in the BREAKING_CHANGES.txt file in the
project root to mark the deprecation and potentially explain how users can
migrate. Use the user.deprecate_command, user.deprecate_action, or
user.deprecate_capture actions to notify users.
2. After 6 months you can delete the deprecated command, action, or capture.
Leave the note in BREAKING_CHANGES.txt so people who missed the
notifications can see what happened.
If for some reason you can't keep the functionality working for 6 months,
just put the information in BREAKING_CHANGES.txt so people can look there to
see what happened.
Usages:
# myfile.talon - demonstrate voice command deprecation
...
old legacy command:
# This will show a notification to say use 'new command' instead of
# 'old legacy command'. No removal of functionality is allowed.
user.deprecate_command("2022-11-10", "old legacy command", "new command")
# perform command
new command:
# perform command
# myfile.py - demonstrate action deprecation
from talon import actions
@mod.action_class
class Actions:
def legacy_action():
actions.user.deprecate_action("2022-10-01", "user.legacy_action")
# Perform action
# otherfile.py - demostrate capture deprecation
@mod.capture(rule="...")
def legacy_capture(m) -> str:
actions.user.deprecate_capture("2023-09-03", "user.legacy_capture")
# implement capture
See https://github.com/talonhub/community/issues/940 for original discussion
"""
import datetime
import os.path
import warnings
from typing import Optional
from talon import Module, actions, settings, speech_system
REPO_DIR = os.path.dirname(os.path.dirname(__file__))
mod = Module()
mod.setting(
"deprecate_warning_interval_hours",
type=float,
desc="""How long, in hours, to wait before notifying the user again of a
deprecated action/command/capture.""",
default=24,
)
# Tells us the last time a notification was shown so we can
# decide when to re-show it without annoying the user too
# much
notification_last_shown = {}
# This gets reset on every phrase, so we avoid notifying more than once per
# phrase.
notified_in_phrase = set()
def calculate_rule_info():
"""
Try to work out the .talon file and line of the command that is executing
"""
try:
current_command = actions.core.current_command__unstable()
start_line = current_command[0].target.start_line
filename = current_command[0].target.filename
rule = " ".join(current_command[1]._unmapped)
return f'\nTriggered from "{rule}" ({filename}:{start_line})'
except Exception as e:
return ""
def deprecate_notify(id: str, message: str):
"""
Notify the user about a deprecation/deactivation. id uniquely
identifies this deprecation.
"""
maybe_last_shown = notification_last_shown.get(id)
now = datetime.datetime.now()
interval = settings.get("user.deprecate_warning_interval_hours")
threshold = now - datetime.timedelta(hours=interval)
if maybe_last_shown is not None and maybe_last_shown > threshold:
return
actions.app.notify(message, "Deprecation warning")
notification_last_shown[id] = now
def post_phrase(_ignored):
global notified_in_phrase
notified_in_phrase = set()
speech_system.register("post:phrase", post_phrase)
@mod.action_class
class Actions:
def deprecate_command(time_deprecated: str, name: str, replacement: str):
"""
Notify the user that the given voice command is deprecated and should
not be used into the future; the command `replacement` should be used
instead.
"""
if name in notified_in_phrase:
return
# Want to tell users every time they use a deprecated command since
# they should immediately be retraining to use {replacement}. Also
# so if they repeat the command they get another chance to read
# the popup message.
notified_in_phrase.add(name)
msg = (
f'The "{name}" command is deprecated. Instead, say: "{replacement}".'
f" See log for more."
)
actions.app.notify(msg, "Deprecation warning")
msg = (
f'The "{name}" command is deprecated since {time_deprecated}.'
f' Instead, say: "{replacement}".'
f' See {os.path.join(REPO_DIR, "BREAKING_CHANGES.txt")}'
)
warnings.warn(msg, DeprecationWarning)
def deprecate_capture(time_deprecated: str, name: str):
"""
Notify the user that the given capture is deprecated and should
not be used into the future.
"""
id = f"capture.{name}.{time_deprecated}"
deprecate_notify(id, f"The `{name}` capture is deprecated. See log for more.")
msg = (
f"The `{name}` capture is deprecated since {time_deprecated}."
f' See {os.path.join(REPO_DIR, "BREAKING_CHANGES.txt")}'
f"{calculate_rule_info()}"
)
warnings.warn(msg, DeprecationWarning, stacklevel=3)
def deprecate_action(time_deprecated: str, name: str, replacement: str = ""):
"""
Notify the user that the given action is deprecated and should
not be used into the future; the action `replacement` should be used
instead.
"""
id = f"action.{name}.{time_deprecated}"
deprecate_notify(id, f"The `{name}` action is deprecated. See log for more.")
replacement_msg = f' Instead, use: "{replacement}".' if replacement else ""
msg = (
f"The `{name}` action is deprecated since {time_deprecated}."
f"{replacement_msg}"
f' See {os.path.join(REPO_DIR, "BREAKING_CHANGES.txt")}'
f"{calculate_rule_info()}"
)
warnings.warn(msg, DeprecationWarning, stacklevel=5)
+22
View File
@@ -0,0 +1,22 @@
from talon import Module, speech_system
mod = Module()
@mod.action_class
class Actions:
def dragon_engine_sleep():
"""Sleep the dragon engine"""
speech_system.engine_mimic("go to sleep"),
def dragon_engine_wake():
"""Wake the dragon engine"""
speech_system.engine_mimic("wake up"),
def dragon_engine_command_mode():
"""Switch dragon to command mode. Requires Pro."""
speech_system.engine_mimic("switch to command mode")
def dragon_engine_normal_mode():
"""Switch dragon to normal mode. Requires Pro."""
speech_system.engine_mimic("start normal mode")
+27
View File
@@ -0,0 +1,27 @@
from talon import Module, actions
mod = Module()
mod.list("delimiter_pair", "List of matching pair delimiters")
@mod.capture(rule="{user.delimiter_pair}")
def delimiter_pair(m) -> list[str]:
pair = m.delimiter_pair.split()
assert len(pair) == 2
# "space" requires a special written form because Talon lists are whitespace insensitive
open = pair[0] if pair[0] != "space" else " "
close = pair[1] if pair[1] != "space" else " "
return [open, close]
@mod.action_class
class Actions:
def delimiter_pair_insert(pair: list[str]):
"""Insert a delimiter pair <pair> leaving the cursor in the middle"""
actions.user.insert_between(pair[0], pair[1])
def delimiter_pair_wrap_selection(pair: list[str]):
"""Wrap selection with delimiter pair <pair>"""
selected = actions.edit.selected_text()
actions.insert(f"{pair[0]}{selected}{pair[1]}")
@@ -0,0 +1,25 @@
list: user.delimiter_pair
-
# Format:
# SPOKEN_FORM: LEFT_DELIMITER RIGHT_DELIMITER
# Use the literal symbols for delimiters (except for whitespace, which is "space")
#
# Examples:
# round: ( )
# pad: space space
round: ( )
box: [ ]
diamond: < >
curly: { }
twin: "' '"
quad: '" "'
skis: ` `
percentages: % %
pad: space space
escaped quad: '\\" \\"'
escaped twin: "\\' \\'"
escaped round: \( \)
escaped box: \[ \]
+153
View File
@@ -0,0 +1,153 @@
from talon import Context, Module, actions, clip, settings
ctx = Context()
mod = Module()
mod.setting(
"selected_text_timeout",
type=float,
default=0.25,
desc="Time in seconds to wait for the clipboard to change when trying to get selected text",
)
END_OF_WORD_SYMBOLS = ".!?;:—_/\\|@#$%^&*()[]{}<>=+-~`"
@ctx.action_class("edit")
class EditActions:
def selected_text() -> str:
timeout = settings.get("user.selected_text_timeout")
with clip.capture(timeout) as s:
actions.edit.copy()
try:
return s.text()
except clip.NoChange:
return ""
def line_insert_down():
actions.edit.line_end()
actions.key("enter")
def selection_clone():
actions.edit.copy()
actions.edit.select_none()
actions.edit.paste()
def line_clone():
# This may not work if editor auto-indents. Is there a better way?
actions.edit.line_start()
actions.edit.extend_line_end()
actions.edit.copy()
actions.edit.right()
actions.key("enter")
actions.edit.paste()
# # This simpler implementation of select_word mostly works, but in some apps it doesn't.
# # See https://github.com/talonhub/community/issues/1084.
# def select_word():
# actions.edit.right()
# actions.edit.word_left()
# actions.edit.extend_word_right()
def select_word():
actions.edit.extend_right()
character_to_right_of_initial_caret_position = actions.edit.selected_text()
# Occasionally apps won't let you edit.extend_right()
# and therefore won't select text if your caret is on the rightmost character
# such as in the Chrome URL bar
did_select_text = character_to_right_of_initial_caret_position != ""
if did_select_text:
# .strip() turns newline & space characters into empty string; the empty
# string is in any other string, so this works.
if (
character_to_right_of_initial_caret_position.strip()
in END_OF_WORD_SYMBOLS
):
# Come out of the highlight in the initial position.
actions.edit.left()
else:
# Come out of the highlight one character
# to the right of the initial position.
actions.edit.right()
actions.edit.word_left()
actions.edit.extend_word_right()
@mod.action_class
class Actions:
def paste(text: str):
"""Pastes text and preserves clipboard"""
with clip.revert():
clip.set_text(text)
actions.edit.paste()
# sleep here so that clip.revert doesn't revert the clipboard too soon
actions.sleep("150ms")
def delete_right():
"""Delete character to the right"""
actions.key("delete")
def delete_all():
"""Delete all text in the current document"""
actions.edit.select_all()
actions.edit.delete()
def words_left(n: int):
"""Moves left by n words."""
for _ in range(n):
actions.edit.word_left()
def words_right(n: int):
"""Moves right by n words."""
for _ in range(n):
actions.edit.word_right()
def cut_word_left():
"""Cuts the word to the left."""
actions.edit.extend_word_left()
actions.edit.cut()
def cut_word_right():
"""Cuts the word to the right."""
actions.edit.extend_word_right()
actions.edit.cut()
def copy_word_left():
"""Copies the word to the left."""
actions.edit.extend_word_left()
actions.edit.copy()
def copy_word_right():
"""Copies the word to the right."""
actions.edit.extend_word_right()
actions.edit.copy()
# ----- Start / End of line -----
def select_line_start():
"""Select to start of current line"""
if actions.edit.selected_text():
actions.edit.left()
actions.edit.extend_line_start()
def select_line_end():
"""Select to end of current line"""
if actions.edit.selected_text():
actions.edit.right()
actions.edit.extend_line_end()
def line_middle():
"""Go to the middle of the line"""
actions.edit.select_line()
half_line_length = int(len(actions.edit.selected_text()) / 2)
actions.edit.left()
for i in range(0, half_line_length):
actions.edit.right()
def cut_line():
"""Cut current line"""
actions.edit.select_line()
actions.edit.cut()
+85
View File
@@ -0,0 +1,85 @@
# Compound of action(select, clear, copy, cut, paste, etc.) and modifier(word,
# line, etc.) commands for editing text.
# eg: "select line", "clear all"
# For overriding or creating aliases for specific actions, this function will
# also accept strings, e.g. `user.edit_command("delete", "wordLeft")`.
# See edit_command_modifiers.py to discover the correct string for the modify argument,
# and `edit_command_actions.py` `simple_action_callbacks` to find strings for the action argument.
<user.edit_action> <user.edit_modifier>: user.edit_command(edit_action, edit_modifier)
# Zoom
zoom in: edit.zoom_in()
zoom out: edit.zoom_out()
zoom reset: edit.zoom_reset()
# Searching
find it: edit.find()
next one: edit.find_next()
# Navigation
# The reason for these spoken forms is that "page up" and "page down" are globally defined as keys.
scroll up: edit.page_up()
scroll down: edit.page_down()
# go left, go left left down, go 5 left 2 down
# go word left, go 2 words right
go <user.navigation_step>+: user.perform_navigation_steps(navigation_step_list)
go line start | head: edit.line_start()
go line end | tail: edit.line_end()
go way left:
edit.line_start()
edit.line_start()
go way right: edit.line_end()
go way up: edit.file_start()
go way down: edit.file_end()
go top: edit.file_start()
go bottom: edit.file_end()
go page up: edit.page_up()
go page down: edit.page_down()
# Indentation
indent [more]: edit.indent_more()
(indent less | out dent): edit.indent_less()
# Copy
copy that: edit.copy()
# Cut
cut that: edit.cut()
# Paste
(pace | paste) (that | it): edit.paste()
(pace | paste) enter:
edit.paste()
key(enter)
paste match: edit.paste_match_style()
# Duplication
clone that: edit.selection_clone()
clone line: edit.line_clone()
# Insert new line
new line above: edit.line_insert_up()
new line below | slap: edit.line_insert_down()
# Insert padding with optional symbols
padding: user.insert_between(" ", " ")
(pad | padding) <user.symbol_key>+:
insert(" ")
user.insert_many(symbol_key_list)
insert(" ")
# Undo/redo
undo that: edit.undo()
redo that: edit.redo()
# Save
file save: edit.save()
file save all: edit.save_all()
[go] line mid: user.line_middle()
+191
View File
@@ -0,0 +1,191 @@
from talon import Module, actions, settings
from .edit_command_actions import EditAction, EditSimpleAction, run_action_callback
from .edit_command_modifiers import EditModifier, run_modifier_callback
mod = Module()
# providing some settings for customizing the word and line selection delay
# talon can execute selections must faster than a human
# resulting in unexpected or inconsistent results in applications such as visual studio code
mod.setting(
"edit_command_word_selection_delay",
type=int,
default=75,
desc="Sleep required between word selections",
)
mod.setting(
"edit_command_line_selection_delay",
type=int,
default=75,
desc="Sleep required between line selections",
)
def before_line_up():
actions.edit.up()
actions.edit.line_start()
def after_line_up():
actions.edit.up()
actions.edit.line_end()
def before_line_down():
actions.edit.down()
actions.edit.line_start()
def after_line_down():
actions.edit.down()
actions.edit.line_end()
def select_lines(action, direction, count):
if direction == "lineUp":
selection_callback = actions.edit.extend_line_up
extend_line_callback = actions.edit.extend_line_start
else:
selection_callback = actions.edit.extend_line_down
extend_line_callback = actions.edit.extend_line_end
selection_delay = f"{settings.get('user.edit_command_line_selection_delay')}ms"
for i in range(1, count + 1):
selection_callback()
actions.sleep(selection_delay)
# ensure we take the start/end of the line too!
extend_line_callback()
actions.sleep(selection_delay)
run_action_callback(action)
def select_words(action, direction, count):
if direction == "wordLeft":
selection_callback = actions.edit.extend_word_left
else:
selection_callback = actions.edit.extend_word_right
selection_delay = f"{settings.get('user.edit_command_word_selection_delay')}ms"
for i in range(1, count + 1):
selection_callback()
actions.sleep(selection_delay)
run_action_callback(action)
def word_movement_handler(action, direction, count):
if direction == "wordLeft":
movement_callback = actions.edit.word_left
else:
movement_callback = actions.edit.word_right
selection_delay = f"{settings.get('user.edit_command_word_selection_delay')}ms"
for i in range(1, count + 1):
movement_callback()
actions.sleep(selection_delay)
# in some cases, it is necessary to have some custom handling for timing reasons
custom_callbacks = {
("goAfter", "wordLeft"): word_movement_handler,
("goAfter", "wordRight"): word_movement_handler,
("goBefore", "wordLeft"): word_movement_handler,
("goBefore", "wordRight"): word_movement_handler,
# delete
("delete", "word"): select_words,
("delete", "wordLeft"): select_words,
("delete", "wordRight"): select_words,
("delete", "lineUp"): select_lines,
("delete", "lineDown"): select_lines,
# cut
("cutToClipboard", "word"): select_words,
("cutToClipboard", "wordLeft"): select_words,
("cutToClipboard", "wordRight"): select_words,
("cutToClipboard", "lineUp"): select_lines,
("cutToClipboard", "lineDown"): select_lines,
# copy
("copyToClipboard", "word"): select_words,
("copyToClipboard", "wordLeft"): select_words,
("copyToClipboard", "wordRight"): select_words,
("copyToClipboard", "lineUp"): select_lines,
("copyToClipboard", "lineDown"): select_lines,
# select
("select", "lineUp"): select_lines,
("select", "lineDown"): select_lines,
}
# In other cases there already is a "compound" talon action for a given action and modifier
compound_actions = {
# select
("select", "wordLeft"): actions.edit.extend_word_left,
("select", "wordRight"): actions.edit.extend_word_right,
("select", "left"): actions.edit.extend_left,
("select", "right"): actions.edit.extend_right,
("select", "word"): actions.edit.extend_word_right,
# Go before
("goBefore", "line"): actions.edit.line_start,
("goBefore", "lineUp"): before_line_up,
("goBefore", "lineDown"): before_line_down,
("goBefore", "paragraph"): actions.edit.paragraph_start,
("goBefore", "document"): actions.edit.file_start,
("goBefore", "fileStart"): actions.edit.file_start,
("goBefore", "selection"): actions.edit.left,
("goBefore", "wordLeft"): actions.edit.word_left,
("goBefore", "word"): actions.edit.word_left,
# Go after
("goAfter", "line"): actions.edit.line_end,
("goAfter", "lineUp"): after_line_up,
("goAfter", "lineDown"): after_line_down,
("goAfter", "paragraph"): actions.edit.paragraph_end,
("goAfter", "document"): actions.edit.file_end,
("goAfter", "fileEnd"): actions.edit.file_end,
("goAfter", "selection"): actions.edit.right,
("goAfter", "wordRight"): actions.edit.word_right,
("goAfter", "wordLeft"): actions.edit.word_left,
("goAfter", "word"): actions.edit.word_right,
# Delete
("delete", "left"): actions.edit.delete,
("delete", "right"): actions.user.delete_right,
("delete", "line"): actions.edit.delete_line,
("delete", "paragraph"): actions.edit.delete_paragraph,
# ("delete", "document"): actions.edit.delete_all, # Beta only
("delete", "document"): actions.user.delete_all,
("delete", "selection"): actions.edit.delete,
# Cut to clipboard
("cutToClipboard", "line"): actions.user.cut_line,
("cutToClipboard", "selection"): actions.edit.cut,
# copy
("copyToClipboard", "selection"): actions.edit.copy,
}
@mod.action_class
class Actions:
def edit_command(action: EditAction | str, modifier: EditModifier | str):
"""Perform edit command with associated modifier.
Action and modifier can be dataclasses (formed from utterances via
capture) or str, for use in scripts. Strings should match the action or
modifier types declared here or in edit_command_modifiers.py or
edit_command_actions.py"""
if isinstance(modifier, str):
modifier = EditModifier(modifier)
if isinstance(action, str):
action = EditSimpleAction(action)
key = (action.type, modifier.type)
count = modifier.count
if key in custom_callbacks:
custom_callbacks[key](action, modifier.type, count)
return
elif key in compound_actions:
for i in range(1, count + 1):
compound_actions[key]()
return
run_modifier_callback(modifier)
run_action_callback(action)
+113
View File
@@ -0,0 +1,113 @@
from dataclasses import dataclass
from typing import Callable, Union
from talon import Module, actions
@dataclass
class EditSimpleAction:
""" "Simple" actions are actions that don't require any arguments, only a type (select, copy, delete, etc.)"""
type: str
def __str__(self):
return self.type
@dataclass
class EditInsertAction:
type = "insert"
text: str
def __str__(self):
return self.type
@dataclass
class EditWrapAction:
type = "wrapWithDelimiterPair"
pair: list[str]
def __str__(self):
return self.type
@dataclass
class EditFormatAction:
type = "applyFormatter"
formatters: str
def __str__(self):
return self.type
EditAction = Union[
EditSimpleAction,
EditInsertAction,
EditWrapAction,
EditFormatAction,
]
mod = Module()
mod.list("edit_action", desc="Actions for the edit command")
@mod.capture(rule="{user.edit_action}")
def edit_simple_action(m) -> EditSimpleAction:
return EditSimpleAction(m.edit_action)
@mod.capture(rule="<user.delimiter_pair> wrap")
def edit_wrap_action(m) -> EditWrapAction:
return EditWrapAction(m.delimiter_pair)
@mod.capture(rule="<user.formatters> format")
def edit_format_action(m) -> EditFormatAction:
return EditFormatAction(m.formatters)
@mod.capture(
rule="<user.edit_simple_action> | <user.edit_wrap_action> | <user.edit_format_action>"
)
def edit_action(m) -> EditAction:
return m[0]
simple_action_callbacks: dict[str, Callable] = {
"select": actions.skip,
"goBefore": actions.edit.left,
"goAfter": actions.edit.right,
"copyToClipboard": actions.edit.copy,
"cutToClipboard": actions.edit.cut,
"pasteFromClipboard": actions.edit.paste,
"insertLineAbove": actions.edit.line_insert_up,
"insertLineBelow": actions.edit.line_insert_down,
"insertCopyAfter": actions.edit.selection_clone,
"delete": actions.edit.delete,
}
def run_action_callback(action: EditAction):
action_type = action.type
if action_type in simple_action_callbacks:
callback = simple_action_callbacks[action_type]
callback()
return
match action_type:
case "insert":
assert isinstance(action, EditInsertAction)
actions.insert(action.text)
case "wrapWithDelimiterPair":
assert isinstance(action, EditWrapAction)
return lambda: actions.user.delimiter_pair_wrap_selection(action.pair)
case "applyFormatter":
assert isinstance(action, EditFormatAction)
actions.user.formatters_reformat_selection(action.formatters)
case _:
raise ValueError(f"Unknown edit action: {action_type}")
@@ -0,0 +1,13 @@
list: user.edit_action
-
select: select
go before: goBefore
go after: goAfter
copy: copyToClipboard
cut: cutToClipboard
paste: pasteFromClipboard
paste to: pasteFromClipboard
clear: delete
@@ -0,0 +1,75 @@
from contextlib import suppress
from dataclasses import dataclass
from typing import Callable
from talon import Module, actions
mod = Module()
mod.list("edit_modifier", desc="Modifiers for the edit command")
mod.list(
"edit_modifier_repeatable",
desc="Modifiers for the edit command that are repeatable",
)
@dataclass
class EditModifier:
type: str
count: int = 1
@dataclass
class EditModifierCallback:
modifier: str
callback: Callable
@mod.capture(
rule="({user.edit_modifier}) | ([<number_small>] {user.edit_modifier_repeatable})"
)
def edit_modifier(m) -> EditModifier:
count = 1
with suppress(AttributeError):
count = m.number_small
with suppress(AttributeError):
type = m.edit_modifier
with suppress(AttributeError):
type = m.edit_modifier_repeatable
return EditModifier(type, count=count)
modifiers = [
EditModifierCallback("document", actions.edit.select_all),
EditModifierCallback("paragraph", actions.edit.select_paragraph),
EditModifierCallback("word", actions.edit.extend_word_right),
EditModifierCallback("wordLeft", actions.edit.extend_word_left),
EditModifierCallback("wordRight", actions.edit.extend_word_right),
EditModifierCallback("left", actions.edit.extend_left),
EditModifierCallback("right", actions.edit.extend_right),
EditModifierCallback("lineUp", actions.edit.extend_line_up),
EditModifierCallback("lineDown", actions.edit.extend_line_down),
EditModifierCallback("line", actions.edit.select_line),
EditModifierCallback("lineEnd", actions.edit.extend_line_end),
EditModifierCallback("lineStart", actions.edit.extend_line_start),
EditModifierCallback("fileStart", actions.edit.extend_file_start),
EditModifierCallback("fileEnd", actions.edit.extend_file_end),
EditModifierCallback("selection", actions.skip),
]
modifier_dictionary: dict[str, EditModifierCallback] = {
item.modifier: item for item in modifiers
}
def run_modifier_callback(modifier: EditModifier):
modifier_type = modifier.type
if modifier_type not in modifier_dictionary:
raise ValueError(f"Unknown edit modifier: {modifier_type}")
count = modifier.count
modifier = modifier_dictionary[modifier_type]
for i in range(1, count + 1):
modifier.callback()
@@ -0,0 +1,13 @@
list: user.edit_modifier
-
all: document
paragraph: paragraph
line: line
line start: lineStart
way left: lineStart
line end: lineEnd
way right: lineEnd
file start: fileStart
way up: fileStart
file end: fileEnd
way down: fileEnd
@@ -0,0 +1,9 @@
list: user.edit_modifier_repeatable
-
word: word
word left: wordLeft
word right: wordRight
up: lineUp
down: lineDown
left: left
right: right
+194
View File
@@ -0,0 +1,194 @@
# defines the default edit actions for linux
from talon import Context, actions
ctx = Context()
ctx.matches = r"""
os: linux
"""
@ctx.action_class("edit")
class EditActions:
def copy():
actions.key("ctrl-c")
def cut():
actions.key("ctrl-x")
def delete():
actions.key("backspace")
def delete_line():
actions.edit.select_line()
actions.edit.delete()
# action(edit.delete_paragraph):
# action(edit.delete_sentence):
def delete_word():
actions.edit.select_word()
actions.edit.delete()
def down():
actions.key("down")
# action(edit.extend_again):
# action(edit.extend_column):
def extend_down():
actions.key("shift-down")
def extend_file_end():
actions.key("shift-ctrl-end")
def extend_file_start():
actions.key("shift-ctrl-home")
def extend_left():
actions.key("shift-left")
# action(edit.extend_line):
def extend_line_down():
actions.key("shift-down")
def extend_line_end():
actions.key("shift-end")
def extend_line_start():
actions.key("shift-home")
def extend_line_up():
actions.key("shift-up")
def extend_page_down():
actions.key("shift-pagedown")
def extend_page_up():
actions.key("shift-pageup")
# action(edit.extend_paragraph_end):
# action(edit.extend_paragraph_next()):
# action(edit.extend_paragraph_previous()):
# action(edit.extend_paragraph_start()):
def extend_right():
actions.key("shift-right")
# action(edit.extend_sentence_end):
# action(edit.extend_sentence_next):
# action(edit.extend_sentence_previous):
# action(edit.extend_sentence_start):
def extend_up():
actions.key("shift-up")
def extend_word_left():
actions.key("ctrl-shift-left")
def extend_word_right():
actions.key("ctrl-shift-right")
def file_end():
actions.key("ctrl-end")
def file_start():
actions.key("ctrl-home")
def find(text: str = None):
actions.key("ctrl-f")
if text:
actions.insert(text)
def find_previous():
actions.key("shift-f3")
def find_next():
actions.key("f3")
def indent_less():
actions.key("home delete")
def indent_more():
actions.key("home tab")
# action(edit.jump_column(n: int)
# action(edit.jump_line(n: int)
def left():
actions.key("left")
def line_down():
actions.key("down home")
def line_end():
actions.key("end")
def line_insert_up():
actions.key("home enter up")
def line_start():
actions.key("home")
def line_up():
actions.key("up home")
# action(edit.move_again):
def page_down():
actions.key("pagedown")
def page_up():
actions.key("pageup")
# action(edit.paragraph_end):
# action(edit.paragraph_next):
# action(edit.paragraph_previous):
# action(edit.paragraph_start):
def paste():
actions.key("ctrl-v")
# action(paste_match_style):
def print():
actions.key("ctrl-p")
def redo():
actions.key("ctrl-y")
def right():
actions.key("right")
def save():
actions.key("ctrl-s")
def save_all():
actions.key("ctrl-shift-s")
def select_all():
actions.key("ctrl-a")
def select_line(n: int = None):
if n is not None:
actions.edit.jump_line(n)
actions.key("end shift-home")
# action(edit.select_lines(a: int, b: int)):
def select_none():
actions.key("right")
# action(edit.select_paragraph):
# action(edit.select_sentence):
def undo():
actions.key("ctrl-z")
def up():
actions.key("up")
def word_left():
actions.key("ctrl-left")
def word_right():
actions.key("ctrl-right")
def zoom_in():
actions.key("ctrl-+")
def zoom_out():
actions.key("ctrl--")
def zoom_reset():
actions.key("ctrl-0")
+194
View File
@@ -0,0 +1,194 @@
from talon import Context, actions, clip
ctx = Context()
ctx.matches = r"""
os: mac
"""
@ctx.action_class("edit")
class EditActions:
def copy():
actions.key("cmd-c")
def cut():
actions.key("cmd-x")
def delete():
actions.key("backspace")
def delete_line():
actions.edit.select_line()
actions.edit.delete()
# action(edit.delete_paragraph):
# action(edit.delete_sentence):
def delete_word():
actions.edit.select_word()
actions.edit.delete()
def down():
actions.key("down")
# action(edit.extend_again):
# action(edit.extend_column):
def extend_down():
actions.key("shift-down")
def extend_file_end():
actions.key("cmd-shift-down")
def extend_file_start():
actions.key("cmd-shift-up")
def extend_left():
actions.key("shift-left")
# action(edit.extend_line):
def extend_line_down():
actions.key("shift-down")
def extend_line_end():
actions.key("cmd-shift-right")
def extend_line_start():
actions.key("cmd-shift-left")
def extend_line_up():
actions.key("shift-up")
def extend_page_down():
actions.key("cmd-shift-pagedown")
def extend_page_up():
actions.key("cmd-shift-pageup")
# action(edit.extend_paragraph_end):
# action(edit.extend_paragraph_next()):
# action(edit.extend_paragraph_previous()):
# action(edit.extend_paragraph_start()):
def extend_right():
actions.key("shift-right")
# action(edit.extend_sentence_end):
# action(edit.extend_sentence_next):
# action(edit.extend_sentence_previous):
# action(edit.extend_sentence_start):
def extend_up():
actions.key("shift-up")
def extend_word_left():
actions.key("shift-alt-left")
def extend_word_right():
actions.key("shift-alt-right")
def file_end():
actions.key("cmd-down")
def file_start():
actions.key("cmd-up")
def find(text: str = None):
if text is not None:
clip.set_text(text, mode="find")
actions.key("cmd-f")
def find_next():
actions.key("cmd-g")
def find_previous():
actions.key("cmd-shift-g")
def indent_less():
actions.key("cmd-left delete")
def indent_more():
actions.key("cmd-left tab")
# action(edit.jump_column(n: int)
# action(edit.jump_line(n: int)
def left():
actions.key("left")
def line_down():
actions.key("down home")
def line_end():
actions.key("cmd-right")
def line_insert_up():
actions.key("cmd-left enter up")
def line_start():
actions.key("cmd-left")
def line_up():
actions.key("up cmd-left")
# action(edit.move_again):
def page_down():
actions.key("pagedown")
def page_up():
actions.key("pageup")
# action(edit.paragraph_end):
# action(edit.paragraph_next):
# action(edit.paragraph_previous):
# action(edit.paragraph_start):
def paste():
actions.key("cmd-v")
def paste_match_style():
actions.key("cmd-alt-shift-v")
def print():
actions.key("cmd-p")
def redo():
actions.key("cmd-shift-z")
def right():
actions.key("right")
def save():
actions.key("cmd-s")
def save_all():
actions.key("cmd-alt-s")
def select_all():
actions.key("cmd-a")
def select_line(n: int = None):
if n is not None:
actions.edit.jump_line(n)
actions.key("cmd-right cmd-shift-left")
# action(edit.select_lines(a: int, b: int)):
def select_none():
actions.key("right")
# action(edit.select_paragraph):
# action(edit.select_sentence):
def undo():
actions.key("cmd-z")
def up():
actions.key("up")
def word_left():
actions.key("alt-left")
def word_right():
actions.key("alt-right")
def zoom_in():
actions.key("cmd-=")
def zoom_out():
actions.key("cmd--")
def zoom_reset():
actions.key("cmd-0")
@@ -0,0 +1,65 @@
from contextlib import suppress
from dataclasses import dataclass
from typing import Callable, Literal
from talon import Module, actions, settings
@dataclass
class NavigationStep:
modifier: Literal[
"wordLeft", "wordRight", "word", "left", "right", "lineUp", "lineDown"
]
count: int
mod = Module()
@mod.capture(rule="[<number_small>] {user.edit_modifier_repeatable}")
def navigation_step(m) -> NavigationStep:
count = 1
modifier = m.edit_modifier_repeatable
with suppress(AttributeError):
count = m.number_small
return NavigationStep(
modifier=modifier,
count=count,
)
@mod.action_class
class Actions:
def perform_navigation_steps(steps: list[NavigationStep]):
"""Navigate by a series of steps"""
for step in steps:
match step.modifier:
case "wordLeft":
repeat_action(actions.edit.word_left, step.count, True)
case "wordRight":
repeat_action(actions.edit.word_right, step.count, True)
case "word":
repeat_action(actions.edit.word_right, step.count, True)
case "left":
repeat_action(actions.edit.left, step.count)
case "right":
repeat_action(actions.edit.right, step.count)
case "lineUp":
repeat_action(actions.edit.up, step.count)
case "lineDown":
repeat_action(actions.edit.down, step.count)
def repeat_action(action: Callable, count: int, delay: bool = False):
delay_string = None
if delay:
delay_string = f"{settings.get('user.edit_command_word_selection_delay')}ms"
for _ in range(count):
action()
if delay_string:
actions.sleep(delay_string)
+115
View File
@@ -0,0 +1,115 @@
from talon import Context, Module, actions
ctx = Context()
mod = Module()
@ctx.action_class("edit")
class EditActions:
def paragraph_start():
if extend_paragraph_start_with_success():
actions.edit.left()
def paragraph_end():
if extend_paragraph_end_with_success():
actions.edit.right()
def select_paragraph():
if is_line_empty():
return
# Search for start of paragraph
actions.edit.extend_paragraph_start()
actions.edit.left()
# Extend to end of paragraph
actions.edit.extend_paragraph_end()
def extend_paragraph_start():
# The reason for the wrapper function is a difference in function signature.
# The Talon action has no return value and the below function returns a boolean with success state.
extend_paragraph_start_with_success()
def extend_paragraph_end():
extend_paragraph_end_with_success()
def delete_paragraph():
actions.edit.select_paragraph()
# Remove selection
actions.edit.delete()
# Remove the empty line containing the cursor
actions.edit.delete()
# Remove leading or trailing empty line
actions.edit.delete_line()
@mod.action_class
class Actions:
def cut_paragraph():
"""Cut paragraph under the cursor"""
actions.edit.select_paragraph()
actions.edit.cut()
def copy_paragraph():
"""Copy paragraph under the cursor"""
actions.edit.select_paragraph()
actions.edit.copy()
def paste_paragraph():
"""Paste to paragraph under the cursor"""
actions.edit.select_paragraph()
actions.edit.paste()
def is_line_empty() -> bool:
"""Check if the current line is empty. Return True if empty."""
actions.edit.extend_line_start()
text = actions.edit.selected_text().strip()
if text:
actions.edit.right()
return False
actions.edit.extend_line_end()
text = actions.edit.selected_text().strip()
if text:
actions.edit.left()
return False
return True
def extend_paragraph_start_with_success() -> bool:
"""Extend selection to the start of the paragraph. Return True if successful."""
actions.edit.extend_line_start()
text = actions.edit.selected_text()
length = len(text)
while True:
actions.edit.extend_up()
actions.edit.extend_line_start()
text = actions.edit.selected_text()
new_length = len(text)
if new_length == length:
break
line = text[: new_length - length].strip()
if not line:
actions.edit.extend_down()
break
length = new_length
return text.strip() != ""
def extend_paragraph_end_with_success() -> bool:
"""Extend selection to the end of the paragraph. Return True if successful."""
actions.edit.extend_line_end()
text = actions.edit.selected_text()
length = len(text)
while True:
actions.edit.extend_down()
actions.edit.extend_line_end()
text = actions.edit.selected_text()
new_length = len(text)
if new_length == length:
break
line = text[length:].strip()
if not line:
actions.edit.extend_line_start()
actions.edit.extend_left()
break
length = new_length
return text.strip() != ""
+194
View File
@@ -0,0 +1,194 @@
# defines the default edit actions for windows
from talon import Context, actions
ctx = Context()
ctx.matches = r"""
os: windows
"""
@ctx.action_class("edit")
class EditActions:
def copy():
actions.key("ctrl-c")
def cut():
actions.key("ctrl-x")
def delete():
actions.key("backspace")
def delete_line():
actions.edit.select_line()
actions.edit.delete()
# action(edit.delete_paragraph):
# action(edit.delete_sentence):
def delete_word():
actions.edit.select_word()
actions.edit.delete()
def down():
actions.key("down")
# action(edit.extend_again):
# action(edit.extend_column):
def extend_down():
actions.key("shift-down")
def extend_file_end():
actions.key("shift-ctrl-end")
def extend_file_start():
actions.key("shift-ctrl-home")
def extend_left():
actions.key("shift-left")
# action(edit.extend_line):
def extend_line_down():
actions.key("shift-down")
def extend_line_end():
actions.key("shift-end")
def extend_line_start():
actions.key("shift-home")
def extend_line_up():
actions.key("shift-up")
def extend_page_down():
actions.key("shift-pagedown")
def extend_page_up():
actions.key("shift-pageup")
# action(edit.extend_paragraph_end):
# action(edit.extend_paragraph_next()):
# action(edit.extend_paragraph_previous()):
# action(edit.extend_paragraph_start()):
def extend_right():
actions.key("shift-right")
# action(edit.extend_sentence_end):
# action(edit.extend_sentence_next):
# action(edit.extend_sentence_previous):
# action(edit.extend_sentence_start):
def extend_up():
actions.key("shift-up")
def extend_word_left():
actions.key("ctrl-shift-left")
def extend_word_right():
actions.key("ctrl-shift-right")
def file_end():
actions.key("ctrl-end")
def file_start():
actions.key("ctrl-home")
def find(text: str = None):
actions.key("ctrl-f")
if text:
actions.insert(text)
def find_previous():
actions.key("shift-f3")
def find_next():
actions.key("f3")
def indent_less():
actions.key("home delete")
def indent_more():
actions.key("home tab")
# action(edit.jump_column(n: int)
# action(edit.jump_line(n: int)
def left():
actions.key("left")
def line_down():
actions.key("down home")
def line_end():
actions.key("end")
def line_insert_up():
actions.key("home enter up")
def line_start():
actions.key("home")
def line_up():
actions.key("up home")
# action(edit.move_again):
def page_down():
actions.key("pagedown")
def page_up():
actions.key("pageup")
# action(edit.paragraph_end):
# action(edit.paragraph_next):
# action(edit.paragraph_previous):
# action(edit.paragraph_start):
def paste():
actions.key("ctrl-v")
# action(paste_match_style):
def print():
actions.key("ctrl-p")
def redo():
actions.key("ctrl-y")
def right():
actions.key("right")
def save():
actions.key("ctrl-s")
def save_all():
actions.key("ctrl-shift-s")
def select_all():
actions.key("ctrl-a")
def select_line(n: int = None):
if n is not None:
actions.edit.jump_line(n)
actions.key("end shift-home")
# action(edit.select_lines(a: int, b: int)):
def select_none():
actions.key("right")
# action(edit.select_paragraph):
# action(edit.select_sentence):
def undo():
actions.key("ctrl-z")
def up():
actions.key("up")
def word_left():
actions.key("ctrl-left")
def word_right():
actions.key("ctrl-right")
def zoom_in():
actions.key("ctrl-+")
def zoom_out():
actions.key("ctrl--")
def zoom_reset():
actions.key("ctrl-0")
+12
View File
@@ -0,0 +1,12 @@
from talon import Module, actions
mod = Module()
@mod.action_class
class module_actions:
def insert_between(before: str, after: str):
"""Insert `before + after`, leaving cursor between `before` and `after`. Not entirely reliable if `after` contains newlines."""
actions.insert(f"{before}{after}")
for _ in after:
actions.edit.left()
@@ -0,0 +1,80 @@
import os
import subprocess
from pathlib import Path
from talon import Context, Module, app
# Path to community root directory
REPO_DIR = Path(__file__).parent.parent.parent
mod = Module()
mod.list(
"edit_text_file",
desc="Paths to frequently edited files (Talon list, CSV, etc.)",
)
ctx_win, ctx_linux, ctx_mac = Context(), Context(), Context()
ctx_win.matches = "os: windows"
ctx_linux.matches = "os: linux"
ctx_mac.matches = "os: mac"
@mod.action_class
class Actions:
def edit_text_file(file: str):
"""Tries to open a file in the user's preferred text editor."""
@ctx_win.action_class("user")
class WinActions:
def edit_text_file(file: str):
path = get_full_path(file)
# If there's no applications registered that can open the given type
# of file, 'edit' will fail, but 'open' always gives the user a
# choice between applications.
try:
os.startfile(path, "edit")
except OSError:
os.startfile(path, "open")
@ctx_mac.action_class("user")
class MacActions:
def edit_text_file(file: str):
path = get_full_path(file)
# -t means try to open in a text editor.
open_with_subprocess(path, ["/usr/bin/open", "-t", path.expanduser().resolve()])
@ctx_linux.action_class("user")
class LinuxActions:
def edit_text_file(file: str):
path = get_full_path(file)
# we use xdg-open for this even though it might not open a text
# editor. we could use $EDITOR, but that might be something that
# requires a terminal (eg nano, vi).
try:
open_with_subprocess(path, ["xdg-open", path.expanduser().resolve()])
except FileNotFoundError:
app.notify(f"xdg-open missing. Could not open file for editing: {path}")
raise
# Helper for linux and mac.
def open_with_subprocess(path: Path, args: list[str | Path]):
"""Tries to open a file using the given subprocess arguments."""
try:
subprocess.run(args, timeout=0.5, check=True)
except subprocess.TimeoutExpired:
app.notify(f"Timeout trying to open file for editing: {path}")
raise
except subprocess.CalledProcessError:
app.notify(f"Could not open file for editing: {path}")
raise
def get_full_path(file: str) -> Path:
path = Path(file)
if not path.is_absolute():
path = REPO_DIR / path
return path.resolve()
@@ -0,0 +1,4 @@
customize {user.edit_text_file}:
user.edit_text_file(edit_text_file)
sleep(500ms)
edit.file_end()
@@ -0,0 +1,17 @@
list: user.edit_text_file
-
additional words: core/vocabulary/vocabulary.talon-list
vocabulary: core/vocabulary/vocabulary.talon-list
alphabet: core/keys/letter.talon-list
homophones: core/homophones/homophones.csv
search engines: core/websites_and_search_engines/search_engine.talon-list
websites: core/websites_and_search_engines/website.talon-list
unix utilities: tags/terminal/unix_utility.talon-list
abbreviations: settings/abbreviations.csv
file extensions: settings/file_extensions.csv
words to replace: settings/words_to_replace.csv
contacts json: private/contacts.json
contacts csv: private/contacts.csv
@@ -0,0 +1,69 @@
from talon import Context, Module
from ..user_settings import track_csv_list
mod = Module()
mod.list("file_extension", desc="A file extension, such as .py")
_file_extensions_defaults = {
"dot pie": ".py",
"dot elixir": ".ex",
"dot talon": ".talon",
"dot talon list": ".talon-list",
"dot mark down": ".md",
"dot shell": ".sh",
"dot vim": ".vim",
"dot see": ".c",
"dot see sharp": ".cs",
"dot com": ".com",
"dot net": ".net",
"dot org": ".org",
"dot us": ".us",
"dot U S": ".us",
"dot co dot UK": ".co.uk",
"dot exe": ".exe",
"dot bin": ".bin",
"dot bend": ".bin",
"dot jason": ".json",
"dot jay son": ".json",
"dot J S": ".js",
"dot java script": ".js",
"dot TS": ".ts",
"dot type script": ".ts",
"dot csv": ".csv",
"totssv": ".csv",
"tot csv": ".csv",
"dot cassie": ".csv",
"dot text": ".txt",
"dot julia": ".jl",
"dot J L": ".jl",
"dot html": ".html",
"dot css": ".css",
"dot sass": ".sass",
"dot svg": ".svg",
"dot png": ".png",
"dot wave": ".wav",
"dot flack": ".flac",
"dot doc": ".doc",
"dot doc x": ".docx",
"dot pdf": ".pdf",
"dot tar": ".tar",
"dot g z": ".gz",
"dot g zip": ".gzip",
"dot zip": ".zip",
"dot toml": ".toml",
"dot java": ".java",
"dot class": ".class",
"dot log": ".log",
}
ctx = Context()
@track_csv_list(
"file_extensions.csv",
headers=("File extension", "Name"),
default=_file_extensions_defaults,
)
def on_update(values):
ctx.lists["self.file_extension"] = values
@@ -0,0 +1 @@
{user.file_extension}: "{file_extension}"
@@ -0,0 +1,19 @@
list: user.code_formatter
-
all cap: ALL_CAPS
all down: ALL_LOWERCASE
camel: PRIVATE_CAMEL_CASE
dotted: DOT_SEPARATED
list: COMMA_SEPARATED
dub string: DOUBLE_QUOTED_STRING
dunder: DOUBLE_UNDERSCORE
hammer: PUBLIC_CAMEL_CASE
kebab: DASH_SEPARATED
packed: DOUBLE_COLON_SEPARATED
padded: SPACE_SURROUNDED_STRING
slasher: ALL_SLASHES
conga: SLASH_SEPARATED
smash: NO_SPACES
snake: SNAKE_CASE
string: SINGLE_QUOTED_STRING
constant: ALL_CAPS,SNAKE_CASE
+482
View File
@@ -0,0 +1,482 @@
import logging
import re
from abc import ABC, abstractmethod
from typing import Callable, Optional, Union
from talon import Context, Module, actions, app, registry
from talon.grammar import Phrase
class Formatter(ABC):
def __init__(self, id: str):
self.id = id
@abstractmethod
def format(self, text: str) -> str:
pass
@abstractmethod
def unformat(self, text: str) -> str:
pass
class CustomFormatter(Formatter):
def __init__(
self,
id: str,
format: Callable[[str], str],
unformat: Optional[Callable[[str], str]] = None,
):
super().__init__(id)
self._format = format
self._unformat = unformat
def format(self, text: str) -> str:
return self._format(text)
def unformat(self, text: str) -> str:
if self._unformat:
return self._unformat(text)
return text
class CodeFormatter(Formatter):
def __init__(
self,
id: str,
delimiter: str,
format_first: Callable[[str], str],
format_rest: Callable[[str], str],
):
super().__init__(id)
self._delimiter = delimiter
self._format_first = format_first
self._format_rest = format_rest
def format(self, text: str) -> str:
return self._format_delim(
text, self._delimiter, self._format_first, self._format_rest
)
def unformat(self, text: str) -> str:
return remove_code_formatting(text)
def _format_delim(
self,
text: str,
delimiter: str,
format_first: Callable[[str], str],
format_rest: Callable[[str], str],
):
# Strip anything that is not alpha-num, whitespace, dot or comma
text = re.sub(r"[^\w\d\s.,]+", "", text)
# Split on anything that is not alpha-num
words = re.split(r"([^\w\d]+)", text)
groups = []
group = []
first = True
for word in words:
if word.isspace():
continue
# Word is number
if word.isnumeric():
first = True
# Word is symbol
elif not word.isalpha():
groups.append(delimiter.join(group))
word = word.strip()
if word != ".":
word += " "
first = True
groups.append(word)
group = []
continue
elif first:
first = False
if format_first:
word = format_first(word)
elif format_rest:
word = format_rest(word)
group.append(word)
groups.append(delimiter.join(group))
return "".join(groups)
class TitleFormatter(Formatter):
_words_to_keep_lowercase = (
"a an and as at but by en for if in nor of on or per the to v via vs".split()
)
def format(self, text: str) -> str:
words = [x for x in re.split(r"(\s+)", text) if x]
words = self._title_case_words(words)
return "".join(words)
def unformat(self, text: str) -> str:
return unformat_upper(text)
def _title_case_word(
self, word: str, is_first: bool, is_last: bool, following_symbol: bool
) -> str:
if not word.islower() or (
word in self._words_to_keep_lowercase
and not is_first
and not is_last
and not following_symbol
):
return word
if "-" in word:
words = word.split("-")
words = self._title_case_words(words)
return "-".join(words)
return word.capitalize()
def _title_case_words(self, words: list[str]) -> list[str]:
following_symbol = False
for i, word in enumerate(words):
if word.isspace():
continue
is_first = i == 0
is_last = i == len(words) - 1
words[i] = self._title_case_word(word, is_first, is_last, following_symbol)
following_symbol = not word[-1].isalnum()
return words
class CapitalizeFormatter(Formatter):
def format(self, text: str) -> str:
return re.sub(r"^\s*\S+", lambda m: capitalize_first(m.group()), text)
def unformat(self, text: str) -> str:
return unformat_upper(text)
class SentenceFormatter(Formatter):
def format(self, text: str) -> str:
"""Capitalize first word if it's already all lower case"""
words = [x for x in re.split(r"(\s+)", text) if x]
for i in range(len(words)):
word = words[i]
if word.isspace():
continue
if word.islower():
words[i] = word.capitalize()
break
return "".join(words)
def unformat(self, text: str) -> str:
return unformat_upper(text)
def capitalize_first(text: str) -> str:
stripped = text.lstrip()
prefix = text[: len(text) - len(stripped)]
return prefix + stripped[:1].upper() + stripped[1:]
def capitalize(text: str) -> str:
return text.capitalize()
def lower(text: str) -> str:
return text.lower()
def unformat_upper(text: str) -> str:
return text.lower() if text.isupper() else text
def remove_code_formatting(text: str) -> str:
"""Remove format from text"""
# Split on delimiters.
result = re.sub(r"[-_.:/]+", " ", text)
# Split camel case. Including numbers
result = de_camel(result)
# Delimiter/camel case successfully split. Lower case to restore "original" text.
if text != result:
return result.lower()
return text
def de_camel(text: str) -> str:
"""Replacing camelCase boundaries with blank space"""
Ll = "a-zåäö"
Lu = "A-ZÅÄÖ"
L = f"{Ll}{Lu}"
low_to_upper = rf"(?<=[{Ll}])(?=[{Lu}])" # camel|Case
upper_to_last_upper = rf"(?<=[L{Lu}])(?=[{Lu}][{Ll}])" # IP|Address
letter_to_digit = rf"(?<=[{L}])(?=[\d])" # version|10
digit_to_letter = rf"(?<=[\d])(?=[{L}])" # 2|x
return re.sub(
rf"{low_to_upper}|{upper_to_last_upper}|{letter_to_digit}|{digit_to_letter}",
" ",
text,
)
formatter_list = [
CustomFormatter("NOOP", lambda text: text),
CustomFormatter("TRAILING_SPACE", lambda text: f"{text} "),
CustomFormatter("DOUBLE_QUOTED_STRING", lambda text: f'"{text}"'),
CustomFormatter("SINGLE_QUOTED_STRING", lambda text: f"'{text}'"),
CustomFormatter("SPACE_SURROUNDED_STRING", lambda text: f" {text} "),
CustomFormatter("ALL_CAPS", lambda text: text.upper()),
CustomFormatter("ALL_LOWERCASE", lambda text: text.lower()),
CustomFormatter("COMMA_SEPARATED", lambda text: re.sub(r"\s+", ", ", text)),
CustomFormatter("REMOVE_FORMATTING", remove_code_formatting),
TitleFormatter("CAPITALIZE_ALL_WORDS"),
# The sentence formatter being called `CAPITALIZE_FIRST_WORD` is a bit of a misnomer, but kept for backward compatibility.
SentenceFormatter("CAPITALIZE_FIRST_WORD"),
# This is the formatter that actually just capitalizes the first word
CapitalizeFormatter("CAPITALIZE"),
CodeFormatter("NO_SPACES", "", lower, lower),
CodeFormatter("PRIVATE_CAMEL_CASE", "", lower, capitalize),
CodeFormatter("PUBLIC_CAMEL_CASE", "", capitalize, capitalize),
CodeFormatter("SNAKE_CASE", "_", lower, lower),
CodeFormatter("DASH_SEPARATED", "-", lower, lower),
CodeFormatter("DOT_SEPARATED", ".", lower, lower),
CodeFormatter("SLASH_SEPARATED", "/", lower, lower),
CodeFormatter("ALL_SLASHES", "/", lambda text: f"/{text.lower()}", lower),
CodeFormatter("DOUBLE_UNDERSCORE", "__", lower, lower),
CodeFormatter("DOUBLE_COLON_SEPARATED", "::", lower, lower),
]
formatters_dict = {f.id: f for f in formatter_list}
mod = Module()
mod.list("reformatter", desc="list of all reformatters")
mod.list("code_formatter", desc="list of formatters typically applied to code")
mod.list(
"prose_formatter", desc="list of prose formatters (words to start dictating prose)"
)
mod.list("word_formatter", "List of word formatters")
# The last phrase spoken, without & with formatting. Used for reformatting.
last_phrase = ""
last_phrase_formatted = ""
def format_phrase(
m: Union[str, Phrase], formatters: str, unformat: bool = False
) -> str:
global last_phrase, last_phrase_formatted
last_phrase = m
if isinstance(m, str):
text = m
else:
text = " ".join(actions.dictate.replace_words(actions.dictate.parse_words(m)))
result = last_phrase_formatted = format_text_without_adding_to_history(
text, formatters, unformat
)
actions.user.add_phrase_to_history(result)
# Arguably, we shouldn't be dealing with history here, but somewhere later
# down the line. But we have a bunch of code that relies on doing it this
# way and I don't feel like rewriting it just now. -rntz, 2020-11-04
return result
def format_text_without_adding_to_history(
text: str, formatters: str, unformat: bool = False
) -> str:
"""Formats a text according to formatters. formatters is a comma-separated string of formatters (e.g. 'TITLE_CASE,SNAKE_CASE')"""
if not text:
return text
text, pre, post = shrink_to_string_inside(text)
for i, formatter_name in enumerate(reversed(formatters.split(","))):
formatter = formatters_dict[formatter_name]
if unformat and i == 0:
text = formatter.unformat(text)
text = formatter.format(text)
return f"{pre}{text}{post}"
string_delimiters = [
['"""', '"""'],
['"', '"'],
["'", "'"],
]
def shrink_to_string_inside(text: str) -> tuple[str, str, str]:
for [left, right] in string_delimiters:
if text.startswith(left) and text.endswith(right):
return text[len(left) : -len(right)], left, right
return text, "", ""
@mod.capture(
rule="({user.code_formatter} | {user.prose_formatter} | {user.reformatter})+"
)
def formatters(m) -> str:
"Returns a comma-separated string of formatters e.g. 'SNAKE,DUBSTRING'"
return ",".join(list(m))
@mod.capture(rule="{self.code_formatter}+")
def code_formatters(m) -> str:
"Returns a comma-separated string of code formatters e.g. 'SNAKE,DUBSTRING'"
return ",".join(m.code_formatter_list)
@mod.capture(
rule="<self.formatters> <user.text> (<user.text> | <user.formatter_immune>)*"
)
def format_text(m) -> str:
"""Formats text and returns a string"""
out = ""
formatters = m[0]
for chunk in m[1:]:
if isinstance(chunk, ImmuneString):
out += chunk.string
else:
out += format_phrase(chunk, formatters)
return out
@mod.capture(rule="<user.code_formatters> <user.text>")
def format_code(m) -> str:
"""Formats code and returns a string"""
return format_phrase(m.text, m.code_formatters)
class ImmuneString:
"""Wrapper that makes a string immune from formatting."""
def __init__(self, string):
self.string = string
@mod.capture(
# Add anything else into this that you want to have inserted when
# using a prose formatter.
rule="(<user.symbol_key> | (numb | numeral) <number>)"
)
def formatter_immune(m) -> ImmuneString:
"""Symbols and numbers that can be interspersed into a prose formatter
(i.e., not dictated immediately after the name of the formatter)
They will be inserted directly, without being formatted.
"""
if hasattr(m, "number"):
value = m.number
else:
value = m[0]
return ImmuneString(str(value))
def get_formatters_and_prose_formatters(
include_reformatters: bool,
) -> tuple[dict[str, str], dict[str, str]]:
"""Returns dictionary of non-word formatters and a dictionary of all prose formatters"""
formatters = {}
prose_formatters = {}
formatters.update(
actions.user.talon_get_active_registry_list("user.code_formatter")
)
formatters.update(
actions.user.talon_get_active_registry_list("user.prose_formatter")
)
if include_reformatters:
formatters.update(
actions.user.talon_get_active_registry_list("user.reformatter")
)
prose_formatters.update(
actions.user.talon_get_active_registry_list("user.prose_formatter")
)
return formatters, prose_formatters
@mod.action_class
class Actions:
def formatted_text(phrase: Union[str, Phrase], formatters: str) -> str:
"""Formats a phrase according to formatters. formatters is a comma-separated string of formatters (e.g. 'CAPITALIZE_ALL_WORDS,DOUBLE_QUOTED_STRING')"""
return format_phrase(phrase, formatters)
def insert_formatted(phrase: Union[str, Phrase], formatters: str):
"""Inserts a phrase formatted according to formatters. Formatters is a comma separated list of formatters (e.g. 'CAPITALIZE_ALL_WORDS,DOUBLE_QUOTED_STRING')"""
actions.insert(format_phrase(phrase, formatters))
def insert_with_history(text: str):
"""Inserts some text, remembering it in the phrase history."""
actions.user.deprecate_action("2022-12-11", "user.insert_with_history")
actions.user.add_phrase_to_history(text)
actions.insert(text)
def formatters_reformat_last(formatters: str):
"""Clears and reformats last formatted phrase"""
global last_phrase, last_phrase_formatted
if actions.user.get_last_phrase() != last_phrase_formatted:
# The last thing we inserted isn't the same as the last thing we
# formatted, so abort.
logging.warning(
"formatters_reformat_last(): Last phrase wasn't a formatter!"
)
return
actions.user.clear_last_phrase()
actions.user.insert_formatted(last_phrase, formatters)
def reformat_text(text: str, formatters: str) -> str:
"""Re-formats <text> as <formatters>"""
return format_phrase(text, formatters, True)
def formatters_reformat_selection(formatters: str):
"""Reformats the current selection as <formatters>"""
selected = actions.edit.selected_text()
if not selected:
app.notify("Asked to reformat selection, but nothing selected!")
return
# Delete separately for compatibility with programs that don't overwrite
# selected text (e.g. Emacs)
actions.edit.delete()
text = actions.user.reformat_text(selected, formatters)
actions.insert(text)
def get_formatters_words() -> dict:
"""Returns words currently used as formatters, and a demonstration string using those formatters"""
formatters_help_demo = {}
formatters, prose_formatters = get_formatters_and_prose_formatters(
include_reformatters=False
)
prose_formatter_names = prose_formatters.keys()
for phrase in sorted(formatters):
name = formatters[phrase]
demo = format_text_without_adding_to_history("one two three", name)
if phrase in prose_formatter_names:
phrase += " *"
formatters_help_demo[phrase] = demo
return formatters_help_demo
def get_reformatters_words() -> dict:
"""Returns words currently used as re-formatters, and a demonstration string using those re-formatters"""
formatters_help_demo = {}
formatters, prose_formatters = get_formatters_and_prose_formatters(
include_reformatters=True
)
prose_formatter_names = prose_formatters.keys()
for phrase in sorted(formatters):
name = formatters[phrase]
demo = format_text_without_adding_to_history("one_two_three", name, True)
if phrase in prose_formatter_names:
phrase += " *"
formatters_help_demo[phrase] = demo
return formatters_help_demo
def insert_many(strings: list[str]) -> None:
"""Insert a list of strings, sequentially."""
for string in strings:
actions.insert(string)
@@ -0,0 +1,6 @@
list: user.prose_formatter
-
say: NOOP
speak: NOOP
sentence: CAPITALIZE_FIRST_WORD
title: CAPITALIZE_ALL_WORDS
@@ -0,0 +1,4 @@
list: user.reformatter
-
cap: CAPITALIZE
unformat: REMOVE_FORMATTING
@@ -0,0 +1,7 @@
list: user.word_formatter
-
word: NOOP
trot: TRAILING_SPACE
proud: CAPITALIZE_FIRST_WORD
leap: TRAILING_SPACE,CAPITALIZE_FIRST_WORD
+890
View File
@@ -0,0 +1,890 @@
import itertools
import math
import re
from collections import defaultdict
from itertools import islice
from textwrap import wrap
from typing import Any, Iterable, Tuple
from talon import Context, Module, actions, imgui, registry, settings
mod = Module()
mod.list("help_contexts", desc="list of available contexts")
mod.tag("help_open", "tag for commands that are available only when help is visible")
mod.setting(
"help_max_contexts_per_page",
type=int,
default=20,
desc="Max contexts to display per page in help",
)
mod.setting(
"help_max_command_lines_per_page",
type=int,
default=50,
desc="Max lines of command to display per page in help",
)
mod.setting(
"help_sort_contexts_by_specificity",
type=bool,
default=True,
desc="If true contexts are sorted by specificity before alphabetically. If false, contexts are just sorted alphabetically.",
)
ctx = Context()
# context name -> commands
context_command_map = {}
# rule word -> Set[(context name, rule)]
rule_word_map: dict[str, set[tuple[str, str]]] = defaultdict(set)
search_phrase = None
# context name -> actual context
context_map = {}
current_context_page = 1
# sorted list of diplay names
sorted_display_list = []
# display names -> context name
display_name_to_context_name_map = {}
selected_context = None
selected_context_page = 1
total_page_count = 1
cached_active_contexts_list = []
live_update = True
show_enabled_contexts_only = False
selected_list = None
current_list_page = 1
def update_title():
global live_update
global show_enabled_contexts_only
if live_update:
if gui_context_help.showing:
if selected_context is None:
refresh_context_command_map(show_enabled_contexts_only)
else:
update_active_contexts_cache(registry.last_active_contexts)
if gui_operators.showing:
update_operators_text()
@imgui.open(y=0)
def gui_formatters(gui: imgui.GUI):
global formatters_words
if formatters_reformat:
gui.text("re-formatters help")
else:
gui.text("formatters help")
gui.line()
for key, val in formatters_words.items():
gui.text(f"{val}: {key}")
gui.spacer()
gui.text("* prose formatter")
gui.spacer()
if gui.button("Help close"):
gui_formatters.hide()
def update_operators_text():
"""For operators implemented for the active language, map spoken forms including operator prefix to
the operator text for operators implemented as text insertion
or an asterisk for operators implemented as a function call.
"""
global operators_text, total_page_count
try:
operators = actions.user.code_get_operators()
# Associate the names of the operator lists with the corresponding prefix
op_list_names = ["array", "assignment", "bitwise", "lambda", "math", "pointer"]
names_with_prefix = [(name, "op") for name in op_list_names]
names_with_prefix.append(("math_comparison", "is"))
# Fill in the list by iterating over the operator lists
operators_text = []
has_operator_without_text_implementation = False
for name, prefix in names_with_prefix:
operators_list = actions.user.talon_get_active_registry_list(
"user.code_operators_" + name
)
has_added_first_list_item = False
for operator_name, operator_text in sorted(operators_list.items()):
# Only display operators implemented for the active language
if operator_text in operators:
# If the operator is implemented as text insertion,
# display the operator text
operator = operators.get(operator_text)
if type(operator) == str:
text = ": " + operator
# Otherwise display the operator name from list
else:
has_operator_without_text_implementation = True
text = "*"
# Only add the header if an item in the list is defined in operators
if not has_added_first_list_item:
has_added_first_list_item = True
operators_text.append(f"{name} operators:")
operators_text.append(f" {prefix} {operator_name}{text}")
if has_operator_without_text_implementation:
operators_text.append(
"* operator is implemented as a function call and cannot be displayed"
)
page_size = settings.get("user.help_max_command_lines_per_page")
total_page_count = math.ceil(len(operators_text) / page_size)
# This exception will get raised if there is no operators object defined in the active context
except NotImplementedError:
operators_text = None
@imgui.open(y=0)
def gui_operators(gui: imgui.GUI):
global operators_text
if operators_text is None:
gui.text("Help: Operators (1/1)")
gui.line()
gui.text("There is no active programming language when you opened this menu")
gui.text("or the language does not have operator support.")
else:
page_size = settings.get("user.help_max_command_lines_per_page")
page_start = page_size * (current_list_page - 1)
page_end = page_start + page_size
gui.text(f"Help: Operators ({current_list_page}/{total_page_count})")
gui.line()
for text in operators_text[page_start:page_end]:
gui.text(text)
if total_page_count > 1:
gui.spacer()
if gui.button("Help next"):
actions.user.help_next()
if gui.button("Help previous"):
actions.user.help_previous()
gui.spacer()
if gui.button("Help close"):
gui_operators.hide()
def format_context_title(context_name: str) -> str:
global cached_active_contexts_list
return "{} [{}]".format(
context_name,
(
"ACTIVE"
if context_map.get(context_name, None) in cached_active_contexts_list
else "INACTIVE"
),
)
def format_context_button(index: int, context_label: str, context_name: str) -> str:
global cached_active_contexts_list
global show_enabled_contexts_only
if not show_enabled_contexts_only:
return "{}. {}{}".format(
index,
context_label,
(
"*"
if context_map.get(context_name, None) in cached_active_contexts_list
else ""
),
)
else:
return f"{index}. {context_label} "
# translates 1-based index -> actual index in sorted_context_map_keys
def get_context_page(index: int) -> int:
return math.ceil(index / settings.get("user.help_max_contexts_per_page"))
def get_total_context_pages() -> int:
return math.ceil(
len(sorted_display_list) / settings.get("user.help_max_contexts_per_page")
)
def get_current_context_page_length() -> int:
start_index = (current_context_page - 1) * settings.get(
"user.help_max_contexts_per_page"
)
return len(
sorted_display_list[
start_index : start_index + settings.get("user.help_max_contexts_per_page")
]
)
def get_command_line_count(command: tuple[str, str]) -> int:
"""This should be kept in sync with draw_commands"""
_, body = command
lines = len(body.split("\n"))
if lines == 1:
return 1
else:
return lines + 1
def get_pages(item_line_counts: list[int]) -> list[int]:
"""Given some set of indivisible items with given line counts,
return the page number each item should appear on.
If an item will cross a page boundary, it is moved to the next page,
so that pages may be shorter than the maximum lenth, but not longer. The only
exception is when an item is longer than the maximum page length, in which
case that item will be placed on a longer page.
"""
current_page_line_count = 0
current_page = 1
pages = []
for line_count in item_line_counts:
if line_count + current_page_line_count > settings.get(
"user.help_max_command_lines_per_page"
):
if current_page_line_count == 0:
# Special case, render a larger page.
page = current_page
current_page_line_count = 0
else:
page = current_page + 1
current_page_line_count = line_count
current_page += 1
else:
current_page_line_count += line_count
page = current_page
pages.append(page)
return pages
@imgui.open(y=0)
def gui_context_help(gui: imgui.GUI):
global context_command_map
global current_context_page
global selected_context
global selected_context_page
global sorted_display_list
global show_enabled_contexts_only
global cached_active_contexts_list
global total_page_count
global search_phrase
# if no selected context, draw the contexts
if selected_context is None and search_phrase is None:
total_page_count = get_total_context_pages()
if not show_enabled_contexts_only:
gui.text(
f"Help: All ({current_context_page}/{total_page_count}) (* = active)"
)
else:
gui.text(
"Help: Active Contexts Only ({}/{})".format(
current_context_page, total_page_count
)
)
gui.line()
current_item_index = 1
current_selection_index = 1
current_group = ""
for display_name, group, _ in sorted_display_list:
target_page = get_context_page(current_item_index)
context_name = display_name_to_context_name_map[display_name]
if current_context_page == target_page:
if current_group != group:
if current_group:
gui.line()
gui.text(f"{group}:")
current_group = group
button_name = format_context_button(
current_selection_index,
display_name,
context_name,
)
if gui.button(button_name):
selected_context = context_name
current_selection_index = current_selection_index + 1
current_item_index += 1
if total_page_count > 1:
gui.spacer()
if gui.button("Help next"):
actions.user.help_next()
if gui.button("Help previous"):
actions.user.help_previous()
# if there's a selected context, draw the commands for it
else:
if selected_context is not None:
draw_context_commands(gui)
elif search_phrase is not None:
draw_search_commands(gui)
gui.spacer()
if total_page_count > 1:
if gui.button("Help next"):
actions.user.help_next()
if gui.button("Help previous"):
actions.user.help_previous()
if gui.button("Help return"):
actions.user.help_return()
if gui.button("Help refresh"):
actions.user.help_refresh()
if gui.button("Help close"):
actions.user.help_hide()
def draw_context_commands(gui: imgui.GUI):
global selected_context
global total_page_count
global selected_context_page
context_title = format_context_title(selected_context)
title = f"Context: {context_title}"
commands = context_command_map[selected_context].items()
item_line_counts = [get_command_line_count(command) for command in commands]
pages = get_pages(item_line_counts)
total_page_count = max(pages, default=1)
draw_commands_title(gui, title)
filtered_commands = [
command
for command, page in zip(commands, pages)
if page == selected_context_page
]
draw_commands(gui, filtered_commands)
def draw_search_commands(gui: imgui.GUI):
global search_phrase
global total_page_count
global cached_active_contexts_list
global selected_context_page
title = f"Search: {search_phrase}"
commands_grouped = get_search_commands(search_phrase)
commands_flat = list(itertools.chain.from_iterable(commands_grouped.values()))
sorted_commands_grouped = sorted(
commands_grouped.items(),
key=lambda item: context_map[item[0]] not in cached_active_contexts_list,
)
pages = get_pages(
[
sum(get_command_line_count(command) for command in commands) + 3
for _, commands in sorted_commands_grouped
]
)
total_page_count = max(pages, default=1)
draw_commands_title(gui, title)
current_item_index = 1
for (context, commands), page in zip(sorted_commands_grouped, pages):
if page == selected_context_page:
gui.text(format_context_title(context))
gui.line()
draw_commands(gui, commands)
gui.spacer()
def get_search_commands(phrase: str) -> dict[str, tuple[str, str]]:
global rule_word_map
tokens = search_phrase.split(" ")
viable_commands = rule_word_map[tokens[0]]
for token in tokens[1:]:
viable_commands &= rule_word_map[token]
# sets have no stable sort order, unlike dicts
viable_commands = list(viable_commands)
viable_commands.sort()
commands_grouped = defaultdict(list)
for context, rule in viable_commands:
command = context_command_map[context][rule]
commands_grouped[context].append((rule, command))
return commands_grouped
def draw_commands_title(gui: imgui.GUI, title: str):
global selected_context_page
global total_page_count
gui.text(f"{title} ({selected_context_page}/{total_page_count})")
gui.line()
def draw_commands(gui: imgui.GUI, commands: Iterable[tuple[str, str]]):
for key, val in commands:
val = val.split("\n")
if len(val) > 1:
gui.text(f"{key}:")
for line in val:
gui.text(f" {line}")
else:
gui.text(f"{key}: {val[0]}")
def reset():
global current_context_page
global sorted_display_list
global selected_context
global search_phrase
global selected_context_page
global show_enabled_contexts_only
global display_name_to_context_name_map
global selected_list
global current_list_page
current_context_page = 1
sorted_display_list = []
selected_context = None
search_phrase = None
selected_context_page = 1
show_enabled_contexts_only = False
display_name_to_context_name_map = {}
selected_list = None
current_list_page = 1
def update_active_contexts_cache(active_contexts):
# print("update_active_contexts_cache")
global cached_active_contexts_list
cached_active_contexts_list = active_contexts
# example usage todo: make a list definable in .talon
# overrides = {"generic browser": "broswer"}
overrides = {}
def refresh_context_command_map(enabled_only=False):
active_contexts = registry.last_active_contexts
local_context_map = {}
local_display_name_to_context_name_map = {}
local_context_command_map = {}
cached_short_context_names = {}
for context_name, context in registry.contexts.items():
splits = context_name.split(".")
if "talon" == splits[-1]:
display_name = splits[-2].replace("_", " ")
short_names = actions.user.create_spoken_forms(
display_name,
generate_subsequences=False,
)
if short_names[0] in overrides:
short_names = [overrides[short_names[0]]]
elif len(short_names) == 2 and short_names[1] in overrides:
short_names = [overrides[short_names[1]]]
if enabled_only and context in active_contexts or not enabled_only:
local_context_command_map[context_name] = {}
for command_alias, val in context.commands.items():
if command_alias in registry.commands or not enabled_only:
local_context_command_map[context_name][
str(val.rule.rule)
] = val.target.code
if len(local_context_command_map[context_name]) == 0:
local_context_command_map.pop(context_name)
else:
for short_name in short_names:
cached_short_context_names[short_name] = context_name
# the last entry will contain no symbols
local_display_name_to_context_name_map[display_name] = context_name
local_context_map[context_name] = context
# Update all the global state after we've performed our calculations
global context_map
global context_command_map
global sorted_display_list
global show_enabled_contexts_only
global display_name_to_context_name_map
global rule_word_map
context_map = local_context_map
context_command_map = local_context_command_map
sorted_display_list = get_sorted_display_keys(
local_context_map,
local_display_name_to_context_name_map,
)
show_enabled_contexts_only = enabled_only
display_name_to_context_name_map = local_display_name_to_context_name_map
rule_word_map = refresh_rule_word_map(local_context_command_map)
ctx.lists["self.help_contexts"] = cached_short_context_names
update_active_contexts_cache(active_contexts)
def get_sorted_display_keys(
context_map: dict[str, Any],
display_name_to_context_name_map: dict[str, str],
):
if settings.get("user.help_sort_contexts_by_specificity"):
return get_sorted_keys_by_context_specificity(
context_map,
display_name_to_context_name_map,
)
return [
(display_name, "", 0)
for display_name in sorted(display_name_to_context_name_map.keys())
]
def get_sorted_keys_by_context_specificity(
context_map: dict[str, Any],
display_name_to_context_name_map: dict[str, str],
) -> list[Tuple[str, str, int]]:
def get_group(display_name) -> Tuple[str, str, int]:
try:
context_name = display_name_to_context_name_map[display_name]
context = context_map[context_name]
keys = context._match.keys()
if any(key for key in keys if key.startswith("app.")):
return (display_name, "Application-specific", 2)
if keys:
return (display_name, "Context-dependent", 1)
return (display_name, "Global", 0)
except Exception as ex:
return (display_name, "", 0)
grouped_list = [
get_group(display_name)
for display_name in display_name_to_context_name_map.keys()
]
return sorted(
grouped_list,
key=lambda item: (-item[2], item[0]),
)
def refresh_rule_word_map(context_command_map):
rule_word_map = defaultdict(set)
for context_name, commands in context_command_map.items():
for rule in commands:
tokens = {token for token in re.split(r"\W+", rule) if token.isalpha()}
for token in tokens:
rule_word_map[token].add((context_name, rule))
return rule_word_map
events_registered = False
def register_events(register: bool):
global events_registered
if register:
if not events_registered and live_update:
events_registered = True
# registry.register('post:update_contexts', contexts_updated)
registry.register("update_commands", commands_updated)
else:
events_registered = False
# registry.unregister('post:update_contexts', contexts_updated)
registry.unregister("update_commands", commands_updated)
def hide_all_help_guis():
gui_context_help.hide()
gui_formatters.hide()
gui_list_help.hide()
gui_operators.hide()
def paginate_list(data, SIZE=None):
chunk_size = SIZE or settings.get("user.help_max_command_lines_per_page")
it = iter(data)
for i in range(0, len(data), chunk_size):
yield {k: data[k] for k in islice(it, chunk_size)}
def draw_list_commands(gui: imgui.GUI):
global selected_list
global total_page_count
global selected_context_page
talon_list = actions.user.talon_get_active_registry_list(selected_list)
# numpages = math.ceil(len(talon_list) / SIZE)
pages_list = []
for item in paginate_list(talon_list):
pages_list.append(item)
# print(pages_list)
total_page_count = len(pages_list)
return pages_list
@imgui.open(y=0)
def gui_list_help(gui: imgui.GUI):
global total_page_count
global current_list_page
global selected_list
pages_list = draw_list_commands(gui)
total_page_count = len(pages_list)
# print(pages_list[current_page])
if total_page_count == 0:
page_info = "empty"
else:
page_info = f"{current_list_page}/{total_page_count}"
gui.text(f"List: {selected_list} ({page_info})")
# Extract description from list declaration, i.e. mod.list(..., desc=...))
if (desc := registry.decls.lists[selected_list].desc) is not None:
for line in wrap(desc):
gui.text(line)
gui.line()
if len(pages_list) > 0:
for key, value in pages_list[current_list_page - 1].items():
gui.text(f"{value}: {key}")
gui.spacer()
if total_page_count > 1:
if gui.button("Help next"):
actions.user.help_next()
if gui.button("Help previous"):
actions.user.help_previous()
if gui.button("Help return"):
actions.user.help_return()
if gui.button("Help refresh"):
actions.user.help_refresh()
if gui.button("Help close"):
actions.user.help_hide()
@mod.action_class
class Actions:
def help_list(ab: str):
"""Provides the symbol dictionary"""
# what you say is stored as a trigger
global selected_list
reset()
selected_list = ab
gui_list_help.show()
register_events(True)
ctx.tags = ["user.help_open"]
def help_formatters(ab: dict, reformat: bool):
"""Provides the list of formatter keywords"""
# what you say is stored as a trigger
global formatters_words, formatters_reformat
formatters_words = ab
formatters_reformat = reformat
reset()
hide_all_help_guis()
gui_formatters.show()
register_events(False)
ctx.tags = ["user.help_open"]
def help_operators():
"""Displays the list of operator names"""
reset()
hide_all_help_guis()
update_operators_text()
gui_operators.show()
register_events(True)
ctx.tags = ["user.help_open"]
def help_context_enabled():
"""Display contextual command info"""
reset()
refresh_context_command_map(enabled_only=True)
hide_all_help_guis()
gui_context_help.show()
register_events(True)
ctx.tags = ["user.help_open"]
def help_context():
"""Display contextual command info"""
reset()
refresh_context_command_map()
hide_all_help_guis()
gui_context_help.show()
register_events(True)
ctx.tags = ["user.help_open"]
def help_search(phrase: str):
"""Display command info for search phrase"""
global search_phrase
reset()
search_phrase = phrase
refresh_context_command_map()
hide_all_help_guis()
gui_context_help.show()
register_events(True)
ctx.tags = ["user.help_open"]
def help_selected_context(m: str):
"""Display command info for selected context"""
global selected_context
global selected_context_page
if not gui_context_help.showing:
reset()
refresh_context_command_map()
else:
selected_context_page = 1
update_active_contexts_cache(registry.last_active_contexts)
selected_context = m
hide_all_help_guis()
gui_context_help.show()
register_events(True)
ctx.tags = ["user.help_open"]
def help_next():
"""Navigates to next page"""
global current_context_page
global selected_context
global selected_context_page
global total_page_count
global current_list_page
if gui_context_help.showing:
if selected_context is None and search_phrase is None:
if current_context_page != total_page_count:
current_context_page += 1
else:
current_context_page = 1
else:
if selected_context_page != total_page_count:
selected_context_page += 1
else:
selected_context_page = 1
if gui_list_help.showing or gui_operators.showing:
if current_list_page != total_page_count:
current_list_page += 1
else:
current_list_page = 1
def help_select_index(index: int):
"""Select the context by a number"""
global sorted_display_list, selected_context
if gui_context_help.showing:
if index < settings.get("user.help_max_contexts_per_page") and (
(current_context_page - 1)
* settings.get("user.help_max_contexts_per_page")
+ index
< len(sorted_display_list)
):
if selected_context is None:
selected_context = display_name_to_context_name_map[
sorted_display_list[
(current_context_page - 1)
* settings.get("user.help_max_contexts_per_page")
+ index
][0]
]
def help_previous():
"""Navigates to previous page"""
global current_context_page
global selected_context
global selected_context_page
global total_page_count
global current_list_page
if gui_context_help.showing:
if selected_context is None and search_phrase is None:
if current_context_page != 1:
current_context_page -= 1
else:
current_context_page = total_page_count
else:
if selected_context_page != 1:
selected_context_page -= 1
else:
selected_context_page = total_page_count
if gui_list_help.showing or gui_operators.showing:
if current_list_page != total_page_count:
current_list_page -= 1
else:
current_list_page = 1
def help_return():
"""Returns to the main help window"""
global selected_context
global selected_context_page
global show_enabled_contexts_only
if gui_context_help.showing:
refresh_context_command_map(show_enabled_contexts_only)
selected_context_page = 1
selected_context = None
def help_refresh():
"""Refreshes the help"""
global show_enabled_contexts_only
global selected_context
if gui_context_help.showing:
if selected_context is None:
refresh_context_command_map(show_enabled_contexts_only)
else:
update_active_contexts_cache(registry.last_active_contexts)
def help_hide():
"""Hides the help"""
reset()
# print("help_hide - alphabet gui_alphabet: {}".format(gui_alphabet.showing))
# print(
# "help_hide - gui_context_help showing: {}".format(gui_context_help.showing)
# )
hide_all_help_guis()
refresh_context_command_map()
register_events(False)
ctx.tags = []
def commands_updated(_):
update_title()
+25
View File
@@ -0,0 +1,25 @@
help alphabet: user.help_list("user.letter")
help symbols: user.help_list("user.symbol_key")
help numbers: user.help_list("user.number_key")
help punctuation: user.help_list("user.punctuation")
help modifier: user.help_list("user.modifier_key")
help special keys: user.help_list("user.special_key")
help function keys: user.help_list("user.function_key")
help arrows: user.help_list("user.arrow_key")
help context$: user.help_context()
help active$: user.help_context_enabled()
help search <user.text>$: user.help_search(text)
help clip search: user.help_search(clip.text())
help this search: user.help_search(edit.selected_text())
help context {user.help_contexts}$: user.help_selected_context(help_contexts)
help help: user.help_search("help")
help scope$: user.help_scope_toggle()
help snip: user.help_list("user.snippet")
help operators: user.help_operators()
help keywords: user.help_list("user.code_keyword")
help keywords unprefixed: user.help_list("user.code_keyword_unprefixed")
(help formatters | help format | format help):
user.help_formatters(user.get_formatters_words(), false)
(help re formatters | help re format | re format help):
user.help_formatters(user.get_reformatters_words(), true)
+8
View File
@@ -0,0 +1,8 @@
tag: user.help_open
-
help next$: user.help_next()
help (previous | last)$: user.help_previous()
help <number>$: user.help_select_index(number - 1)
help return$: user.help_return()
help refresh$: user.help_refresh()
help close$: user.help_hide()
+70
View File
@@ -0,0 +1,70 @@
from talon import Context, Module, actions, imgui, scope, settings, ui
ctx = Context()
mod = Module()
mod.tag("help_scope_open", "tag for showing the scope help gui")
mod.setting(
"help_scope_max_length",
type=int,
default=50,
)
@imgui.open(x=ui.main_screen().x)
def gui(gui: imgui.GUI):
gui.text("Scope")
gui.line()
gui.spacer()
gui.text("Modes")
gui.line()
for mode in sorted(scope.get("mode")):
gui.text(mode)
gui.spacer()
gui.text("Tags")
gui.line()
for tag in sorted(scope.get("tag")):
gui.text(tag)
gui.spacer()
gui.text("Misc")
gui.line()
ignore = {"main", "mode", "tag"}
keys = {*scope.data.keys(), *scope.data["main"].keys()}
for key in sorted(keys):
if key not in ignore:
value = scope.get(key)
print_value(gui, key, value, ignore)
gui.spacer()
if gui.button("Hide"):
actions.user.help_scope_toggle()
def print_value(gui: imgui.GUI, path: str, value, ignore: set[str] = {}):
if isinstance(value, dict):
for key in value:
if key not in ignore:
p = f"{path}.{key}" if path else key
print_value(gui, p, value[key])
elif value:
gui.text(f"{path}: {format_value(value)}")
def format_value(value):
if isinstance(value, (list, set)):
value = ", ".join(sorted(value))
setting_max_length = settings.get("user.help_scope_max_length")
if isinstance(value, str) and len(value) > setting_max_length + 4:
return f"{value[:setting_max_length]} ..."
return value
@mod.action_class
class Actions:
def help_scope_toggle():
"""Toggle help scope gui"""
if gui.showing:
ctx.tags = []
gui.hide()
else:
ctx.tags = ["user.help_scope_open"]
gui.show()
@@ -0,0 +1,4 @@
tag: user.help_scope_open
-
scope (hide | close)$: user.help_scope_toggle()
+677
View File
@@ -0,0 +1,677 @@
Aaron,Erin
able,Abel
acclamation,acclimation
acts,ax
Adam,atom
add,ad
addition,edition
adds,ads,adz
adduce,educe
adherence,adherents
ado,adieu
aerial,ariel
affected,effected
afterward,afterword
aid,aide
air,heir,err
ale,ail
align,a line,aline
all,awl
allowed,aloud
alluded,eluded
alter,altar
analyst,annalist
appetite,apatite
apprize,apprise
arc,ark
ascent,assent
assistance,assistants
attendance,attendants
augur,auger
aunt,ant
away,aweigh
axis,axes
axle,axel
Babel,babble
bad,bade,bed
bah,baa
bail,bale,baal
bait,bate
baited,bated
bald,balled,bawled
ball,bawl
band,banned
baron,barren
barred,bard
barrel,beryl
base,bass
based,baste
baseline,bassline
basil,basal
basis,bases
basque,bask
be,bee
beach,beech
bear,bare
beat,beet
been,bin
beer,bier
beetle,beatle
bell,belle
better,bettor
bib,bibb
bird,burred
birth,berth
bite,byte,bight
bizarre,bazaar
block,bloc
blue,blew
board,bored
bold,bowled
bomb,balm,bombe
booty,bootie
border,boarder
bore,boar
born,borne
borough,burrow,burro
bought,bot
boulder,bolder
bow,beau
bow,bough
bowed,bode
boy,buoy
braid,brayed
brays,braise
breach,breech
bread,bred
break,brake
brewed,brood
brews,bruise
bridal,bridle
broach,brooch
browse,brows
brute,brut
build,billed
bullion,bouillon,boolean
burger,burgher
bury,berry
bust,bussed
but,butt
by,buy,bye
cached,cashed
caches,cashes
caching,cashing
caddy,caddie
Cain,cane
calendar,calender
callous,callus
cannon,canon
cantor,canter
canvas,canvass
capital,capitol
carol,carrel
carrot,carat,caret,karat
cash,cache
cast,caste
caster,castor
cause,caws
cedar,seeder
ceiling,sealing
chance,chants
chased,chaste
chauffeur,shofar
cheap,cheep
check,Czech
chic,sheik
Chile,chili,chilly
choir,quire
choose,chews
cited,sided,sighted
clack,claque
clammer,clamor,clamber
clause,claws
click,clique
climb,clime
close,clothes,cloze
clue,clew
coal,cole
coarser,courser
coat,cote
coax,cokes
collared,collard
complacent,complaisant
complement,compliment
conceded,conceited
consonants,consonance
consul,console,consol
continents,continence
cops,copse
coral,choral
cord,chord,cored
core,corps
coughers,coffers
coulee,coolie
council,counsel
coup,coo
coupe,coop
course,coarse
cousin,cozen
coward,cowered
coy,koi
craft,kraft
crawl,kraal
creek,creak
crepe,crape
cruel,crewel
cruise,crews
current,currant
cursor,curser
Cyprus,cypress
damn,dam
Dane,deign
days,daze
dear,deer
dense,dents
descent,dissent
die,dye
diffused,defused
discrete,discreet
disperse,disburse
do,dough,doe
do,due,dew
doc,dock
does,doze
done,dun
draft,draught
dual,duel
ducked,duct
ducks,ducts
dying,dyeing
earn,urn
effect,affect
effects,affects
eight,ate
eke,eek
elude,allude
elusive,illusive,allusive
emend,amend
end,and
ensure,insure
errant,arrant
eve,eave
exceed,accede
except,accept
excepting,accepting
exercise,exorcise
eyes,ayes
facts,fax
faint,feint
fair,fare
fairy,ferry
fawn,faun
feet,feat
fens,fends
fete,fate
few,phew
find,fined
finish,Finnish
fish,phish
fished,phished
fisher,fissure
fishing,phishing
flare,flair
flee,flea
flew,flu,flue
flocks,phlox
flow,floe
flower,flour
flyer,flier
foe,faux
fold,foaled
for,four,fore
foregone,forgone
fort,forte
forward,foreword
foul,fowl
fourth,forth
frank,franc
freeze,frees,frieze
friar,fryer
fur,fir
gaffe,gaff
gale,Gail
gamble,gambol
gate,gait
gator,gaiter
gauge,gage
gel,jell
gene,Jean
gilder,guilder
gnome,Nome
gopher,gofer
gorilla,guerilla
gourd,gored
grade,grayed
graft,graphed
graham,gram
graze,grays
great,grate
greater,grater
Greece,grease
grill,grille
grizzly,grisly
grown,groan
guest,guessed
guild,gild
guilt,gilt
hail,hale
hair,hare
hall,haul
handmade,handmaid
handsome,hansom
hangar,hanger
have,halve
haze,hays
he'd,heed
he'll,heal,heel
heard,herd
heart,hart
here,hear
heroin,heroine
hey,hay
high,hi
higher,hire
him,hymn
ho,hoe
hold,holed
holy,wholly,holey
hoop,whoop
horde,hoard
horse,hoarse
hose,hoes
hostile,hostel
Hugh,hue,hew
humorous,humerus
hurts,hertz
I,eye,aye
I'd,eyed
I'll,aisle,isle
idle,idol,idyll
illicit,elicit
illusion,allusion
imminent,immanent
impassable,impassible
in,inn
innocence,innocents
innumerable,enumerable
insight,incite
instance,instants
intense,intents
islet,eyelet
its,it's
jam,jamb
jibe,gibe
Jim,gym
jinx,jinks
json,jason
kernel,colonel
knickers,nickers
knit,nit
knock,nock
knows,nose
lacks,lax
laid,lade
lama,llama
lane,lain
laps,lapse,Lapps
latter,ladder
lay,lei
lays,leis,laze
leach,leech
lead,led
leak,leek
lean,lien
least,leased
lee,lea
lens,lends
lesson,lessen
let's,lets
levy,levee
liar,lyre,lier
lie,lye
light,lite
liken,lichen
links,lynx
literal,littoral
load,lode,lowed
loan,lone
loathe,loath
loch,lock
lochs,locks,lox
loot,lute
Lou,lieu
low,lo
lumber,lumbar
made,maid
main,Maine,mane
male,mail
mall,maul
manner,manor
mantle,mantel
mark,marc
martial,marshal
martin,marten
Mary,marry,merry
mast,massed
mat,matte
matter,madder
mayor,mare
maze,maize
mean,mien
meet,meat,mete
metal,medal,meddle,mettle
meteor,meatier
might,mite
mill,mil
mince,mints
mind,mined
minor,miner
missed,mist
missile,missal
moan,mown
moat,mote
mode,mowed
mood,mooed
moose,mousse
morning,mourning
mourn,morn
Mrs,misses
mucus,mucous
mule,mewl
muscle,mussel
muse,mews
must,mussed
mustard,mustered
nap,knap
naval,navel
nave,knave
nay,neigh
need,knead,kneed
new,knew,gnu
nice,gneiss
Nice,niece
night,knight
no,know
none,nun
not,knot
oh,owe
one,won
or,ore,oar
oral,aural
oriole,aureole
our,hour
ours,hours
outcast,outcaste
overdue,overdo
overseas,oversees
owed,ode
packed,pact
paean,peon,paeon
pain,pane
pair,pear,pare
pale,pail
palette,palate,pallet
parish,perish
parley,parlay
past,passed
paste,paced
patients,patience
patted,padded
Paul,pall
pause,paws
peak,peek,pique
peas,pees
pedal,peddle,petal
pee,pea
peel,peal
peer,pier
penance,pennants
per,purr
perl,pearl,purl
pervade,purveyed
Pete,peat
petrol,petrel
pew,pugh
phase,faze
Phil,fill
phrase,frays
picot,pekoe
pie,pi
piece,peace
pigeon,pidgin
pilot,Pilate
pistol,pistil
plane,plain
plaque,plack
plate,plait
please,pleas
plum,plumb
poll,pole
pour,pore
praise,prays,preys
pray,prey
precedence,precedents
premier,premiere
presence,presents
pride,pried
primer,primmer
prince,prints
principle,principal
profit,prophet
pros,prose
pull,pool
quartz,quarts
queue,cue
queues,cues
quince,quints
rabbit,rabbet
rack,wrack
rain,reign,rein
raise,rays,raze
read,red
read,reed
real,reel
residents,residence
rest,wrest
review,revue
right,write,rite
rights,writes,rites
rigor,rigger
ring,wring
road,rode,rowed
roads,rhodes
role,roll
Rome,roam
room,rheum
rose,rows
rot,wrought
rough,ruff
route,root
row,roe
rude,rued
rue,roux
rumor,roomer
rung,wrung
Russell,rustle
rye,wry
sachet,sashay
sack,sac
sacks,sax
sale,sail
sane,seine
saver,savor
scalar,scaler
see,sea
seed,cede
seem,seam
seen,scene
seer,sear,sere
sees,seize,seas
sell,cell
seller,cellar
sense,cents,scents
sensor,censor
sent,cent,scent
serial,cereal
series,Ceres
session,cession
sewing,sowing
sheer,shear
shoe,shoo
shoot,chute
shown,shone
sick,sic
side,sighed
signet,cygnet
sink,sync
sinking,syncing
site,sight,cite
size,sighs
skull,scull
slay,sleigh
slew,slough,slue
slight,sleight
slow,sloe
so,sow,sew
sold,soled
some,sum
son,sun
sore,soar
sorry,sari
soul,sole
spade,spayed
stake,steak
stare,stair
stationary,stationery
stayed,staid
steel,steal
step,steppe
straight,strait
straightened,straitened
style,stile
sue,Sioux
suit,soot
summary,summery
Sunday,sundae
surf,serf
surge,serge
swayed,suede
sweet,suite
sword,soared
symbol,cymbal
tacked,tact
tale,tail
talk,tock
taper,tapir
taught,taut
taupe,tope
tax,tacks
tea,tee
team,teem
tear,tare
tear,tier
tease,teas,tees
tens,tends
tense,tents
terry,tarry
than,then
the,thee
their,there,they're
there's,theirs
through,threw
thrown,throne
throws,throes
tick,tic
tie,Thai
tied,tide
Tigris,tigress
timber,timbre
time,thyme
to,two,too
toe,tow
told,tolled
tool,tulle
tort,torte
torturous,tortuous
towed,toad,toed
tracked,tract
trader,traitor
troop,troupe
trust,trussed
tucks,tux
turbine,turban
turn,tern
tutor,Tudor,tooter
undo,undue
use,ewes,yews
utter,udder
vain,vein,vane
valence,valance
variants,variance
veil,vale
vein,vane
Venus,venous
versus,verses
very,vary
vice,vise
vile,vial
wade,weighed
wait,weight
waiter,wader
Wales,whales,wails
walk,wok
want,wont
war,wore
ward,word
waste,waist
wave,waive
wax,whacks
way,weigh,whey
Wayne,wane,wain
ways,weighs
we,wee
we'd,weed
we'll,wheel
we've,weave
wears,where's,wares
week,weak
weekly,weakly
were,whir,we're
wet,whet
whale,wail,wale
wheeled,wield
where,wear,ware
whether,weather,wether
whether,weather
which,witch
while,wile
whoa,woe
whole,hole
whose,who's
wind,whined,wined
wine,whine
with,width
word,whirred
world,whirled,whorled
worn,warn
would,wood
wrap,rap
wrapped,rapped,rapt
wrapper,rapper
wreak,reek
wretch,retch
wrote,rote
yoke,yolk
you,yew,ewe
you'll,Yule
your,you're,yore
1 Aaron,Erin
2 able,Abel
3 acclamation,acclimation
4 acts,ax
5 Adam,atom
6 add,ad
7 addition,edition
8 adds,ads,adz
9 adduce,educe
10 adherence,adherents
11 ado,adieu
12 aerial,ariel
13 affected,effected
14 afterward,afterword
15 aid,aide
16 air,heir,err
17 ale,ail
18 align,a line,aline
19 all,awl
20 allowed,aloud
21 alluded,eluded
22 alter,altar
23 analyst,annalist
24 appetite,apatite
25 apprize,apprise
26 arc,ark
27 ascent,assent
28 assistance,assistants
29 attendance,attendants
30 augur,auger
31 aunt,ant
32 away,aweigh
33 axis,axes
34 axle,axel
35 Babel,babble
36 bad,bade,bed
37 bah,baa
38 bail,bale,baal
39 bait,bate
40 baited,bated
41 bald,balled,bawled
42 ball,bawl
43 band,banned
44 baron,barren
45 barred,bard
46 barrel,beryl
47 base,bass
48 based,baste
49 baseline,bassline
50 basil,basal
51 basis,bases
52 basque,bask
53 be,bee
54 beach,beech
55 bear,bare
56 beat,beet
57 been,bin
58 beer,bier
59 beetle,beatle
60 bell,belle
61 better,bettor
62 bib,bibb
63 bird,burred
64 birth,berth
65 bite,byte,bight
66 bizarre,bazaar
67 block,bloc
68 blue,blew
69 board,bored
70 bold,bowled
71 bomb,balm,bombe
72 booty,bootie
73 border,boarder
74 bore,boar
75 born,borne
76 borough,burrow,burro
77 bought,bot
78 boulder,bolder
79 bow,beau
80 bow,bough
81 bowed,bode
82 boy,buoy
83 braid,brayed
84 brays,braise
85 breach,breech
86 bread,bred
87 break,brake
88 brewed,brood
89 brews,bruise
90 bridal,bridle
91 broach,brooch
92 browse,brows
93 brute,brut
94 build,billed
95 bullion,bouillon,boolean
96 burger,burgher
97 bury,berry
98 bust,bussed
99 but,butt
100 by,buy,bye
101 cached,cashed
102 caches,cashes
103 caching,cashing
104 caddy,caddie
105 Cain,cane
106 calendar,calender
107 callous,callus
108 cannon,canon
109 cantor,canter
110 canvas,canvass
111 capital,capitol
112 carol,carrel
113 carrot,carat,caret,karat
114 cash,cache
115 cast,caste
116 caster,castor
117 cause,caws
118 cedar,seeder
119 ceiling,sealing
120 chance,chants
121 chased,chaste
122 chauffeur,shofar
123 cheap,cheep
124 check,Czech
125 chic,sheik
126 Chile,chili,chilly
127 choir,quire
128 choose,chews
129 cited,sided,sighted
130 clack,claque
131 clammer,clamor,clamber
132 clause,claws
133 click,clique
134 climb,clime
135 close,clothes,cloze
136 clue,clew
137 coal,cole
138 coarser,courser
139 coat,cote
140 coax,cokes
141 collared,collard
142 complacent,complaisant
143 complement,compliment
144 conceded,conceited
145 consonants,consonance
146 consul,console,consol
147 continents,continence
148 cops,copse
149 coral,choral
150 cord,chord,cored
151 core,corps
152 coughers,coffers
153 coulee,coolie
154 council,counsel
155 coup,coo
156 coupe,coop
157 course,coarse
158 cousin,cozen
159 coward,cowered
160 coy,koi
161 craft,kraft
162 crawl,kraal
163 creek,creak
164 crepe,crape
165 cruel,crewel
166 cruise,crews
167 current,currant
168 cursor,curser
169 Cyprus,cypress
170 damn,dam
171 Dane,deign
172 days,daze
173 dear,deer
174 dense,dents
175 descent,dissent
176 die,dye
177 diffused,defused
178 discrete,discreet
179 disperse,disburse
180 do,dough,doe
181 do,due,dew
182 doc,dock
183 does,doze
184 done,dun
185 draft,draught
186 dual,duel
187 ducked,duct
188 ducks,ducts
189 dying,dyeing
190 earn,urn
191 effect,affect
192 effects,affects
193 eight,ate
194 eke,eek
195 elude,allude
196 elusive,illusive,allusive
197 emend,amend
198 end,and
199 ensure,insure
200 errant,arrant
201 eve,eave
202 exceed,accede
203 except,accept
204 excepting,accepting
205 exercise,exorcise
206 eyes,ayes
207 facts,fax
208 faint,feint
209 fair,fare
210 fairy,ferry
211 fawn,faun
212 feet,feat
213 fens,fends
214 fete,fate
215 few,phew
216 find,fined
217 finish,Finnish
218 fish,phish
219 fished,phished
220 fisher,fissure
221 fishing,phishing
222 flare,flair
223 flee,flea
224 flew,flu,flue
225 flocks,phlox
226 flow,floe
227 flower,flour
228 flyer,flier
229 foe,faux
230 fold,foaled
231 for,four,fore
232 foregone,forgone
233 fort,forte
234 forward,foreword
235 foul,fowl
236 fourth,forth
237 frank,franc
238 freeze,frees,frieze
239 friar,fryer
240 fur,fir
241 gaffe,gaff
242 gale,Gail
243 gamble,gambol
244 gate,gait
245 gator,gaiter
246 gauge,gage
247 gel,jell
248 gene,Jean
249 gilder,guilder
250 gnome,Nome
251 gopher,gofer
252 gorilla,guerilla
253 gourd,gored
254 grade,grayed
255 graft,graphed
256 graham,gram
257 graze,grays
258 great,grate
259 greater,grater
260 Greece,grease
261 grill,grille
262 grizzly,grisly
263 grown,groan
264 guest,guessed
265 guild,gild
266 guilt,gilt
267 hail,hale
268 hair,hare
269 hall,haul
270 handmade,handmaid
271 handsome,hansom
272 hangar,hanger
273 have,halve
274 haze,hays
275 he'd,heed
276 he'll,heal,heel
277 heard,herd
278 heart,hart
279 here,hear
280 heroin,heroine
281 hey,hay
282 high,hi
283 higher,hire
284 him,hymn
285 ho,hoe
286 hold,holed
287 holy,wholly,holey
288 hoop,whoop
289 horde,hoard
290 horse,hoarse
291 hose,hoes
292 hostile,hostel
293 Hugh,hue,hew
294 humorous,humerus
295 hurts,hertz
296 I,eye,aye
297 I'd,eyed
298 I'll,aisle,isle
299 idle,idol,idyll
300 illicit,elicit
301 illusion,allusion
302 imminent,immanent
303 impassable,impassible
304 in,inn
305 innocence,innocents
306 innumerable,enumerable
307 insight,incite
308 instance,instants
309 intense,intents
310 islet,eyelet
311 its,it's
312 jam,jamb
313 jibe,gibe
314 Jim,gym
315 jinx,jinks
316 json,jason
317 kernel,colonel
318 knickers,nickers
319 knit,nit
320 knock,nock
321 knows,nose
322 lacks,lax
323 laid,lade
324 lama,llama
325 lane,lain
326 laps,lapse,Lapps
327 latter,ladder
328 lay,lei
329 lays,leis,laze
330 leach,leech
331 lead,led
332 leak,leek
333 lean,lien
334 least,leased
335 lee,lea
336 lens,lends
337 lesson,lessen
338 let's,lets
339 levy,levee
340 liar,lyre,lier
341 lie,lye
342 light,lite
343 liken,lichen
344 links,lynx
345 literal,littoral
346 load,lode,lowed
347 loan,lone
348 loathe,loath
349 loch,lock
350 lochs,locks,lox
351 loot,lute
352 Lou,lieu
353 low,lo
354 lumber,lumbar
355 made,maid
356 main,Maine,mane
357 male,mail
358 mall,maul
359 manner,manor
360 mantle,mantel
361 mark,marc
362 martial,marshal
363 martin,marten
364 Mary,marry,merry
365 mast,massed
366 mat,matte
367 matter,madder
368 mayor,mare
369 maze,maize
370 mean,mien
371 meet,meat,mete
372 metal,medal,meddle,mettle
373 meteor,meatier
374 might,mite
375 mill,mil
376 mince,mints
377 mind,mined
378 minor,miner
379 missed,mist
380 missile,missal
381 moan,mown
382 moat,mote
383 mode,mowed
384 mood,mooed
385 moose,mousse
386 morning,mourning
387 mourn,morn
388 Mrs,misses
389 mucus,mucous
390 mule,mewl
391 muscle,mussel
392 muse,mews
393 must,mussed
394 mustard,mustered
395 nap,knap
396 naval,navel
397 nave,knave
398 nay,neigh
399 need,knead,kneed
400 new,knew,gnu
401 nice,gneiss
402 Nice,niece
403 night,knight
404 no,know
405 none,nun
406 not,knot
407 oh,owe
408 one,won
409 or,ore,oar
410 oral,aural
411 oriole,aureole
412 our,hour
413 ours,hours
414 outcast,outcaste
415 overdue,overdo
416 overseas,oversees
417 owed,ode
418 packed,pact
419 paean,peon,paeon
420 pain,pane
421 pair,pear,pare
422 pale,pail
423 palette,palate,pallet
424 parish,perish
425 parley,parlay
426 past,passed
427 paste,paced
428 patients,patience
429 patted,padded
430 Paul,pall
431 pause,paws
432 peak,peek,pique
433 peas,pees
434 pedal,peddle,petal
435 pee,pea
436 peel,peal
437 peer,pier
438 penance,pennants
439 per,purr
440 perl,pearl,purl
441 pervade,purveyed
442 Pete,peat
443 petrol,petrel
444 pew,pugh
445 phase,faze
446 Phil,fill
447 phrase,frays
448 picot,pekoe
449 pie,pi
450 piece,peace
451 pigeon,pidgin
452 pilot,Pilate
453 pistol,pistil
454 plane,plain
455 plaque,plack
456 plate,plait
457 please,pleas
458 plum,plumb
459 poll,pole
460 pour,pore
461 praise,prays,preys
462 pray,prey
463 precedence,precedents
464 premier,premiere
465 presence,presents
466 pride,pried
467 primer,primmer
468 prince,prints
469 principle,principal
470 profit,prophet
471 pros,prose
472 pull,pool
473 quartz,quarts
474 queue,cue
475 queues,cues
476 quince,quints
477 rabbit,rabbet
478 rack,wrack
479 rain,reign,rein
480 raise,rays,raze
481 read,red
482 read,reed
483 real,reel
484 residents,residence
485 rest,wrest
486 review,revue
487 right,write,rite
488 rights,writes,rites
489 rigor,rigger
490 ring,wring
491 road,rode,rowed
492 roads,rhodes
493 role,roll
494 Rome,roam
495 room,rheum
496 rose,rows
497 rot,wrought
498 rough,ruff
499 route,root
500 row,roe
501 rude,rued
502 rue,roux
503 rumor,roomer
504 rung,wrung
505 Russell,rustle
506 rye,wry
507 sachet,sashay
508 sack,sac
509 sacks,sax
510 sale,sail
511 sane,seine
512 saver,savor
513 scalar,scaler
514 see,sea
515 seed,cede
516 seem,seam
517 seen,scene
518 seer,sear,sere
519 sees,seize,seas
520 sell,cell
521 seller,cellar
522 sense,cents,scents
523 sensor,censor
524 sent,cent,scent
525 serial,cereal
526 series,Ceres
527 session,cession
528 sewing,sowing
529 sheer,shear
530 shoe,shoo
531 shoot,chute
532 shown,shone
533 sick,sic
534 side,sighed
535 signet,cygnet
536 sink,sync
537 sinking,syncing
538 site,sight,cite
539 size,sighs
540 skull,scull
541 slay,sleigh
542 slew,slough,slue
543 slight,sleight
544 slow,sloe
545 so,sow,sew
546 sold,soled
547 some,sum
548 son,sun
549 sore,soar
550 sorry,sari
551 soul,sole
552 spade,spayed
553 stake,steak
554 stare,stair
555 stationary,stationery
556 stayed,staid
557 steel,steal
558 step,steppe
559 straight,strait
560 straightened,straitened
561 style,stile
562 sue,Sioux
563 suit,soot
564 summary,summery
565 Sunday,sundae
566 surf,serf
567 surge,serge
568 swayed,suede
569 sweet,suite
570 sword,soared
571 symbol,cymbal
572 tacked,tact
573 tale,tail
574 talk,tock
575 taper,tapir
576 taught,taut
577 taupe,tope
578 tax,tacks
579 tea,tee
580 team,teem
581 tear,tare
582 tear,tier
583 tease,teas,tees
584 tens,tends
585 tense,tents
586 terry,tarry
587 than,then
588 the,thee
589 their,there,they're
590 there's,theirs
591 through,threw
592 thrown,throne
593 throws,throes
594 tick,tic
595 tie,Thai
596 tied,tide
597 Tigris,tigress
598 timber,timbre
599 time,thyme
600 to,two,too
601 toe,tow
602 told,tolled
603 tool,tulle
604 tort,torte
605 torturous,tortuous
606 towed,toad,toed
607 tracked,tract
608 trader,traitor
609 troop,troupe
610 trust,trussed
611 tucks,tux
612 turbine,turban
613 turn,tern
614 tutor,Tudor,tooter
615 undo,undue
616 use,ewes,yews
617 utter,udder
618 vain,vein,vane
619 valence,valance
620 variants,variance
621 veil,vale
622 vein,vane
623 Venus,venous
624 versus,verses
625 very,vary
626 vice,vise
627 vile,vial
628 wade,weighed
629 wait,weight
630 waiter,wader
631 Wales,whales,wails
632 walk,wok
633 want,wont
634 war,wore
635 ward,word
636 waste,waist
637 wave,waive
638 wax,whacks
639 way,weigh,whey
640 Wayne,wane,wain
641 ways,weighs
642 we,wee
643 we'd,weed
644 we'll,wheel
645 we've,weave
646 wears,where's,wares
647 week,weak
648 weekly,weakly
649 were,whir,we're
650 wet,whet
651 whale,wail,wale
652 wheeled,wield
653 where,wear,ware
654 whether,weather,wether
655 whether,weather
656 which,witch
657 while,wile
658 whoa,woe
659 whole,hole
660 whose,who's
661 wind,whined,wined
662 wine,whine
663 with,width
664 word,whirred
665 world,whirled,whorled
666 worn,warn
667 would,wood
668 wrap,rap
669 wrapped,rapped,rapt
670 wrapper,rapper
671 wreak,reek
672 wretch,retch
673 wrote,rote
674 yoke,yolk
675 you,yew,ewe
676 you'll,Yule
677 your,you're,yore
+243
View File
@@ -0,0 +1,243 @@
import os
from talon import Context, Module, actions, app, clip, fs, imgui, ui
########################################################################
# global settings
########################################################################
# a list of homophones where each line is a comma separated list
# e.g. where,wear,ware
# a suitable one can be found here:
# https://github.com/pimentel/homophones
cwd = os.path.dirname(os.path.realpath(__file__))
homophones_file = os.path.join(cwd, "homophones.csv")
# if quick_replace, then when a word is selected and only one homophone exists,
# replace it without bringing up the options
quick_replace = True
show_help = False
########################################################################
ctx = Context()
mod = Module()
mod.list("homophones_canonicals", desc="list of words ")
mod.tag(
"homophones_open",
desc="Tag for enabling homophones commands when the associated gui is open",
)
main_screen = ui.main_screen()
def update_homophones(name, flags):
if name != homophones_file:
return
phones = {}
canonical_list = []
with open(homophones_file) as f:
for line in f:
words = line.rstrip().split(",")
words = [x for x in words if x.strip() != ""]
canonical_list.append(words[0])
merged_words = set(words)
for word in words:
old_words = phones.get(word.lower(), [])
merged_words.update(old_words)
merged_words = sorted(merged_words)
for word in merged_words:
phones[word.lower()] = merged_words
global all_homophones
all_homophones = phones
ctx.lists["self.homophones_canonicals"] = canonical_list
update_homophones(homophones_file, None)
fs.watch(cwd, update_homophones)
active_word_list = None
is_selection = False
def close_homophones():
gui.hide()
ctx.tags = []
PHONES_FORMATTERS = [
lambda word: word.capitalize(),
lambda word: word.upper(),
]
def find_matching_format_function(word_with_formatting, format_functions):
"""Finds the formatter function from a list of formatter functions which transforms a word into itself.
Returns an identity function if none exists"""
for formatter in format_functions:
formatted_word = formatter(word_with_formatting)
if word_with_formatting == formatted_word:
return formatter
return lambda word: word
def raise_homophones(word_to_find_homophones_for, forced=False, selection=False):
global quick_replace
global active_word_list
global show_help
global force_raise
global is_selection
force_raise = forced
is_selection = selection
if is_selection:
word_to_find_homophones_for = word_to_find_homophones_for.strip()
formatter = find_matching_format_function(
word_to_find_homophones_for, PHONES_FORMATTERS
)
word_to_find_homophones_for = word_to_find_homophones_for.lower()
# We support plurals, but very naively. If we can't find your word but your word ends in an s, presume its plural
# and attempt to find the singular, then present the presumed plurals back. This could be improved!
if word_to_find_homophones_for in all_homophones:
valid_homophones = all_homophones[word_to_find_homophones_for]
elif (
word_to_find_homophones_for.endswith("s")
and word_to_find_homophones_for[:-1] in all_homophones
):
valid_homophones = map(
lambda w: w + "s", all_homophones[word_to_find_homophones_for[:-1]]
)
else:
app.notify(
"homophones.py", f'"{word_to_find_homophones_for}" not in homophones list'
)
return
# Move current word to end of list to reduce searcher's cognitive load
valid_homophones_reordered = list(
filter(
lambda word_from_list: word_from_list.lower()
!= word_to_find_homophones_for,
valid_homophones,
)
) + [word_to_find_homophones_for]
active_word_list = list(map(formatter, valid_homophones_reordered))
if (
is_selection
and len(active_word_list) == 2
and quick_replace
and not force_raise
):
if word_to_find_homophones_for == active_word_list[0].lower():
new = active_word_list[1]
else:
new = active_word_list[0]
clip.set(new)
actions.edit.paste()
return
ctx.tags = ["user.homophones_open"]
show_help = False
gui.show()
@imgui.open(x=main_screen.x + main_screen.width / 2.6, y=main_screen.y)
def gui(gui: imgui.GUI):
global active_word_list
if show_help:
gui.text("Homophone help - todo")
else:
gui.text("Select a homophone")
gui.line()
index = 1
for word in active_word_list:
if gui.button(f"Choose {index}: {word}"):
actions.insert(actions.user.homophones_select(index))
actions.user.homophones_hide()
index = index + 1
if gui.button("Phones (hide | exit)"):
actions.user.homophones_hide()
def show_help_gui():
global show_help
show_help = True
gui.show()
@mod.capture(rule="{self.homophones_canonicals}")
def homophones_canonical(m) -> str:
"Returns a single string"
return m.homophones_canonicals
@mod.action_class
class Actions:
def homophones_hide():
"""Hides the homophones display"""
close_homophones()
def homophones_show(m: str):
"""Show the homophones display"""
raise_homophones(m, False, False)
def homophones_show_auto():
"""Show homophones for selection, or current word if selection is empty."""
text = actions.edit.selected_text()
if text:
raise_homophones(text, False, True)
else:
actions.edit.select_word()
actions.user.homophones_show_selection()
def homophones_show_selection():
"""Show the homophones display for the selected text"""
raise_homophones(actions.edit.selected_text(), False, True)
def homophones_force_show(m: str):
"""Show the homophones display forcibly"""
raise_homophones(m, True, False)
def homophones_force_show_selection():
"""Show the homophones display for the selected text forcibly"""
raise_homophones(actions.edit.selected_text(), True, True)
def homophones_select(number: int) -> str:
"""selects the homophone by number"""
if number <= len(active_word_list) and number > 0:
return active_word_list[number - 1]
error = "homophones.py index {} is out of range (1-{})".format(
number, len(active_word_list)
)
app.notify(error)
raise error
def homophones_get(word: str) -> [str] or None:
"""Get homophones for the given word"""
word = word.lower()
if word in all_homophones:
return all_homophones[word]
return None
ctx_homophones_open = Context()
ctx_homophones_open.matches = """
tag: user.homophones_open
"""
@ctx_homophones_open.action_class("user")
class UserActions:
def choose(number_small: int):
"""Choose the nth homophone"""
result = actions.user.homophones_select(number_small)
actions.insert(result)
actions.user.homophones_hide()
@@ -0,0 +1,19 @@
phones <user.homophones_canonical>: user.homophones_show(homophones_canonical)
phones that: user.homophones_show_auto()
phones force <user.homophones_canonical>:
user.homophones_force_show(homophones_canonical)
phones force: user.homophones_force_show_selection()
phones (hide | exit): user.homophones_hide()
phones word:
edit.select_word()
user.homophones_show_selection()
phones [<user.ordinals>] word left:
n = ordinals or 1
user.words_left(n - 1)
edit.extend_word_left()
user.homophones_show_selection()
phones [<user.ordinals>] word right:
n = ordinals or 1
user.words_right(n - 1)
edit.extend_word_right()
user.homophones_show_selection()
@@ -0,0 +1,7 @@
tag: user.homophones_open
-
choose <user.formatters> <number_small>:
result = user.homophones_select(number_small)
insert(user.formatted_text(result, formatters))
user.homophones_hide()
+6
View File
@@ -0,0 +1,6 @@
list: user.arrow_key
-
down: down
left: left
right: right
up: up
@@ -0,0 +1,27 @@
list: user.function_key
-
f one: f1
f two: f2
f three: f3
f four: f4
f five: f5
f six: f6
f seven: f7
f eight: f8
f nine: f9
f ten: f10
f eleven: f11
f twelve: f12
f thirteen: f13
f fourteen: f14
f fifteen: f15
f sixteen: f16
f seventeen: f17
f eighteen: f18
f nineteen: f19
f twenty: f20
# these f keys are not supported by all platforms (eg Mac) and are disabled by default
#f twenty one: f21
#f twenty two: f22
#f twenty three: f23
#f twenty four: f24
+21
View File
@@ -0,0 +1,21 @@
list: user.keypad_key
-
key pad zero: keypad_0
key pad one: keypad_1
key pad two: keypad_2
key pad three: keypad_3
key pad four: keypad_4
key pad five: keypad_5
key pad six: keypad_6
key pad seven: keypad_7
key pad eight: keypad_8
key pad nine: keypad_9
key pad point: keypad_decimal
key pad plus: keypad_plus
key pad minus: keypad_minus
key pad star: keypad_multiply
key pad slash: keypad_divide
key pad equals: keypad_equals
key pad clear: keypad_clear
key pad enter: keypad_enter
+128
View File
@@ -0,0 +1,128 @@
from talon import Context, Module, actions, app
from .symbols import (
dragon_punctuation_dict,
punctuation_dict,
symbol_key_dict,
)
mod = Module()
ctx = Context()
ctx_dragon = Context()
ctx_dragon.matches = r"""
speech.engine: dragon
"""
mod.list("letter", desc="The spoken phonetic alphabet")
mod.list("symbol_key", desc="All symbols from the keyboard")
mod.list("arrow_key", desc="All arrow keys")
mod.list("number_key", desc="All number keys")
mod.list("modifier_key", desc="All modifier keys")
mod.list("function_key", desc="All function keys")
mod.list("special_key", desc="All special keys")
mod.list("keypad_key", desc="All keypad keys")
mod.list("punctuation", desc="words for inserting punctuation into text")
@mod.capture(rule="{self.modifier_key}+")
def modifiers(m) -> str:
"One or more modifier keys"
return "-".join(m.modifier_key_list)
@mod.capture(rule="{self.arrow_key}")
def arrow_key(m) -> str:
"One directional arrow key"
return m.arrow_key
@mod.capture(rule="<self.arrow_key>+")
def arrow_keys(m) -> str:
"One or more arrow keys separated by a space"
return str(m)
@mod.capture(rule="{self.number_key}")
def number_key(m) -> str:
"One number key"
return m.number_key
@mod.capture(rule="{self.keypad_key}")
def keypad_key(m) -> str:
"One keypad key"
return m.keypad_key
@mod.capture(rule="{self.letter}")
def letter(m) -> str:
"One letter key"
return m.letter
@mod.capture(rule="{self.special_key}")
def special_key(m) -> str:
"One special key"
return m.special_key
@mod.capture(rule="{self.symbol_key}")
def symbol_key(m) -> str:
"One symbol key"
return m.symbol_key
@mod.capture(rule="{self.function_key}")
def function_key(m) -> str:
"One function key"
return m.function_key
@mod.capture(rule="( <self.letter> | <self.number_key> | <self.symbol_key> )")
def any_alphanumeric_key(m) -> str:
"any alphanumeric key"
return str(m)
@mod.capture(
rule="( <self.letter> | <self.number_key> | <self.symbol_key> "
"| <self.arrow_key> | <self.function_key> | <self.special_key> | <self.keypad_key>)"
)
def unmodified_key(m) -> str:
"A single key with no modifiers"
return str(m)
@mod.capture(rule="{self.modifier_key}* <self.unmodified_key>")
def key(m) -> str:
"A single key with optional modifiers"
try:
mods = m.modifier_key_list
except AttributeError:
mods = []
return "-".join(mods + [m.unmodified_key])
@mod.capture(rule="<self.key>+")
def keys(m) -> str:
"A sequence of one or more keys with optional modifiers"
return " ".join(m.key_list)
@mod.capture(rule="{self.letter}+")
def letters(m) -> str:
"Multiple letter keys"
return "".join(m.letter_list)
@mod.action_class
class Actions:
def get_punctuation_words():
"""Gets the user.punctuation list"""
return punctuation_dict
ctx.lists["user.punctuation"] = punctuation_dict
ctx.lists["user.symbol_key"] = symbol_key_dict
ctx_dragon.lists["user.punctuation"] = dragon_punctuation_dict
+12
View File
@@ -0,0 +1,12 @@
<user.letter>: key(letter)
(ship | uppercase) <user.letters> [(lowercase | sunk)]:
user.insert_formatted(letters, "ALL_CAPS")
<user.symbol_key>: key(symbol_key)
<user.function_key>: key(function_key)
<user.special_key>: key(special_key)
<user.keypad_key>: key(keypad_key)
<user.modifiers> <user.unmodified_key>: key("{modifiers}-{unmodified_key}")
# for key combos consisting only of modifiers, eg. `press super`.
press <user.modifiers>: key(modifiers)
# for consistency with dictation mode and explicit arrow keys if you need them.
press <user.keys>: key(keys)
+30
View File
@@ -0,0 +1,30 @@
list: user.letter
-
# for common alternative spoken forms for letters, visit
# https://talon.wiki/Resource%20Hub/Speech%20Recognition/improving_recognition_accuracy#collected-alternatives-to-the-default-alphabet
air: a
bat: b
cap: c
drum: d
each: e
fine: f
gust: g
harp: h
sit: i
jury: j
crunch: k
look: l
made: m
near: n
odd: o
pit: p
quench: q
red: r
sun: s
trap: t
urge: u
vest: v
whale: w
plex: x
yank: y
zip: z
@@ -0,0 +1,12 @@
list: user.modifier_key
os: mac
-
alt: alt
control: ctrl
shift: shift
super: cmd
command: cmd
option: alt
function: fn
globe: fn
@@ -0,0 +1,18 @@
list: user.special_key
os: mac
-
end: end
home: home
minus: minus
return: return
enter: keypad_enter
page down: pagedown
page up: pageup
escape: escape
space: space
tab: tab
insert: insert
wipe: backspace
delete: backspace
forward delete: delete
+12
View File
@@ -0,0 +1,12 @@
list: user.number_key
-
zero: 0
one: 1
two: 2
three: 3
four: 4
five: 5
six: 6
seven: 7
eight: 8
nine: 9
+102
View File
@@ -0,0 +1,102 @@
# fmt: off
# define the spoken forms for symbols in command and dictation mode
punctuation_dict = {}
# for dragon, we add a couple of mappings that don't work for conformer
# i.e. dragon supports some actual symbols as the spoken form
dragon_punctuation_dict = {
"`": "`",
",": ",",
}
# define the spoken forms for symbols that are intended for command mode only
symbol_key_dict = {}
# define spoken form for symbols for use in create_spoken_forms.py functionality
# we define a handful of symbol only. at present, this is restricted to one entry per symbol.
symbols_for_create_spoken_forms = {
# for application names like "Movies & TV"
"and": "&",
# for emails
"at": "@",
"dot": ".",
# for application names like "notepad++"
"plus": "+",
}
class Symbol:
character: str
command_and_dictation_forms: list[str] = None
command_forms: list[str] = None
def __init__(
self, character: str, command_and_dictation_forms=None, command_forms=None
):
self.character = character
if command_and_dictation_forms:
self.command_and_dictation_forms = (
[command_and_dictation_forms]
if isinstance(command_and_dictation_forms, str)
else command_and_dictation_forms
)
if command_forms:
self.command_forms = (
[command_forms] if isinstance(command_forms, str) else command_forms
)
currency_symbols = [
Symbol("$", ["dollar sign"], ["dollar"]),
Symbol("£", ["pound sign"], ["pound"]),
]
symbols = [
Symbol("`", ["back tick"], ["grave"]),
Symbol(",", ["comma", "coma"]),
Symbol(".", ["period", "full stop"], ["dot", "point"]),
Symbol(";", ["semicolon"]),
Symbol(":", ["colon"]),
Symbol("?", ["question mark"], ["question"]),
Symbol("!", ["exclamation mark", "exclamation point"], ["bang"]),
Symbol("*", ["asterisk"], ["star"]),
Symbol("#", ["hash sign", "number sign"], ["hash"]),
Symbol("%", ["percent sign"], ["percent"]),
Symbol("@", ["at symbol", "at sign"]),
Symbol("&", ["ampersand", "and sign"], ["amper"]),
Symbol("-", ["hyphen"], ["minus", "dash"]),
Symbol("=", None, ["equals"]),
Symbol("+", None, ["plus"]),
Symbol("~", None, ["tilde"]),
Symbol("_", None, ["down score", "underscore"]),
Symbol("(", ["paren", "L paren", "left paren"], None),
Symbol(")", ["R paren", "right paren"], None),
Symbol("[", None,["brack", "L brack", "bracket", "L bracket", "left bracket", "square", "L square", "left square",],),
Symbol("]", None, ["R brack", "R bracket", "right bracket", "R square", "right square"]),
Symbol("/", ["forward slash"], ["slash"]),
Symbol("\\", None, ["backslash"]),
Symbol("{", None, ["brace", "L brace", "left brace", "curly bracket", "left curly bracket"],),
Symbol("}", None, ["R brace", "right brace","R curly bracket", "right curly bracket"]),
Symbol("<", None, ["angle", "L Angle", "left angle", "less than"]),
Symbol(">", None, ["rangle", "R angle", "right angle", "greater than"]),
Symbol("^", None, ["caret"]),
Symbol("|", None, ["pipe"]),
Symbol("'", None, ["quote", "apostrophe"]),
Symbol('"', None, ["dub quote", "double quote"]),
]
# by convention, symbols should include currency symbols
symbols.extend(currency_symbols)
for symbol in symbols:
if symbol.command_and_dictation_forms:
for spoken_form in symbol.command_and_dictation_forms:
punctuation_dict[spoken_form] = symbol.character
symbol_key_dict[spoken_form] = symbol.character
dragon_punctuation_dict[spoken_form] = symbol.character
if symbol.command_forms:
for spoken_form in symbol.command_forms:
symbol_key_dict[spoken_form] = symbol.character
@@ -0,0 +1,13 @@
list: user.modifier_key
-
alt: alt
control: ctrl
roll: ctrl
shift: shift
big: shift
# super is the windows key
super: super
command: ctrl
ope: alt
@@ -0,0 +1,20 @@
list: user.special_key
os: windows
os: linux
-
end: end
home: home
minus: minus
enter: enter
page down: pagedown
page up: pageup
escape: escape
space: space
tab: tab
insert: insert
wipe: backspace
delete: backspace
forward delete: delete
menu key: menu
print screen: printscr
+14
View File
@@ -0,0 +1,14 @@
from talon import Context, Module, actions
mod = Module()
@mod.action_class
class Actions:
def choose(number_small: int):
"""Choose the nth item"""
actions.key(f"down:{number_small-1} enter")
def choose_up(number_small: int):
"""Choose the nth item up"""
actions.key(f"up:{number_small} enter")
@@ -0,0 +1,3 @@
# pick item from a dropdown
choose <number_small>: user.choose(number_small)
choose up <number_small>: user.choose_up(number_small)
+80
View File
@@ -0,0 +1,80 @@
class Language:
id: str
spoken_forms: list[str]
extensions: list[str]
def __init__(self, id: str, spoken_form: str | list[str], extensions: list[str]):
self.id = id
self.spoken_forms = (
[spoken_form] if isinstance(spoken_form, str) else spoken_form
)
self.extensions = extensions
# Maps code language identifiers, names and file extensions. Only put languages
# here which have a supported language mode; that's why there are so many
# commented out entries.
code_languages = [
# Language("assembly", "assembly", ["asm", "s"]),
# Language("bash", "bash", ["sh", "bashbook"]),
Language("batch", "batch", ["bat"]),
Language("c", "see", ["c", "h"]),
# Language("cmake", "see make", ["cmake"]),
Language("csharp", "see sharp", ["cs"]),
Language("css", "c s s", ["css"]),
# Language("elisp", "elisp", ["el"]),
Language("elixir", "elixir", ["ex"]),
# Language("elm", "elm", ["elm"]),
Language("gdb", "g d b", ["gdb"]),
Language("go", ["go lang", "go language"], ["go"]),
Language("java", "java", ["java"]),
Language("javascript", "java script", ["js"]),
Language("javascriptreact", "java script react", ["jsx"]),
# Language("jsonl", "json lines", ["jsonl"]),
Language("kotlin", "kotlin", ["kt"]),
Language("lua", "lua", ["lua"]),
Language("markdown", "mark down", ["md"]),
# Language("perl", "perl", ["pl"]),
Language("php", "p h p", ["php"]),
# Language("powershell", "power shell", ["ps1"]),
Language("protobuf", "proto buf", ["proto"]),
Language("python", "python", ["py"]),
Language("r", "are language", ["r"]),
# Language("racket", "racket", ["rkt"]),
Language("ruby", "ruby", ["rb"]),
Language("rust", "rust", ["rs"]),
Language("scala", "scala", ["scala"]),
Language("scss", "scss", ["scss"]),
# Language("snippets", "snippets", ["snippets"]),
Language("sql", "sql", ["sql"]),
Language("stata", "stata", ["do", "ado"]),
Language("talon", "talon", ["talon"]),
Language("talonlist", "talon list", ["talon-list"]),
Language("terraform", "terraform", ["tf"]),
Language("tex", ["tech", "lay tech", "latex"], ["tex"]),
Language("typescript", "type script", ["ts"]),
Language("typescriptreact", "type script react", ["tsx"]),
# Language("vba", "vba", ["vba"]),
Language("vimscript", "vim script", ["vim", "vimrc"]),
# These languages doesn't actually have a language mode, but we do have snippets.
Language("cpp", "see plus plus", ["cpp", "hpp"]),
Language("csv", "csv", ["csv"]),
Language("html", "html", ["html"]),
Language("json", "json", ["json"]),
Language("shellscript", "shell script", ["sh"]),
Language("xml", "xml", ["xml"]),
]
# Files without specific extensions but are associated with languages
# Maps full filename to language identifiers
code_special_file_map = {
"CMakeLists.txt": "cmake",
"Makefile": "make",
"Dockerfile": "docker",
"meson.build": "meson",
".bashrc": "bash",
".zshrc": "zsh",
"PKGBUILD": "pkgbuild",
".vimrc": "vimscript",
"vimrc": "vimscript",
}
@@ -0,0 +1,13 @@
mode: command
mode: dictation
-
^dictation mode$:
mode.disable("sleep")
mode.disable("command")
mode.enable("dictation")
user.code_clear_language_mode()
user.gdb_disable()
^command mode$:
mode.disable("sleep")
mode.disable("dictation")
mode.enable("command")
+41
View File
@@ -0,0 +1,41 @@
from talon import Context, Module, actions
mod = Module()
mod.tag(
"deep_sleep",
desc="Tag for enabling deep sleep, requiring a longer wakeup command (defined in `sleep_mode_deep.talon`)",
)
ctx = Context()
@mod.action_class
class Actions:
def deep_sleep_enable():
"""Enable deep sleep.
Deep sleep requires a longer wakeup command to exit sleep
mode, helping prevent unintended wakeups from conversations,
meetings, listening to videos, etc.
Instead of invoking this action directly, consider enabling
the `user.deep_sleep` tag in applications where unwanted
wakeups are more likely or problematic, such as meeting
apps. With this tag active, any sleep command triggers deep
sleep.
You can also manually activate deep sleep by defining a custom
voice command using this action.
Note: If the user.deep_sleep_disable action is not used to
wake up from deep sleep, then the deep sleep tag will stay
active.
"""
ctx.tags = ["user.deep_sleep"]
actions.speech.disable()
def deep_sleep_disable():
"""Disable deep sleep"""
ctx.tags = []
actions.speech.enable()
+76
View File
@@ -0,0 +1,76 @@
mode: dictation
-
^press <user.modifiers>$: key(modifiers)
^press <user.keys>$: key(keys)
# Everything here should call `user.dictation_insert()` instead of `insert()`, to correctly auto-capitalize/auto-space.
<user.raw_prose>: user.dictation_insert(raw_prose)
cap: user.dictation_format_cap()
# Hyphenated variants are for Dragon.
(no cap | no-caps): user.dictation_format_no_cap()
(no space | no-space): user.dictation_format_no_space()
^cap that$: user.dictation_reformat_cap()
^(no cap | no-caps) that$: user.dictation_reformat_no_cap()
^(no space | no-space) that$: user.dictation_reformat_no_space()
# Navigation
go up <number_small> (line | lines):
edit.up()
repeat(number_small - 1)
go down <number_small> (line | lines):
edit.down()
repeat(number_small - 1)
go left <number_small> (word | words):
edit.word_left()
repeat(number_small - 1)
go right <number_small> (word | words):
edit.word_right()
repeat(number_small - 1)
go line start: edit.line_start()
go line end: edit.line_end()
# Selection
select left <number_small> (word | words):
edit.extend_word_left()
repeat(number_small - 1)
select right <number_small> (word | words):
edit.extend_word_right()
repeat(number_small - 1)
select left <number_small> (character | characters):
edit.extend_left()
repeat(number_small - 1)
select right <number_small> (character | characters):
edit.extend_right()
repeat(number_small - 1)
clear left <number_small> (word | words):
edit.extend_word_left()
repeat(number_small - 1)
edit.delete()
clear right <number_small> (word | words):
edit.extend_word_right()
repeat(number_small - 1)
edit.delete()
clear left <number_small> (character | characters):
edit.extend_left()
repeat(number_small - 1)
edit.delete()
clear right <number_small> (character | characters):
edit.extend_right()
repeat(number_small - 1)
edit.delete()
# Formatting
formatted <user.format_text>: user.dictation_insert_raw(format_text)
^format selection <user.formatters>$: user.formatters_reformat_selection(formatters)
# Corrections
nope that | scratch that: user.clear_last_phrase()
(nope | scratch) selection: edit.delete()
select that: user.select_last_phrase()
spell that <user.letters>: user.dictation_insert(letters)
spell that <user.formatters> <user.letters>:
result = user.formatted_text(letters, formatters)
user.dictation_insert_raw(result)
# Escape, type things that would otherwise be commands
^escape <user.text>$: user.dictation_insert(user.text)
+8
View File
@@ -0,0 +1,8 @@
#defines modes specific to Dragon.
speech.engine: dragon
mode: all
-
# wakes Dragon on Mac, deactivates talon speech commands
dragon mode: user.dragon_mode()
#sleep dragon on Mac, activates talon speech commands
talon mode: user.talon_mode()
+73
View File
@@ -0,0 +1,73 @@
from talon import Context, Module, actions, app
from .code_languages import code_languages, code_special_file_map
mod = Module()
ctx = Context()
ctx_forced = Context()
ctx_forced.matches = r"""
tag: user.code_language_forced
"""
mod.tag("code_language_forced", "This tag is active when a language mode is forced")
mod.list("language_mode", desc="Name of a programming language mode.")
# Maps spoken forms to language ids
ctx.lists["user.language_mode"] = {
spoken_form: language.id
for language in code_languages
for spoken_form in language.spoken_forms
}
# Maps extension to language ids
extension_lang_map = {
f".{ext}": lang.id for lang in code_languages for ext in lang.extensions
}
language_ids = {lang.id for lang in code_languages}
forced_language = ""
@ctx.action_class("code")
class CodeActions:
def language():
file_name = actions.win.filename()
if file_name in code_special_file_map:
return code_special_file_map[file_name]
file_extension = actions.win.file_ext().lower()
return extension_lang_map.get(file_extension, "")
@ctx_forced.action_class("code")
class ForcedCodeActions:
def language():
return forced_language
@mod.action_class
class Actions:
def code_set_language_mode(language: str):
"""Sets the active language mode, and disables extension matching"""
global forced_language
assert language in language_ids
forced_language = language
# Update tags to force a context refresh. Otherwise `code.language` will not update.
# Necessary to first set an empty list otherwise you can't move from one forced language to another.
ctx.tags = []
ctx.tags = ["user.code_language_forced"]
def code_clear_language_mode():
"""Clears the active language mode, and re-enables code.language: extension matching"""
global forced_language
forced_language = ""
ctx.tags = []
def code_show_forced_language_mode():
"""Show the active language for this context"""
if forced_language:
app.notify(f"Forced language: {forced_language}")
else:
app.notify("No language forced")
@@ -0,0 +1,3 @@
^force {user.language_mode}$: user.code_set_language_mode(language_mode)
show [forced] language mode: user.code_show_forced_language_mode()
^clear language mode$: user.code_clear_language_mode()
+64
View File
@@ -0,0 +1,64 @@
from talon import Context, Module, actions, app, speech_system
mod = Module()
ctx_sleep = Context()
ctx_awake = Context()
modes = {
"presentation": "a more strict form of sleep where only a more strict wake up command works",
}
for key, value in modes.items():
mod.mode(key, value)
ctx_sleep.matches = r"""
mode: sleep
"""
ctx_awake.matches = r"""
not mode: sleep
"""
@ctx_sleep.action_class("speech")
class ActionsSleepMode:
def disable():
actions.app.notify("Talon is already asleep")
@ctx_awake.action_class("speech")
class ActionsAwakeMode:
def enable():
actions.app.notify("Talon is already awake")
@mod.action_class
class Actions:
def talon_mode():
"""For windows and Mac with Dragon, enables Talon commands and Dragon's command mode."""
actions.speech.enable()
engine = speech_system.engine.name
# app.notify(engine)
if "dragon" in engine:
if app.platform == "mac":
actions.user.dragon_engine_sleep()
elif app.platform == "windows":
actions.user.dragon_engine_wake()
# note: this may not do anything for all versions of Dragon. Requires Pro.
actions.user.dragon_engine_command_mode()
def dragon_mode():
"""For windows and Mac with Dragon, disables Talon commands and exits Dragon's command mode"""
engine = speech_system.engine.name
# app.notify(engine)
if "dragon" in engine:
# app.notify("dragon mode")
actions.speech.disable()
if app.platform == "mac":
actions.user.dragon_engine_wake()
elif app.platform == "windows":
actions.user.dragon_engine_wake()
# note: this may not do anything for all versions of Dragon. Requires Pro.
actions.user.dragon_engine_normal_mode()
@@ -0,0 +1,34 @@
mode: command
mode: dictation
mode: sleep
not speech.engine: dragon
-
# The optional <phrase> afterwards allows these to match even if you say arbitrary text
# after this command, without having to wait for the speech timeout.
# This is handy because you often need to put Talon asleep in order to immediately
# talk to humans, and it's annoying to have to say "sleep all", wait for the timeout,
# and then resume your conversation.
# With this, you can say "sleep all hey bob" and Talon will immediately go to
# sleep and ignore "hey bob". Note that subtitles will show "sleep all hey bob",
# because it's part of the rule definition, but "hey bob" will be ignored, because
# we don't do anything with the <phrase> in the body of the command.
# We define this *only* if the speech engine isn't Dragon, because if you're using Dragon,
# "go to sleep" is used to specifically control Dragon, and not affect Talon.
#
# It's a useful and well known command, though, so if you're using any other speech
# engine, this controls Talon.
^go to sleep [<phrase>]$: speech.disable()
^talon sleep [<phrase>]$:
speech.disable()
user.deprecate_command("2025-06-25", "talon sleep (without dragon)", "go to sleep")
^sleep all [<phrase>]$:
user.switcher_hide_running()
user.history_disable()
user.homophones_hide()
user.help_hide()
user.mouse_sleep()
speech.disable()
+9
View File
@@ -0,0 +1,9 @@
mode: sleep
-
settings():
# Stop continuous scroll/gaze scroll with a pop
user.mouse_enable_pop_stops_scroll = false
# Stop pop click with 'control mouse' mode
user.mouse_enable_pop_click = 0
# Stop mouse scroll down using hiss noise
user.mouse_enable_hiss_scroll = false
@@ -0,0 +1,5 @@
mode: sleep
tag: user.deep_sleep
-
^wake up and listen$: user.deep_sleep_disable()
@@ -0,0 +1,25 @@
mode: all
speech.engine: dragon
-
# The optional <phrase> afterwards allows these to match even if you say arbitrary text
# after this command, without having to wait for the speech timeout.
# This is handy because you often need to put Talon asleep in order to immediately
# talk to humans, and it's annoying to have to say "sleep all", wait for the timeout,
# and then resume your conversation.
# With this, you can say "sleep all hey bob" and Talon will immediately go to
# sleep and ignore "hey bob". Note that subtitles will show "sleep all hey bob",
# because it's part of the rule definition, but "hey bob" will be ignored, because
# we don't do anything with the <phrase> in the body of the command.
^talon sleep [<phrase>]$: speech.disable()
^talon wake [<phrase>]$: speech.enable()
^sleep all [<phrase>]$:
user.switcher_hide_running()
user.history_disable()
user.homophones_hide()
user.help_hide()
user.mouse_sleep()
speech.disable()
user.dragon_engine_sleep()
@@ -0,0 +1,18 @@
mode: command
mode: dictation
mode: sleep
not speech.engine: dragon
not tag: user.deep_sleep
-
# We define this *only* if the speech engine isn't Dragon, because if you're using Dragon,
# "wake up" is used to specifically control Dragon, and not affect Talon.
#
# It's a useful and well known command, though, so if you're using any other speech
# engine, this controls Talon.
^(wake up)+$: speech.enable()
^talon wake [<phrase>]$:
speech.enable()
user.deprecate_command("2025-06-25", "talon wake (without dragon)", "wake up")
@@ -0,0 +1,44 @@
import time
from talon import Context, Module, actions, settings
ctx = Context()
mod = Module()
mod.tag("pop_twice_to_wake", desc="tag for enabling pop twice to wake in sleep mode")
mod.setting(
"double_pop_speed_minimum",
type=float,
desc="""Shortest time in seconds to accept a second pop to trigger additional actions""",
default=0.1,
)
mod.setting(
"double_pop_speed_maximum",
type=float,
desc="""Longest time in seconds to accept a second pop to trigger additional actions""",
default=0.3,
)
ctx.matches = r"""
mode: sleep
and tag: user.pop_twice_to_wake
"""
time_last_pop = 0
@ctx.action_class("user")
class UserActions:
def noise_trigger_pop():
# Since zoom mouse is registering against noise.register("pop", on_pop), let that take priority
if actions.tracking.control_zoom_enabled():
return
global time_last_pop
double_pop_speed_minimum = settings.get("user.double_pop_speed_minimum")
double_pop_speed_maximum = settings.get("user.double_pop_speed_maximum")
delta = time.perf_counter() - time_last_pop
if delta >= double_pop_speed_minimum and delta <= double_pop_speed_maximum:
actions.speech.enable()
time_last_pop = time.perf_counter()
@@ -0,0 +1,30 @@
mode: sleep
not tag: user.deep_sleep
-
#================================================================================
# Commands to wake Talon
#================================================================================
# Note: these have repeaters on them (+) to work around an issue where, in sleep mode,
# you can get into a situation where these commands are difficult to trigger.
# These commands are fully anchored (^ and $), which means that there must be
# silence before and after saying them in order for them to recognize (this reduces
# false positives during normal sleep mode, normally a good thing).
# However, ignored background speech during sleep mode also counts as an utterance.
# Thus, if you say "blah blah blah talon wake", these won't trigger, because "blah
# blah blah" was part of the same utterance. You have to say "blah blah blah" <pause,
# wait for speech timeout>, "talon wake" <pause, wait for speech timeout>.
# Sometimes people would forget the second pause, notice things weren't working, and
# say "talon wake" over and over again before the speech timeout ever gets hit, which
# means that these won't recognize. The (+) handles this case, so if you say
# <pause> "talon wake talon wake" <pause>, it'll still work.
^(welcome back)+$:
user.mouse_wake()
user.history_enable()
user.talon_mode()
@@ -0,0 +1,6 @@
mode: sleep
speech.engine: wav2letter
-
#this exists solely to prevent talon from waking up super easily in sleep mode at the moment with wav2letter
#you probably shouldn't have any other commands here
<phrase>: skip()
+313
View File
@@ -0,0 +1,313 @@
# courtesy of https://github.com/timo/
# see https://github.com/timo/talon_scripts
import math
from typing import Union
from talon import Context, Module, actions, canvas, cron, ctrl, screen, settings, ui
from talon.skia import Paint, Rect
from talon.types.point import Point2d
mod = Module()
mod.setting(
"grid_narrow_expansion",
type=int,
default=0,
desc="""After narrowing, grow the new region by this many pixels in every direction, to make things immediately on edges easier to hit, and when the grid is at its smallest, it allows you to still nudge it around""",
)
mod.setting(
"grids_put_one_bottom_left",
type=bool,
default=False,
desc="""Allows you to switch mouse grid and friends between a computer numpad and a phone numpad (the number one goes on the bottom left or the top left)""",
)
mod.tag("mouse_grid_showing", desc="Tag indicates whether the mouse grid is showing")
mod.tag(
"mouse_grid_enabled",
desc="Deprecated: do not use. Activates legacy m grid command",
)
ctx = Context()
class MouseSnapNine:
def __init__(self):
self.screen = None
self.rect = None
self.history = []
self.img = None
self.mcanvas = None
self.active = False
self.count = 0
self.was_zoom_mouse_active = False
self.was_control_mouse_active = False
self.was_control1_mouse_active = False
def setup(self, *, rect: Rect = None, screen_num: int = None):
screens = ui.screens()
# each if block here might set the rect to None to indicate failure
if rect is not None:
try:
screen = ui.screen_containing(*rect.center)
except Exception:
rect = None
if rect is None and screen_num is not None:
screen = screens[screen_num % len(screens)]
rect = screen.rect
if rect is None:
screen = screens[0]
rect = screen.rect
self.rect = rect.copy()
self.screen = screen
self.count = 0
self.img = None
if self.mcanvas is not None:
self.mcanvas.close()
self.mcanvas = canvas.Canvas.from_screen(screen)
if self.active:
self.mcanvas.register("draw", self.draw)
self.mcanvas.freeze()
def show(self):
if self.active:
return
# noinspection PyUnresolvedReferences
if actions.tracking.control_zoom_enabled():
self.was_zoom_mouse_active = True
actions.tracking.control_zoom_toggle(False)
if actions.tracking.control_enabled():
self.was_control_mouse_active = True
actions.tracking.control_toggle(False)
if actions.tracking.control1_enabled():
self.was_control1_mouse_active = True
actions.tracking.control1_toggle(False)
self.mcanvas.register("draw", self.draw)
self.mcanvas.freeze()
self.active = True
return
def close(self):
if not self.active:
return
self.mcanvas.unregister("draw", self.draw)
self.mcanvas.close()
self.mcanvas = None
self.img = None
self.active = False
if self.was_control_mouse_active and not actions.tracking.control_enabled():
actions.tracking.control_toggle(True)
if self.was_control1_mouse_active and not actions.tracking.control1_enabled():
actions.tracking.control1_toggle(True)
if self.was_zoom_mouse_active and not actions.tracking.control_zoom_enabled():
actions.tracking.control_zoom_toggle(True)
self.was_zoom_mouse_active = False
self.was_control_mouse_active = False
self.was_control1_mouse_active = False
def draw(self, canvas):
paint = canvas.paint
def draw_grid(offset_x, offset_y, width, height):
canvas.draw_line(
offset_x + width // 3,
offset_y,
offset_x + width // 3,
offset_y + height,
)
canvas.draw_line(
offset_x + 2 * width // 3,
offset_y,
offset_x + 2 * width // 3,
offset_y + height,
)
canvas.draw_line(
offset_x,
offset_y + height // 3,
offset_x + width,
offset_y + height // 3,
)
canvas.draw_line(
offset_x,
offset_y + 2 * height // 3,
offset_x + width,
offset_y + 2 * height // 3,
)
def draw_crosses(offset_x, offset_y, width, height):
for row in range(0, 2):
for col in range(0, 2):
cx = offset_x + width / 6 + (col + 0.5) * width / 3
cy = offset_y + height / 6 + (row + 0.5) * height / 3
canvas.draw_line(cx - 10, cy, cx + 10, cy)
canvas.draw_line(cx, cy - 10, cx, cy + 10)
grid_stroke = 1
def draw_text(offset_x, offset_y, width, height):
canvas.paint.text_align = canvas.paint.TextAlign.CENTER
for row in range(3):
for col in range(3):
text_string = ""
if settings.get("user.grids_put_one_bottom_left"):
text_string = f"{(2 - row)*3+col+1}"
else:
text_string = f"{row*3+col+1}"
text_rect = canvas.paint.measure_text(text_string)[1]
background_rect = text_rect.copy()
background_rect.center = Point2d(
offset_x + width / 6 + col * width / 3,
offset_y + height / 6 + row * height / 3,
)
background_rect = background_rect.inset(-4)
paint.color = "9999995f"
paint.style = Paint.Style.FILL
canvas.draw_rect(background_rect)
paint.color = "00ff00ff"
canvas.draw_text(
text_string,
offset_x + width / 6 + col * width / 3,
offset_y + height / 6 + row * height / 3 + text_rect.height / 2,
)
if self.count < 2:
paint.color = "00ff007f"
for which in range(1, 10):
gap = 35 - self.count * 10
if not self.active:
gap = 45
draw_crosses(*self.calc_narrow(which, self.rect))
paint.stroke_width = grid_stroke
if self.active:
paint.color = "ff0000ff"
else:
paint.color = "000000ff"
if self.count >= 2:
aspect = self.rect.width / self.rect.height
if aspect >= 1:
w = self.screen.width / 3
h = w / aspect
else:
h = self.screen.height / 3
w = h * aspect
x = self.screen.x + (self.screen.width - w) / 2
y = self.screen.y + (self.screen.height - h) / 2
self.draw_zoom(canvas, x, y, w, h)
draw_grid(x, y, w, h)
draw_text(x, y, w, h)
else:
draw_grid(self.rect.x, self.rect.y, self.rect.width, self.rect.height)
paint.textsize += 12 - self.count * 3
draw_text(self.rect.x, self.rect.y, self.rect.width, self.rect.height)
def calc_narrow(self, which, rect):
rect = rect.copy()
bdr = settings.get("user.grid_narrow_expansion")
row = int(which - 1) // 3
col = int(which - 1) % 3
if settings.get("user.grids_put_one_bottom_left"):
row = 2 - row
rect.x += int(col * rect.width // 3) - bdr
rect.y += int(row * rect.height // 3) - bdr
rect.width = (rect.width // 3) + bdr * 2
rect.height = (rect.height // 3) + bdr * 2
return rect
def narrow(self, which, move=True):
if which < 1 or which > 9:
return
self.save_state()
rect = self.calc_narrow(which, self.rect)
# check count so we don't bother zooming in _too_ far
if self.count < 5:
self.rect = rect.copy()
self.count += 1
if move:
ctrl.mouse_move(*rect.center)
if self.count >= 2:
self.update_screenshot()
else:
self.mcanvas.freeze()
def update_screenshot(self):
def finish_capture():
self.img = screen.capture_rect(self.rect)
self.mcanvas.freeze()
self.mcanvas.hide()
cron.after("16ms", finish_capture)
def draw_zoom(self, canvas, x, y, w, h):
if self.img:
src = Rect(0, 0, self.img.width, self.img.height)
dst = Rect(x, y, w, h)
canvas.draw_image_rect(self.img, src, dst)
def narrow_to_pos(self, x, y):
col_size = int(self.width // 3)
row_size = int(self.height // 3)
col = math.floor((x - self.rect.x) / col_size)
row = math.floor((y - self.rect.x) / row_size)
self.narrow(1 + col + 3 * row, move=False)
def save_state(self):
self.history.append((self.count, self.rect.copy()))
def go_back(self):
# FIXME: need window and screen tracking
self.count, self.rect = self.history.pop()
self.mcanvas.freeze()
mg = MouseSnapNine()
@mod.action_class
class GridActions:
def grid_activate():
"""Show mouse grid"""
if not mg.mcanvas:
mg.setup()
mg.show()
ctx.tags = ["user.mouse_grid_showing"]
def grid_place_window():
"""Places the grid on the currently active window"""
mg.setup(rect=ui.active_window().rect)
def grid_reset():
"""Resets the grid to fill the whole screen again"""
if mg.active:
mg.setup()
def grid_select_screen(screen: int):
"""Brings up mouse grid"""
mg.setup(screen_num=screen - 1)
mg.show()
def grid_narrow_list(digit_list: list[str]):
"""Choose fields multiple times in a row"""
for d in digit_list:
actions.self.grid_narrow(int(d))
def grid_narrow(digit: Union[int, str]):
"""Choose a field of the grid and narrow the selection down"""
mg.narrow(int(digit))
def grid_go_back():
"""Sets the grid state back to what it was before the last command"""
mg.go_back()
def grid_close():
"""Close the active grid"""
ctx.tags = []
mg.close()
def grid_is_active():
"""check if grid is already active"""
return mg.active
@@ -0,0 +1,6 @@
tag: user.mouse_grid_enabled
-
M grid:
app.notify("please use the voice command 'mouse grid' instead of 'm grid'")
user.grid_select_screen(1)
user.grid_activate()
@@ -0,0 +1,15 @@
mouse grid:
user.grid_select_screen(1)
user.grid_activate()
grid win:
user.grid_place_window()
user.grid_activate()
grid <user.number_key>+:
user.grid_activate()
user.grid_narrow_list(number_key_list)
grid screen [<number>]:
user.grid_select_screen(number or 1)
user.grid_activate()
@@ -0,0 +1,8 @@
tag: user.mouse_grid_showing
-
<user.number_key>: user.grid_narrow(number_key)
grid (off | close | hide): user.grid_close()
grid reset: user.grid_reset()
grid back: user.grid_go_back()
+43
View File
@@ -0,0 +1,43 @@
from talon import Context, Module, actions
mod = Module()
mod.tag("navigation")
ctx_browser = Context()
ctx_browser.matches = r"""
tag: browser
"""
ctx_mac = Context()
ctx_mac.matches = r"""
os: mac
"""
@ctx_browser.action_class("user")
class BrowserActions:
def go_back():
actions.browser.go_back()
def go_forward():
actions.browser.go_forward()
@ctx_mac.action_class("user")
class MacActions:
def go_back():
actions.key("cmd-[")
def go_forward():
actions.key("cmd-]")
@mod.action_class
class Actions:
def go_back():
"""Navigate back"""
actions.key("alt-left")
def go_forward():
"""Navigate forward"""
actions.key("alt-right")
@@ -0,0 +1,5 @@
tag: user.navigation
-
go back: user.go_back()
go forward: user.go_forward()
+51
View File
@@ -0,0 +1,51 @@
"""
Map noises (like pop) to actions so they can have contextually differing behavior
"""
from talon import Module, actions, cron, noise, settings
mod = Module()
hiss_cron = None
mod.setting(
"hiss_scroll_debounce_time",
type=int,
default=100,
desc="How much time a hiss must last for to be considered a hiss rather than part of speech, in ms",
)
@mod.action_class
class Actions:
def noise_trigger_pop():
"""
Called when the user makes a 'pop' noise. Listen to
https://noise.talonvoice.com/static/previews/pop.mp3 for an
example.
"""
actions.skip()
def noise_trigger_hiss(active: bool):
"""
Called when the user makes a 'hiss' noise. Listen to
https://noise.talonvoice.com/static/previews/hiss.mp3 for an
example.
"""
actions.skip()
def noise_trigger_hiss_debounce(active: bool):
"""Since the hiss noise triggers while you're talking we need to debounce it"""
global hiss_cron
if active:
hiss_cron = cron.after(
str(f"{settings.get('user.hiss_scroll_debounce_time')}ms"),
lambda: actions.user.noise_trigger_hiss(active),
)
else:
cron.cancel(hiss_cron)
actions.user.noise_trigger_hiss(active)
noise.register("pop", lambda _: actions.user.noise_trigger_pop())
noise.register("hiss", noise_trigger_hiss_debounce)
+301
View File
@@ -0,0 +1,301 @@
import math
from typing import Iterator, Union
from talon import Context, Module
mod = Module()
ctx = Context()
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()
scales = "hundred thousand million billion trillion quadrillion quintillion sextillion septillion octillion nonillion decillion".split()
digits_map = {n: i for i, n in enumerate(digit_list)}
digits_map["oh"] = 0
teens_map = {n: i + 10 for i, n in enumerate(teens)}
tens_map = {n: 10 * (i + 2) for i, n in enumerate(tens)}
scales_map = {n: 10 ** (3 * (i + 1)) for i, n in enumerate(scales[1:])}
scales_map["hundred"] = 100
# Maps number words to integers values that are used to compute numeric values.
numbers_map = digits_map.copy()
numbers_map.update(teens_map)
numbers_map.update(tens_map)
numbers_map.update(scales_map)
def get_spoken_form_under_one_hundred(
start,
end,
*,
include_oh_variant_for_single_digits=False,
include_default_variant_for_single_digits=False,
include_double_digits=False,
):
"""Helper function to get dictionary of spoken forms for non-negative numbers in the range [start, end] under 100"""
result = {}
for value in range(start, end + 1):
digit_index = value % 10
if value < 10:
# oh prefix digit: "oh five"-> `05`
if include_oh_variant_for_single_digits:
result[f"oh {digit_list[digit_index]}"] = f"0{value}"
# default digit: "five" -> `5`
if include_default_variant_for_single_digits:
result[f"{digit_list[digit_index]}"] = f"{value}"
elif value < 20:
teens_index = value - 10
result[f"{teens[teens_index]}"] = f"{value}"
elif value < 100:
tens_index = math.floor(value / 10) - 2
if digit_index > 0:
spoken_form = f"{tens[tens_index]} {digit_list[digit_index]}"
else:
spoken_form = f"{tens[tens_index]}"
result[spoken_form] = f"{value}"
else:
raise ValueError(f"Value {value} is not in the range [0, 100)")
# double digits: "five one" -> `51`
if include_double_digits and value > 9:
tens_index = math.floor(value / 10)
spoken_form = f"{digit_list[tens_index]} {digit_list[digit_index]}"
result[spoken_form] = f"{value}"
return result
def parse_number(l: list[str]) -> str:
"""Parses a list of words into a number/digit string."""
l = list(scan_small_numbers(l))
for scale in scales:
l = parse_scale(scale, l)
return "".join(str(n) for n in l)
def scan_small_numbers(l: list[str]) -> Iterator[Union[str, int]]:
"""
Takes a list of number words, yields a generator of mixed numbers & strings.
Translates small number terms (<100) into corresponding numbers.
Drops all occurrences of "and".
Smashes digits onto tens words, eg. ["twenty", "one"] -> [21].
But note that "ten" and "zero" are excluded, ie:
["ten", "three"] -> [10, 3]
["fifty", "zero"] -> [50, 0]
Does nothing to scale words ("hundred", "thousand", "million", etc).
"""
# reversed so that repeated pop() visits in left-to-right order
l = [x for x in reversed(l) if x != "and"]
while l:
n = l.pop()
# fuse tens onto digits, eg. "twenty", "one" -> 21
if n in tens_map and l and digits_map.get(l[-1], 0) != 0:
d = l.pop()
yield numbers_map[n] + numbers_map[d]
# turn small number terms into corresponding numbers
elif n not in scales_map:
yield numbers_map[n]
else:
yield n
def parse_scale(scale: str, l: list[Union[str, int]]) -> list[Union[str, int]]:
"""Parses a list of mixed numbers & strings for occurrences of the following
pattern:
<multiplier> <scale> <remainder>
where <scale> is a scale word like "hundred", "thousand", "million", etc and
multiplier and remainder are numbers or strings of numbers of the
appropriate size. For example:
parse_scale("hundred", [1, "hundred", 2]) -> [102]
parse_scale("thousand", [12, "thousand", 3, 45]) -> [12345]
We assume that all scales of lower magnitude have already been parsed; don't
call parse_scale("thousand") until you've called parse_scale("hundred").
"""
scale_value = scales_map[scale]
scale_digits = len(str(scale_value))
# Split the list on the desired scale word, then parse from left to right.
left, *splits = split_list(scale, l)
for right in splits:
# (1) Figure out the multiplier by looking to the left of the scale
# word. We ignore non-integers because they are scale words that we
# haven't processed yet; this strategy means that "thousand hundred"
# gets parsed as 1,100 instead of 100,000, but "hundred thousand" is
# parsed correctly as 100,000.
before = 1 # default multiplier
if left and isinstance(left[-1], int) and left[-1] != 0:
before = left.pop()
# (2) Absorb numbers to the right, eg. in [1, "thousand", 1, 26], "1
# thousand" absorbs ["1", "26"] to make 1,126. We pull numbers off
# `right` until we fill up the desired number of digits.
after = ""
while right and isinstance(right[0], int):
next = after + str(right[0])
if len(next) >= scale_digits:
break
after = next
right.pop(0)
after = int(after) if after else 0
# (3) Push the parsed number into place, append whatever was left
# unparsed, and continue.
left.append(before * scale_value + after)
left.extend(right)
return left
def split_list(value, l: list) -> Iterator:
"""Splits a list by occurrences of a given value."""
start = 0
while True:
try:
i = l.index(value, start)
except ValueError:
break
yield l[start:i]
start = i + 1
yield l[start:]
# # ---------- TESTS (uncomment to run) ----------
# def test_number(expected, string):
# print('testing:', string)
# l = list(scan_small_numbers(string.split()))
# print(" scan --->", l)
# for scale in scales:
# old = l
# l = parse_scale(scale, l)
# if scale in old: print(" parse -->", l)
# else: assert old == l, "parse_scale should do nothing if the scale does not occur in the list"
# result = "".join(str(n) for n in l)
# assert result == parse_number(string.split())
# assert str(expected) == result, f"parsing {string!r}, expected {expected}, got {result}"
# test_number(105000, "one hundred and five thousand")
# test_number(1000000, "one thousand thousand")
# test_number(1501000, "one million five hundred one thousand")
# test_number(1501106, "one million five hundred and one thousand one hundred and six")
# test_number(123, "one two three")
# test_number(123, "one twenty three")
# test_number(104, "ten four") # borderline, but valid in some dialects
# test_number(1066, "ten sixty six") # a common way of saying years
# test_number(1906, "nineteen oh six") # year
# test_number(2001, "twenty oh one") # year
# test_number(2020, "twenty twenty")
# test_number(1001, "one thousand one")
# test_number(1010, "one thousand ten")
# test_number(123456, "one hundred and twenty three thousand and four hundred and fifty six")
# test_number(123456, "one twenty three thousand four fifty six")
# ## failing (and somewhat debatable) tests from old numbers.py
# #test_number(10000011, "one million one one")
# #test_number(100001010, "one million ten ten")
# #test_number(1050006000, "one hundred thousand and five thousand and six thousand")
# ---------- CAPTURES ----------
alt_digits = "(" + "|".join(digits_map.keys()) + ")"
alt_teens = "(" + "|".join(teens_map.keys()) + ")"
alt_tens = "(" + "|".join(tens_map.keys()) + ")"
alt_scales = "(" + "|".join(scales_map.keys()) + ")"
number_word = "(" + "|".join(numbers_map.keys()) + ")"
# don't allow numbers to start with scale words like "hundred", "thousand", etc
leading_words = numbers_map.keys() - scales_map.keys()
leading_words -= {"oh", "o"} # comment out to enable bare/initial "oh"
number_word_leading = f"({'|'.join(leading_words)})"
mod.list("number_small", "List of small (0-99) numbers")
mod.tag("unprefixed_numbers", desc="Dont require prefix when saying a number")
ctx.lists["user.number_small"] = get_spoken_form_under_one_hundred(
0,
99,
include_default_variant_for_single_digits=True,
include_double_digits=True,
)
# TODO: allow things like "double eight" for 88
@ctx.capture("digit_string", rule=f"({alt_digits} | {alt_teens} | {alt_tens})+")
def digit_string(m) -> str:
return parse_number(list(m))
@ctx.capture("digits", rule="<digit_string>")
def digits(m) -> int:
"""Parses a phrase representing a digit sequence, returning it as an integer."""
return int(m.digit_string)
@mod.capture(rule=f"{number_word_leading} ([and] {number_word})*")
def number_string(m) -> str:
"""Parses a number phrase, returning that number as a string."""
return parse_number(list(m))
@ctx.capture("number", rule="<user.number_string>")
def number(m) -> int:
"""Parses a number phrase, returning it as an integer."""
return int(m.number_string)
@mod.capture(rule="[negative | minus] <user.number_string>")
def number_signed_string(m) -> str:
"""Parses a (possibly negative) number phrase, returning that number as a string."""
number = m.number_string
return f"-{number}" if (m[0] in ["negative", "minus"]) else number
@ctx.capture("number_signed", rule="<user.number_signed_string>")
def number_signed(m) -> int:
"""Parses a (possibly negative) number phrase, returning that number as a integer."""
return int(m.number_signed_string)
@mod.capture(rule="<user.number_string> ((dot | point) <user.number_string>)+")
def number_prose_with_dot(m) -> str:
return ".".join(m.number_string_list)
@mod.capture(rule="<user.number_string> (comma <user.number_string>)+")
def number_prose_with_comma(m) -> str:
return ",".join(m.number_string_list)
@mod.capture(rule="<user.number_string> (colon <user.number_string>)+")
def number_prose_with_colon(m) -> str:
return ":".join(m.number_string_list)
@mod.capture(
rule="<user.number_signed_string> | <user.number_prose_with_dot> | <user.number_prose_with_comma> | <user.number_prose_with_colon>"
)
def number_prose_unprefixed(m) -> str:
return m[0]
@mod.capture(rule="(numb | numeral) <user.number_prose_unprefixed>")
def number_prose_prefixed(m) -> str:
return m.number_prose_unprefixed
@ctx.capture("number_small", rule="{user.number_small}")
def number_small(m) -> int:
return int(m.number_small)
@mod.capture(rule="[negative | minus] <number_small>")
def number_signed_small(m) -> int:
"""Parses an integer between -99 and 99."""
number = m[-1]
return -number if (m[0] in ["negative", "minus"]) else number
@@ -0,0 +1 @@
<user.number_prose_prefixed>: "{number_prose_prefixed}"
@@ -0,0 +1,6 @@
tag: user.unprefixed_numbers
and not tag: user.continuous_scrolling
and not tag: user.mouse_grid_showing
-
<user.number_prose_unprefixed>: "{number_prose_unprefixed}"
+72
View File
@@ -0,0 +1,72 @@
from talon import Context, Module
# The primitive ordinal words in English below a hundred.
ordinal_words = {
0: "zeroth",
1: "first",
2: "second",
3: "third",
4: "fourth",
5: "fifth",
6: "sixth",
7: "seventh",
8: "eighth",
9: "ninth",
10: "tenth",
11: "eleventh",
12: "twelfth",
13: "thirteenth",
14: "fourteenth",
15: "fifteenth",
16: "sixteenth",
17: "seventeenth",
18: "eighteenth",
19: "nineteenth",
20: "twentieth",
30: "thirtieth",
40: "fortieth",
50: "fiftieth",
60: "sixtieth",
70: "seventieth",
80: "eightieth",
90: "ninetieth",
}
tens_words = "zero ten twenty thirty forty fifty sixty seventy eighty ninety".split()
# ordinal_numbers maps ordinal words into their corresponding numbers.
ordinal_numbers = {}
ordinal_small = {}
for n in range(1, 100):
if n in ordinal_words:
word = ordinal_words[n]
else:
(tens, units) = divmod(n, 10)
assert 1 < tens < 10, "we have already handled all ordinals < 20"
assert 0 < units, "we have already handled all ordinals divisible by ten"
word = f"{tens_words[tens]} {ordinal_words[units]}"
if n <= 20:
ordinal_small[word] = str(n)
ordinal_numbers[word] = str(n)
mod = Module()
ctx = Context()
mod.list("ordinals", "List of ordinals (1-99)")
mod.list("ordinals_small", "List of small ordinals (1-20)")
ctx.lists["user.ordinals"] = ordinal_numbers
ctx.lists["user.ordinals_small"] = ordinal_small
@mod.capture(rule="{user.ordinals}")
def ordinals(m) -> int:
"""Returns a single ordinal as an integer"""
return int(m.ordinals)
@mod.capture(rule="{user.ordinals_small}")
def ordinals_small(m) -> int:
"""Returns a single small ordinal as an integer"""
return int(m.ordinals_small)
+64
View File
@@ -0,0 +1,64 @@
from talon import Module, cron, ui
from talon.canvas import Canvas
mod = Module()
@mod.action_class
class Actions:
def screens_show_numbering():
"""Show screen number on each screen"""
screens = ui.screens()
number = 1
for screen in screens:
show_screen_number(screen, number)
number += 1
def screens_get_by_number(screen_number: int) -> ui.Screen:
"""Get screen by number"""
screens = ui.screens()
length = len(screens)
if screen_number < 1 or screen_number > length:
raise Exception(
f"Non-existing screen {screen_number} in range [1, {length}]"
)
return screens[screen_number - 1]
def screens_get_previous(screen: ui.Screen) -> ui.Screen:
"""Get the screen before this one"""
return get_screen_by_offset(screen, -1)
def screens_get_next(screen: ui.Screen) -> ui.Screen:
"""Get the screen after this one"""
return get_screen_by_offset(screen, 1)
def get_screen_by_offset(screen: ui.Screen, offset: int) -> ui.Screen:
screens = ui.screens()
index = (screens.index(screen) + offset) % len(screens)
return screens[index]
def show_screen_number(screen: ui.Screen, number: int):
def on_draw(c):
c.paint.typeface = "arial"
# The min(width, height) is to not get gigantic size on portrait screens
c.paint.textsize = round(min(c.width, c.height) / 2)
text = f"{number}"
rect = c.paint.measure_text(text)[1]
x = c.x + c.width / 2 - rect.x - rect.width / 2
y = c.y + c.height / 2 + rect.height / 2
c.paint.style = c.paint.Style.FILL
c.paint.color = "eeeeee"
c.draw_text(text, x, y)
c.paint.style = c.paint.Style.STROKE
c.paint.color = "000000"
c.draw_text(text, x, y)
cron.after("3s", canvas.close)
canvas = Canvas.from_rect(screen.rect)
canvas.register("draw", on_draw)
canvas.freeze()

Some files were not shown because too many files have changed in this diff Show More