Skip to content

Commit

Permalink
Merge pull request #183 from dictation-toolbox/macos-window-class
Browse files Browse the repository at this point in the history
Add Window class for Mac OS using AppleScript
  • Loading branch information
drmfinlay authored Dec 18, 2019
2 parents c50b26f + 2fab8a5 commit d44b2e7
Show file tree
Hide file tree
Showing 5 changed files with 281 additions and 1 deletion.
2 changes: 1 addition & 1 deletion documentation/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ def __getattr__(cls, name):
"win32clipboard", "win32com", "win32com.client", "numpy",
"win32com.client.gencache", "win32com.gen_py", "win32com.shell",
"win32con", "win32event", "win32file", "win32gui", "winsound",
"winxpgui", "psutil",
"winxpgui", "psutil", "applescript",
}

for module_name in mock_modules:
Expand Down
3 changes: 3 additions & 0 deletions documentation/window_classes.txt
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,6 @@ The :class:`FakeWindow` class will be used on unsupported platforms.

.. automodule:: dragonfly.windows.x11_window
:members:

.. automodule:: dragonfly.windows.darwin_window
:members:
271 changes: 271 additions & 0 deletions dragonfly/windows/darwin_window.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
# coding=utf-8
#
# This file is part of Aenea
#
# Aenea is free software: you can redistribute it and/or modify it under
# the terms of version 3 of the GNU Lesser General Public License as
# published by the Free Software Foundation.
#
# Aenea is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
# License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with Aenea. If not, see <http://www.gnu.org/licenses/>.
#
# Copyright (2014) Alex Roper
# Alex Roper <[email protected]>
#
# Modified from Aenea's server_osx.py file.

import locale
import logging
import psutil

from six import binary_type, string_types, integer_types
import applescript

from .base_window import BaseWindow
from .rectangle import Rectangle


class DarwinWindow(BaseWindow):
"""
The Window class is an interface to the macOS window control and
placement.
"""

_log = logging.getLogger("window")

#-----------------------------------------------------------------------
# Class methods to create new Window objects.

@classmethod
def get_foreground(cls):
script = '''
global frontApp, frontAppName
tell application "System Events"
set frontApp to first application process whose frontmost is true
set frontAppName to name of frontApp
tell process frontAppName
set mainWindow to missing value
repeat with win in windows
if attribute "AXMain" of win is true then
set mainWindow to win
exit repeat
end if
end repeat
end tell
end tell
return frontAppName
'''

# window_id isn't really a unique id, instead it's just the app name
# -- but still useful for automating through applescript
window_id = applescript.AppleScript(script).run()
if isinstance(window_id, binary_type):
window_id = window_id.decode(locale.getpreferredencoding())

return cls.get_window(id=window_id)

@classmethod
def get_all_windows(cls):
script = '''
global appIds
tell application "System Events"
set appIds to {}
repeat with theProcess in (application processes)
if not background only of theProcess then
set appIds to appIds & name of theProcess
end if
end repeat
end tell
return appIds
'''
return [cls.get_window(app_id) for app_id in
applescript.AppleScript(script).run()]

#-----------------------------------------------------------------------
# Methods for initialization and introspection.

def __init__(self, id):
BaseWindow.__init__(self, id)
self._names.add(id)

#-----------------------------------------------------------------------
# Methods that control attribute access.

def _set_id(self, id):
if not isinstance(id, (string_types, integer_types)):
raise TypeError("Window id/handle must be an int or string,"
" but received {0!r}".format(id))
self._id = id
self._windows_by_id[id] = self

#-----------------------------------------------------------------------
# Methods and properties for window attributes.

def get_properties(self):
"""
Method to get the properties of a macOS window.
:rtype: dict
:returns: window properties
"""
script = '''
tell application "System Events" to tell application process "%s"
try
get properties of window 1
on error errmess
log errmess
end try
end tell
''' % self._id
properties = applescript.AppleScript(script).run()
if not properties:
return {}

result = {}
encoding = locale.getpreferredencoding()
for key, value in properties.items():
key = key.code
if isinstance(key, binary_type):
key = key.decode(encoding)
if isinstance(value, applescript.AEType):
value = value.code
if isinstance(value, binary_type):
value = value.decode(encoding)
result[key] = value
return result

def get_attribute(self, attribute):
"""
Method to get an attribute of a macOS window.
:param attribute: attribute name
:type attribute: string
:returns: attribute value
"""
script = '''
tell application "%s"
try
get %s of window 1
on error errmess
log errmess
end try
end tell
''' % (self._id, attribute)
return applescript.AppleScript(script).run()

def _get_window_text(self):
return self.get_properties().get('pnam', '')

def _get_class_name(self):
return self.get_properties().get('pcls', '')

def _get_window_module(self):
return self._id # The window ID is the app name on macOS.

def _get_window_pid(self):
if not (self._id and isinstance(self._id, string_types)):
# Can't match against numerical / empty / null app bundle ID.
return -1

for process in psutil.process_iter(attrs=['pid', 'exe']):
exe = process.info['exe']
if exe and exe.endswith(self._id):
# Return the ID of the first matching process.
return process.info['pid']

# No match.
return -1

@property
def is_minimized(self):
return self.get_attribute('miniaturized')

@property
def is_maximized(self):
return self.get_attribute('zoomed')

@property
def is_visible(self):
return self.get_attribute('visible')

#-----------------------------------------------------------------------
# Methods related to window geometry.

def get_position(self):
props = self.get_properties()
return Rectangle(props['posn'][0], props['posn'][1],
props['ptsz'][0], props['ptsz'][1])

def set_position(self, rectangle):
assert isinstance(rectangle, Rectangle)
script = '''
tell application "System Events"
set firstWindow to first window of application process "%s"
set position of firstWindow to {%d, %d}
set size of firstWindow to {%d, %d}
end tell
''' % (self._id, rectangle.x, rectangle.y, rectangle.dx,
rectangle.dy)
applescript.AppleScript(script).run()

#-----------------------------------------------------------------------
# Methods for miscellaneous window control.

def _press_window_button(self, button_subrole, action):
# Note: The negation symbol ¬ is used to split long lines in
# AppleScript.
script = u'''
tell application "System Events"
perform action "%s" of (first button whose subrole is "%s") of ¬
first window of process "%s"
end tell
''' % (action, button_subrole, self._id)
try:
applescript.AppleScript(script).run()
return True
except applescript.ScriptError as err:
self._log.error("Failed to perform window button action "
"%s -> %s: %s", button_subrole, action, err)
return False

def minimize(self):
return self._press_window_button("AXMinimizeButton", "AXPress")

def maximize(self):
return self._press_window_button("AXFullScreenButton",
"AXZoomWindow")

def full_screen(self):
"""
Enable full screen mode for this window.
**Note**: this doesn't allow transitioning out of full screen mode.
"""
return self._press_window_button("AXFullScreenButton", "AXPress")

def restore(self):
# Toggle maximized/minimized state if necessary.
if self.is_maximized:
return self.maximize()

if self.is_minimized:
return self.minimize()

return True

def close(self):
return self._press_window_button("AXCloseButton", "AXPress")

def set_foreground(self):
script = '''
tell application "%s"
activate window 1
end tell
''' % self._id
applescript.AppleScript(script).run()
4 changes: 4 additions & 0 deletions dragonfly/windows/window.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@
elif os.environ.get("XDG_SESSION_TYPE") == "x11":
from .x11_window import X11Window as Window

# Mac OS
elif sys.platform == "darwin":
from .darwin_window import DarwinWindow as Window

# Unsupported
else:
from .fake_window import FakeWindow as Window
2 changes: 2 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ def read(*names):
# Mac OS dependencies.
"pynput >= 1.4.2;platform_system=='Darwin'",
"pyobjc >= 5.2;platform_system=='Darwin'",
"py-applescript == 1.0.0;platform_system=='Darwin'",
"psutil >= 5.5.1;platform_system=='Darwin'",

# RPC requirements
"json-rpc",
Expand Down

0 comments on commit d44b2e7

Please sign in to comment.