From b724cb81a16277c72d06e4288e4e7116f4275a57 Mon Sep 17 00:00:00 2001 From: LexiconCode Date: Sat, 27 Feb 2021 18:23:41 -0600 Subject: [PATCH 1/4] Implemented get_caster_messaging_window Returns window title of window title of caster messaging window --- castervoice/lib/utilities.py | 55 +++++++++++++++++++++++++----------- 1 file changed, 38 insertions(+), 17 deletions(-) diff --git a/castervoice/lib/utilities.py b/castervoice/lib/utilities.py index b6c0cd2c9..12fbcf81a 100644 --- a/castervoice/lib/utilities.py +++ b/castervoice/lib/utilities.py @@ -40,6 +40,7 @@ DARWIN = sys.platform.startswith('darwin') LINUX = sys.platform.startswith('linux') WIN32 = sys.platform.startswith('win') +lasthandle = None # TODO: Move functions that manipulate or retrieve information from Windows to `window_mgmt_support` in navigation_rules. # TODO: Implement Optional exact title matching for `get_matching_windows` in Dragonfly @@ -67,18 +68,6 @@ def get_active_window_path(): return Window.get_foreground().executable -def get_active_window_info(): - '''Returns foreground window executable_file, executable_path, title, handle, classname''' - FILENAME_PATTERN = re.compile(r"[/\\]([\w_ ]+\.[\w]+)") - window = Window.get_foreground() - executable_path = str(Path(get_active_window_path())) - match_object = FILENAME_PATTERN.findall(window.executable) - executable_file = None - if len(match_object) > 0: - executable_file = match_object[0] - return [executable_file, executable_path, window.title, window.handle, window.classname] - - def maximize_window(): ''' Maximize foreground Window @@ -90,8 +79,25 @@ def minimize_window(): ''' Minimize foreground Window ''' + global lasthandle + lasthandle = Window.get_foreground() Window.get_foreground().minimize() +def restore_window(): + global lasthandle + Window.restore(lasthandle) + +def get_active_window_info(): + '''Returns foreground window executable_file, executable_path, title, handle, classname''' + FILENAME_PATTERN = re.compile(r"[/\\]([\w_ ]+\.[\w]+)") + window = Window.get_foreground() + executable_path = str(Path(get_active_window_path())) + match_object = FILENAME_PATTERN.findall(window.executable) + executable_file = None + if len(match_object) > 0: + executable_file = match_object[0] + return [executable_file, executable_path, window.title, window.handle, window.classname] + def save_toml_file(data, path): guidance.offer() @@ -158,6 +164,22 @@ def simple_log(to_file=False): with io.open(settings.SETTINGS["paths"]["LOG_PATH"], 'at', encoding="utf-8") as f: f.write(msg + "\n") +def get_caster_messaging_window(): + ''' + Returns window title of window that outputs caster messages + ''' + engine = get_current_engine().name + if engine == 'natlink': + import natlinkstatus # pylint: disable=import-error + status = natlinkstatus.NatlinkStatus() + if status.NatlinkIsEnabled() == 1: + if six.PY2: + return "Messages from Python Macros" + else: + return "Messages from Natlink" + else: + return "Caster: Status Window" + def availability_message(feature, dependency): printer.out(feature + " feature not available without " + dependency) @@ -243,21 +265,20 @@ def clear_log(): # Function to clear status window. # Natlink status window not used an out-of-process mode. # TODO: window_exists utilized when engine launched through Dragonfly CLI via bat in future + window_title = get_caster_messaging_window() try: if WIN32: clearcmd = "cls" # Windows OS else: clearcmd = "clear" # Linux if get_current_engine().name == 'natlink': - import natlinkstatus # pylint: disable=import-error - status = natlinkstatus.NatlinkStatus() - if status.NatlinkIsEnabled() == 1: + handle = get_window_by_title(window_title) + if handle: import win32gui # pylint: disable=import-error - handle = get_window_by_title("Messages from Python Macros") or get_window_by_title("Messages from Natlink") rt_handle = win32gui.FindWindowEx(handle, None, "RICHEDIT", None) win32gui.SetWindowText(rt_handle, "") else: - if window_exists(windowname="Caster: Status Window"): + if window_exists(windowname=window_title): os.system(clearcmd) else: if window_exists(windowname="Caster: Status Window"): From 6486783bbb622a95bf03f89cff864eea599d25c9 Mon Sep 17 00:00:00 2001 From: LexiconCode Date: Sat, 27 Feb 2021 18:30:37 -0600 Subject: [PATCH 2/4] Simplified show_window --- .../show_window_on_error_hook.py | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/castervoice/lib/merge/ccrmerging2/hooks/standard_hooks/show_window_on_error_hook.py b/castervoice/lib/merge/ccrmerging2/hooks/standard_hooks/show_window_on_error_hook.py index d2ca5b1c3..411864de8 100644 --- a/castervoice/lib/merge/ccrmerging2/hooks/standard_hooks/show_window_on_error_hook.py +++ b/castervoice/lib/merge/ccrmerging2/hooks/standard_hooks/show_window_on_error_hook.py @@ -1,4 +1,4 @@ -from castervoice.lib import settings, textformat +from castervoice.lib import settings, textformat, utilities from castervoice.lib.merge.ccrmerging2.hooks.base_hook import BaseHook from castervoice.lib.merge.ccrmerging2.hooks.events.event_types import EventType from castervoice.lib import printer @@ -8,21 +8,8 @@ from dragonfly.windows.window import Window def show_window(): - title = None - engine = get_current_engine().name - if engine == 'natlink': - import natlinkstatus # pylint: disable=import-error - status = natlinkstatus.NatlinkStatus() - if status.NatlinkIsEnabled() == 1: - if six.PY2: - title = "Messages from Python Macros" - else: - title= "Messages from Natlink" - else: - title = "Caster: Status Window" - if engine != 'natlink': - title = "Caster: Status Window" - windows = Window.get_matching_windows(title=title) + window_title = utilities.get_caster_messaging_window() + windows = Window.get_matching_windows(title=window_title) if windows: windows[0].set_foreground() From 8ac0831ea2633836cfeadea791a826172d243654 Mon Sep 17 00:00:00 2001 From: LexiconCode Date: Sat, 27 Feb 2021 18:31:45 -0600 Subject: [PATCH 3/4] Implemented Window Switcher Management --- .../core/navigation_rules/window_mgmt_rule.py | 55 ++++++- .../window_mgmt_rule_support.py | 144 ++++++++++++++++++ 2 files changed, 193 insertions(+), 6 deletions(-) create mode 100644 castervoice/rules/core/navigation_rules/window_mgmt_rule_support.py diff --git a/castervoice/rules/core/navigation_rules/window_mgmt_rule.py b/castervoice/rules/core/navigation_rules/window_mgmt_rule.py index c3a2e1b96..66695024c 100644 --- a/castervoice/rules/core/navigation_rules/window_mgmt_rule.py +++ b/castervoice/rules/core/navigation_rules/window_mgmt_rule.py @@ -1,4 +1,4 @@ -from dragonfly import MappingRule, Function, Repeat, ShortIntegerRef +from dragonfly import MappingRule, Function, Repeat, DictListRef, Repetition, get_engine, ShortIntegerRef from castervoice.lib import utilities from castervoice.lib import virtual_desktops @@ -6,15 +6,53 @@ from castervoice.lib.ctrl.mgr.rule_details import RuleDetails from castervoice.lib.merge.state.short import R +try: # Try first loading from caster user directory + from navigation_rules.window_mgmt_rule_support import refresh_open_windows_dictlist, debug_window_switching, switch_window, open_windows_dictlist, timerinstance +except ImportError: + from castervoice.rules.core.navigation_rules.window_mgmt_rule_support import refresh_open_windows_dictlist, debug_window_switching, switch_window, open_windows_dictlist, timerinstance + + +""" +Window Switch Manager to swap windows by saying words in their title. + +Uses a timer to periodically load the list of open windows into a DictList, +so they can be referenced by the "switch window" command. + +Commands: + + "window switch " -> switch to the window with the given word in its + title. If multiple windows have that word in + their title, then you can say more words in the + window's title to disambiguate which one you + mean. If you don't, the caster messaging window will be + foregrounded instead with info on which windows + are ambiguously being matched by your keywords. + "window switch refresh" -> manually reload the list of windows. Useful while + developing if you don't want to use the timer. Command disabled + "window switch show" -> output information about which keywords can + be used on their own to switch windows and which + require multiple words. + +""" + class WindowManagementRule(MappingRule): mapping = { - 'maximize win': + 'window maximize': R(Function(utilities.maximize_window)), - 'minimize win': + 'window minimize': R(Function(utilities.minimize_window)), - - # Workspace management + 'window restore': + R(Function(utilities.restore_window)), + # Window Switcher Management + "window switch ": + R(Function(switch_window), rdescript=""), # Block printing out rdescript + # Manualy refreshes open windows if `timerinstance.set()` not used + # "window switch refresh": + # R(Function(lambda: refresh_open_windows_dictlist())), + "window switch show": + R(Function(debug_window_switching)), + # Virtual Workspace Management "show work [spaces]": R(Key("w-tab")), "(create | new) work [space]": @@ -27,7 +65,6 @@ class WindowManagementRule(MappingRule): R(Key("wc-right"))*Repeat(extra="n"), "(previous | prior) work [space] []": R(Key("wc-left"))*Repeat(extra="n"), - "go work [space] ": R(Function(virtual_desktops.go_to_desktop_number)), "send work [space] ": @@ -38,9 +75,15 @@ class WindowManagementRule(MappingRule): extras = [ ShortIntegerRef("n", 1, 20, default=1), + Repetition(name="windows", min=1, max=5, + child=DictListRef("window_by_keyword", open_windows_dictlist)) ] +# Window switch update sopen_windows_dictlist every 2 second +timerinstance.set() + + def get_rule(): details = RuleDetails(name="window management rule") return WindowManagementRule, details diff --git a/castervoice/rules/core/navigation_rules/window_mgmt_rule_support.py b/castervoice/rules/core/navigation_rules/window_mgmt_rule_support.py new file mode 100644 index 000000000..616bfd2c1 --- /dev/null +++ b/castervoice/rules/core/navigation_rules/window_mgmt_rule_support.py @@ -0,0 +1,144 @@ +# All credit goes to caspark +# This is adapted from caspark's grammar at https://gist.github.com/caspark/9c2c5e2853a14b6e28e9aa4f121164a6 + +from __future__ import print_function + +import re +import time +import six + +from dragonfly import Window, DictList, get_engine, get_current_engine +from castervoice.lib import utilities +from castervoice.lib.util import recognition_history + +_history = recognition_history.get_and_register_history(1) + +open_windows_dictlist = DictList("open_windows") + +WORD_SPLITTER = re.compile('[^a-zA-Z0-9]+') + + +def lower_if_not_abbreviation(s): + if len(s) <= 4 and s.upper() == s: + return s + else: + return s.lower() + + +def find_window(window_matcher_func, timeout_ms=3000): + """ + Returns a Window matching the given matcher function, or raises an error otherwise + """ + steps = int(timeout_ms / 100) + for i in range(steps): + for win in Window.get_all_windows(): + if window_matcher_func(win): + return win + time.sleep(0.1) + raise ValueError( + "no matching window found within {} ms".format(timeout_ms)) + + +def refresh_open_windows_dictlist(): + """ + Refreshes `open_windows_dictlist` + """ + window_options = {} + for window in (x for x in Window.get_all_windows() if + x.is_valid and + x.is_enabled and + x.is_visible and + not x.executable.startswith("C:\\Windows") and + x.classname != "DgnResultsBoxWindow"): + for word in {lower_if_not_abbreviation(word) + for word + in WORD_SPLITTER.split(window.title) + if len(word)}: + if word in window_options: + window_options[word] += [window] + else: + window_options[word] = [window] + + window_options = {k: v for k, + v in six.iteritems(window_options) if v is not None} + open_windows_dictlist.set(window_options) + + +def debug_window_switching(): + """ + Prints out contents of `open_windows_dictlist` + """ + options = open_windows_dictlist.copy() + print("*** Windows known:\n", + "\n".join(sorted({w.title for list_of_windows in six.itervalues(options) for w in list_of_windows}))) + + print("*** Single word switching options:\n", "\n".join( + "{}: '{}'".format( + k.ljust(20), "', '".join(window.title for window in options[k]) + ) for k in sorted(six.iterkeys(options)) if len(options[k]) == 1)) + print("*** Ambiguous switching options:\n", "\n".join( + "{}: '{}'".format( + k.ljust(20), "', '".join(window.title for window in options[k]) + ) for k in sorted(six.iterkeys(options)) if len(options[k]) > 1)) + + +def switch_window(windows): + """ + Matches keywords to window titles stored in `open_windows_dictlist` + """ + matched_window_handles = {w.handle: w for w in windows[0]} + for window_options in windows[1:]: + matched_window_handles = { + w.handle: w for w in window_options if w.handle in matched_window_handles} + if six.PY2: + matched_windows = matched_window_handles.values() + else: + matched_windows = list(matched_window_handles.values()) + if len(matched_windows) == 1: + window = matched_windows[0] + print("Window Management: Switching to", window.title) + window.set_foreground() + else: + try: + # Brings caster messaging window to the forefront + messaging_title = utilities.get_caster_messaging_window() + messaging_window = find_window( + lambda w: messaging_title in w.title, timeout_ms=100) + if messaging_window.is_minimized: + messaging_window.restore() + else: + messaging_window.set_foreground() + except ValueError: + # window didn't exist, it'll be created when we write some output + pass + if len(matched_windows) >= 2: # Keywords match more than one window title + print("Ambiguous window switch command:\n", "\n".join( + "'{}' from {} (handle: {})".format(w.title, w.executable, w.handle) for w in matched_windows)) + else: + # At this point the series of keywords do not match any single window title. + # Uses recognition history to inform what keywords were said in repetition element + spec_n_word = 2 # `window switch` + # Edge case: if the spec `window switch ` word length changes. + # The `spec_n_word` integer equals `n` number of words in spec excluding + words = list(map(str, _history[0])) + del words[:spec_n_word] + print("Window Management: No matching window title containing keywords: `{}`".format( + ' '.join(map(str, words)))) + + +class Timer: + """ + Dragonfly timer runs every 2 seconds updating open_windows_dictlist + """ + timer = None + + def __init__(self): + pass + + def set(self): + if self.timer is None: + self.timer = get_engine().create_timer(refresh_open_windows_dictlist, 2) + self.timer.start() + + +timerinstance = Timer() From 9e2656c8ef53cf5db4562c291d49bb767abffef8 Mon Sep 17 00:00:00 2001 From: LexiconCode Date: Sun, 28 Feb 2021 20:49:05 -0600 Subject: [PATCH 4/4] Error handling for restore_window --- castervoice/lib/utilities.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/castervoice/lib/utilities.py b/castervoice/lib/utilities.py index 12fbcf81a..766f7ce5f 100644 --- a/castervoice/lib/utilities.py +++ b/castervoice/lib/utilities.py @@ -84,8 +84,14 @@ def minimize_window(): Window.get_foreground().minimize() def restore_window(): + ''' + Restores last minimized window triggered minimize_window. + ''' global lasthandle - Window.restore(lasthandle) + if lasthandle is None: + printer.out("No previous window minimized by voice") + else: + Window.restore(lasthandle) def get_active_window_info(): '''Returns foreground window executable_file, executable_path, title, handle, classname'''