forked from talonhub/community
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathapp_switcher.py
461 lines (388 loc) · 15.9 KB
/
app_switcher.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
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)