init commit
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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)
|
||||
@@ -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/
|
||||
"""
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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: \[ \]
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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() != ""
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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}"
|
||||
@@ -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)
|
||||
@@ -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
Reference in New Issue
Block a user