Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implemented Window Switch Manager #881

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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()

Expand Down
61 changes: 44 additions & 17 deletions castervoice/lib/utilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -90,8 +79,31 @@ def minimize_window():
'''
Minimize foreground Window
'''
global lasthandle
lasthandle = Window.get_foreground()
Window.get_foreground().minimize()

def restore_window():
'''
Restores last minimized window triggered minimize_window.
'''
global 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'''
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()
Expand Down Expand Up @@ -158,6 +170,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)
Expand Down Expand Up @@ -243,21 +271,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"):
Expand Down
55 changes: 49 additions & 6 deletions castervoice/rules/core/navigation_rules/window_mgmt_rule.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,58 @@
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
from castervoice.lib.actions import Key
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 <windows>" -> 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 <windows>":
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]":
Expand All @@ -27,7 +65,6 @@ class WindowManagementRule(MappingRule):
R(Key("wc-right"))*Repeat(extra="n"),
"(previous | prior) work [space] [<n>]":
R(Key("wc-left"))*Repeat(extra="n"),

"go work [space] <n>":
R(Function(virtual_desktops.go_to_desktop_number)),
"send work [space] <n>":
Expand All @@ -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
144 changes: 144 additions & 0 deletions castervoice/rules/core/navigation_rules/window_mgmt_rule_support.py
Original file line number Diff line number Diff line change
@@ -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 <windows> repetition element
spec_n_word = 2 # `window switch`
# Edge case: if the spec `window switch <windows>` word length changes.
# The `spec_n_word` integer equals `n` number of words in spec excluding <windows>
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()