462 lines
16 KiB
Python
462 lines
16 KiB
Python
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)
|