switcher.py (11701B)
1 import os 2 import re 3 import time 4 5 import talon 6 from talon import Context, Module, app, imgui, ui, fs, actions 7 from glob import glob 8 from itertools import islice 9 from pathlib import Path 10 11 # Construct at startup a list of overides for application names (similar to how homophone list is managed) 12 # ie for a given talon recognition word set `one note`, recognized this in these switcher functions as `ONENOTE` 13 # the list is a comma seperated `<Recognized Words>, <Overide>` 14 # TODO: Consider put list csv's (homophones.csv, app_name_overrides.csv) files together in a seperate directory,`knausj_talon/lists` 15 cwd = os.path.dirname(os.path.realpath(__file__)) 16 overrides_directory = os.path.join(cwd, "app_names") 17 override_file_name = f"app_name_overrides.{talon.app.platform}.csv" 18 override_file_path = os.path.join(overrides_directory, override_file_name) 19 20 21 mod = Module() 22 mod.list("running", desc="all running applications") 23 mod.list("launch", desc="all launchable applications") 24 ctx = Context() 25 26 # a list of the current overrides 27 overrides = {} 28 29 # a list of the currently running application names 30 running_application_dict = {} 31 32 33 mac_application_directories = [ 34 "/Applications", 35 "/Applications/Utilities", 36 "/System/Applications", 37 "/System/Applications/Utilities", 38 ] 39 40 # windows_application_directories = [ 41 # "%AppData%/Microsoft/Windows/Start Menu/Programs", 42 # "%ProgramData%/Microsoft/Windows/Start Menu/Programs", 43 # "%AppData%/Microsoft/Internet Explorer/Quick Launch/User Pinned/TaskBar", 44 # ] 45 46 words_to_exclude = [ 47 "and", 48 "zero", 49 "one", 50 "two", 51 "three", 52 "for", 53 "four", 54 "five", 55 "six", 56 "seven", 57 "eight", 58 "nine", 59 "microsoft", 60 "windows", 61 "Windows", 62 ] 63 64 # windows-specific logic 65 if app.platform == "windows": 66 import os 67 import ctypes 68 import pywintypes 69 import pythoncom 70 import winerror 71 72 try: 73 import winreg 74 except ImportError: 75 # Python 2 76 import _winreg as winreg 77 78 bytes = lambda x: str(buffer(x)) 79 80 from ctypes import wintypes 81 from win32com.shell import shell, shellcon 82 from win32com.propsys import propsys, pscon 83 84 # KNOWNFOLDERID 85 # https://msdn.microsoft.com/en-us/library/dd378457 86 # win32com defines most of these, except the ones added in Windows 8. 87 FOLDERID_AppsFolder = pywintypes.IID("{1e87508d-89c2-42f0-8a7e-645a0f50ca58}") 88 89 # win32com is missing SHGetKnownFolderIDList, so use ctypes. 90 91 _ole32 = ctypes.OleDLL("ole32") 92 _shell32 = ctypes.OleDLL("shell32") 93 94 _REFKNOWNFOLDERID = ctypes.c_char_p 95 _PPITEMIDLIST = ctypes.POINTER(ctypes.c_void_p) 96 97 _ole32.CoTaskMemFree.restype = None 98 _ole32.CoTaskMemFree.argtypes = (wintypes.LPVOID,) 99 100 _shell32.SHGetKnownFolderIDList.argtypes = ( 101 _REFKNOWNFOLDERID, # rfid 102 wintypes.DWORD, # dwFlags 103 wintypes.HANDLE, # hToken 104 _PPITEMIDLIST, 105 ) # ppidl 106 107 def get_known_folder_id_list(folder_id, htoken=None): 108 if isinstance(folder_id, pywintypes.IIDType): 109 folder_id = bytes(folder_id) 110 pidl = ctypes.c_void_p() 111 try: 112 _shell32.SHGetKnownFolderIDList(folder_id, 0, htoken, ctypes.byref(pidl)) 113 return shell.AddressAsPIDL(pidl.value) 114 except WindowsError as e: 115 if e.winerror & 0x80070000 == 0x80070000: 116 # It's a WinAPI error, so re-raise it, letting Python 117 # raise a specific exception such as FileNotFoundError. 118 raise ctypes.WinError(e.winerror & 0x0000FFFF) 119 raise 120 finally: 121 if pidl: 122 _ole32.CoTaskMemFree(pidl) 123 124 def enum_known_folder(folder_id, htoken=None): 125 id_list = get_known_folder_id_list(folder_id, htoken) 126 folder_shell_item = shell.SHCreateShellItem(None, None, id_list) 127 items_enum = folder_shell_item.BindToHandler( 128 None, shell.BHID_EnumItems, shell.IID_IEnumShellItems 129 ) 130 result = [] 131 for item in items_enum: 132 # print(item.GetDisplayName(shellcon.SIGDN_NORMALDISPLAY)) 133 result.append(item.GetDisplayName(shellcon.SIGDN_NORMALDISPLAY)) 134 135 return result 136 137 def list_known_folder(folder_id, htoken=None): 138 result = [] 139 for item in enum_known_folder(folder_id, htoken): 140 result.append(item.GetDisplayName(shellcon.SIGDN_NORMALDISPLAY)) 141 result.sort(key=lambda x: x.upper()) 142 return result 143 144 145 @mod.capture(rule="{self.running}") # | <user.text>)") 146 def running_applications(m) -> str: 147 "Returns a single application name" 148 try: 149 return m.running 150 except AttributeError: 151 return m.text 152 153 154 @mod.capture(rule="{self.launch}") 155 def launch_applications(m) -> str: 156 "Returns a single application name" 157 return m.launch 158 159 160 def split_camel(word): 161 return re.findall(r"[0-9A-Z]*[a-z]+(?=[A-Z]|$)", word) 162 163 164 def get_words(name): 165 words = re.findall(r"[0-9A-Za-z]+", name) 166 out = [] 167 for word in words: 168 out += split_camel(word) 169 return out 170 171 172 def update_lists(): 173 global running_application_dict 174 running_application_dict = {} 175 running = {} 176 for cur_app in ui.apps(background=False): 177 name = cur_app.name 178 179 if name.endswith(".exe"): 180 name = name.rsplit(".", 1)[0] 181 182 words = get_words(name) 183 for word in words: 184 if word and word not in running and len(word) >= 3: 185 running[word.lower()] = cur_app.name 186 187 running[name.lower()] = cur_app.name 188 running_application_dict[cur_app.name] = True 189 190 for override in overrides: 191 running[override] = overrides[override] 192 193 lists = { 194 "self.running": running, 195 # "self.launch": launch, 196 } 197 198 # batch update lists 199 ctx.lists.update(lists) 200 201 202 def update_overrides(name, flags): 203 """Updates the overrides list""" 204 global overrides 205 overrides = {} 206 207 if name is None or name == override_file_path: 208 # print("update_overrides") 209 with open(override_file_path, "r") as f: 210 for line in f: 211 line = line.rstrip() 212 line = line.split(",") 213 if len(line) == 2: 214 overrides[line[0].lower()] = line[1].strip() 215 216 update_lists() 217 218 219 pattern = re.compile(r"[A-Z][a-z]*|[a-z]+|\d|[+]") 220 221 # todo: this is garbage 222 def create_spoken_forms(name, max_len=30): 223 result = " ".join(list(islice(pattern.findall(name), max_len))) 224 225 result = ( 226 result.replace("0", "zero") 227 .replace("1", "one") 228 .replace("2", "two") 229 .replace("3", "three") 230 .replace("4", "four") 231 .replace("5", "five") 232 .replace("6", "six") 233 .replace("7", "seven") 234 .replace("8", "eight") 235 .replace("9", "nine") 236 .replace("+", "plus") 237 ) 238 return result 239 240 241 @mod.action_class 242 class Actions: 243 def get_running_app(name: str) -> ui.App: 244 """Get the first available running app with `name`.""" 245 # We should use the capture result directly if it's already in the list 246 # of running applications. Otherwise, name is from <user.text> and we 247 # can be a bit fuzzier 248 if name not in running_application_dict: 249 if len(name) < 3: 250 raise RuntimeError( 251 f'Skipped getting app: "{name}" has less than 3 chars.' 252 ) 253 for running_name, full_application_name in ctx.lists[ 254 "self.running" 255 ].items(): 256 if running_name == name or running_name.lower().startswith( 257 name.lower() 258 ): 259 name = full_application_name 260 break 261 for app in ui.apps(): 262 if app.name == name and not app.background: 263 return app 264 raise RuntimeError(f'App not running: "{name}"') 265 266 def switcher_focus(name: str): 267 """Focus a new application by name""" 268 app = actions.user.get_running_app(name) 269 app.focus() 270 271 # Hacky solution to do this reliably on Mac. 272 timeout = 5 273 t1 = time.monotonic() 274 if talon.app.platform == "mac": 275 while ui.active_app() != app and time.monotonic() - t1 < timeout: 276 time.sleep(0.1) 277 278 def switcher_launch(path: str): 279 """Launch a new application by path""" 280 if app.platform == "windows": 281 is_valid_path = False 282 try: 283 current_path = Path(path) 284 is_valid_path = current_path.is_file() 285 # print("valid path: {}".format(is_valid_path)) 286 287 except: 288 # print("invalid path") 289 is_valid_path = False 290 291 if is_valid_path: 292 # print("path: " + path) 293 ui.launch(path=path) 294 295 else: 296 # print("envelop") 297 actions.key("super-s") 298 actions.sleep("300ms") 299 actions.insert("apps: {}".format(path)) 300 actions.sleep("150ms") 301 actions.key("enter") 302 303 else: 304 ui.launch(path=path) 305 306 def switcher_toggle_running(): 307 """Shows/hides all running applications""" 308 if gui.showing: 309 gui.hide() 310 else: 311 gui.show() 312 313 def switcher_hide_running(): 314 """Hides list of running applications""" 315 gui.hide() 316 317 318 @imgui.open() 319 def gui(gui: imgui.GUI): 320 gui.text("Names of running applications") 321 gui.line() 322 for line in ctx.lists["self.running"]: 323 gui.text(line) 324 325 326 def update_launch_list(): 327 launch = {} 328 if app.platform == "mac": 329 for base in mac_application_directories: 330 if os.path.isdir(base): 331 for name in os.listdir(base): 332 path = os.path.join(base, name) 333 name = name.rsplit(".", 1)[0].lower() 334 launch[name] = path 335 words = name.split(" ") 336 for word in words: 337 if word and word not in launch: 338 if len(name) > 6 and len(word) < 3: 339 continue 340 launch[word] = path 341 342 elif app.platform == "windows": 343 shortcuts = enum_known_folder(FOLDERID_AppsFolder) 344 # str(shortcuts) 345 for name in shortcuts: 346 # print("hit: " + name) 347 # print(name) 348 # name = path.rsplit("\\")[-1].split(".")[0].lower() 349 if "install" not in name: 350 spoken_form = create_spoken_forms(name) 351 # print(spoken_form) 352 launch[spoken_form] = name 353 words = spoken_form.split(" ") 354 for word in words: 355 if word not in words_to_exclude and word not in launch: 356 if len(name) > 6 and len(word) < 3: 357 continue 358 launch[word] = name 359 360 ctx.lists["self.launch"] = launch 361 362 363 def ui_event(event, arg): 364 if event in ("app_launch", "app_close"): 365 update_lists() 366 367 368 # Currently update_launch_list only does anything on mac, so we should make sure 369 # to initialize user launch to avoid getting "List not found: user.launch" 370 # errors on other platforms. 371 ctx.lists["user.launch"] = {} 372 ctx.lists["user.running"] = {} 373 374 # Talon starts faster if you don't use the `talon.ui` module during launch 375 def on_ready(): 376 update_overrides(None, None) 377 fs.watch(overrides_directory, update_overrides) 378 update_launch_list() 379 ui.register("", ui_event) 380 381 382 # NOTE: please update this from "launch" to "ready" in Talon v0.1.5 383 app.register("ready", on_ready)