Kalmat / PyWinCtl

Cross-Platform module to get info on and control windows on screen
Other
179 stars 19 forks source link

Refactor getActiveWindow() to avoid use of subprocess calls to AppleScript #51

Closed super-ibby closed 1 year ago

super-ibby commented 1 year ago

Please let me know if this works on your end. It seems to work fine when called from another module or a Jupyter notebook. However, on my end, running the _pywinctl_macos.py file as a script yields the following error:

Traceback (most recent call last):
  File "/Users/ibby/Developer/PyWinCtl/src/pywinctl/_pywinctl_macos.py", line 2672, in <module>
    main()
  File "/Users/ibby/Developer/PyWinCtl/src/pywinctl/_pywinctl_macos.py", line 2662, in main
    npw = getActiveWindow()
  File "/Users/ibby/Developer/PyWinCtl/src/pywinctl/_pywinctl_macos.py", line 104, in getActiveWindow
    return MacOSWindow(active_app, title=active_window_info['kCGWindowName'], bounds=bounds)
TypeError: Can't instantiate abstract class MacOSWindow with abstract method isAlerting

Note that line 110 in the old code seems to be setting the Rect bounds incorrectly. The AppleScript returns size as {(x, y), (width, height)}, and the old code sets the bounds to these numbers. The right and bottom bounds are therefore off by -x and -y, respectively. Please let me know if you disagree.

Kalmat commented 1 year ago

Please let me know if this works on your end. It seems to work fine when called from another module or a Jupyter notebook. However, on my end, running the _pywinctl_macos.py file as a script yields the following error:

Traceback (most recent call last):
  File "/Users/ibby/Developer/PyWinCtl/src/pywinctl/_pywinctl_macos.py", line 2672, in <module>
    main()
  File "/Users/ibby/Developer/PyWinCtl/src/pywinctl/_pywinctl_macos.py", line 2662, in main
    npw = getActiveWindow()
  File "/Users/ibby/Developer/PyWinCtl/src/pywinctl/_pywinctl_macos.py", line 104, in getActiveWindow
    return MacOSWindow(active_app, title=active_window_info['kCGWindowName'], bounds=bounds)
TypeError: Can't instantiate abstract class MacOSWindow with abstract method isAlerting

I'm not sure why this is happening on your side. Most likely it's my fault since I messed with dev branch (I am a total newbie with git, github, branches, ... sorry). If you check current master version (or dev), isAlerting() method is commented everywhere, so it shouldn't complain because it is present or not. I guess the solution can be to install pywinctl-0.043 and download the proper release (v0.0.43)... not sure (hopefuly yes!). Apart from this, I suggest you use dev branch so we can better manage all pre-release changes, if you agree, of course.

Kalmat commented 1 year ago

What the hell?!?! I'm sure I tested your WindowDelegate solution in Catalina. It was pending to test in other versions, so I have started to test it in Yosemite and got this error. I then tested again in Catalina... but I get this very same error!

Traceback (most recent call last):
  File "/Volumes/Proyectos/PycharmProjects/PyWinCtl-dev/src/pywinctl/_pywinctl_macos.py", line 488, in <module>
    class WindowDelegate(AppKit.NSObject):  # Cannot put into a closure as subsequent calls will cause a re-registration error due to subclassing NSObject.
objc.error: WindowDelegate is overriding existing Objective-C class

Don't know what is happening, to be honest.

EDIT: Mistery solved. If you run _pwinctl_macos.py script directly, it loads pywinctl module twice, so it complains because WindowDelegate is already registered. This doesn't happen if you run it as a module. Most likely, when I first tested in Catalina, I didn't update the new module version, but just ran _pwinctl_macos.py directly. Since the existing pywinctl module version had no reference of WindowDelegate, it didn't fail. The question now is how to solve this... I will check!

EDIT 2: Doing this, it seems to work running the script directly as python3 _pywinctl_macos.py and within a separate script as import pywinctl. I have included two calls to getClientFrame() in test_MacOSNSWindow.py, and it doesn't fail, but you have a comment suggesting it's not the right way, correct?:

def getClientFrame(self):
    """
    Get the client area of window including scroll, menu and status bars, as a Rect (x, y, right, bottom)
    Notice that this method won't match non-standard window decoration style sizes

    :return: Rect struct

    WARNING: it will fail if not called within main thread
    """

    class WindowDelegate(AppKit.NSObject):  # Cannot put into a closure as subsequent calls will cause a re-registration error due to subclassing NSObject.
        """Helps run window operations on the main thread."""

        print("IN", registered)
        results: Dict[bytes, Any] = {}  # Store results here. Not ideal, but may be better than using a global.
        registered = True
        print("IN", registered)

        @staticmethod
        def run_on_main_thread(selector: bytes, obj: Optional[Any] = None, wait: Optional[bool] = True) -> Any:
            """Runs a method of this object on the main thread."""
            WindowDelegate.alloc().performSelectorOnMainThread_withObject_waitUntilDone_(selector, obj, wait)
            return WindowDelegate.results.get(selector)

        def getTitleBarHeightAndBorderWidth(self) -> None:
            """Updates results with title bar height and border width."""
            frame_width = 100
            window = AppKit.NSWindow.alloc().initWithContentRect_styleMask_backing_defer_(
                ((0, 0), (frame_width, 100)),
                AppKit.NSTitledWindowMask,
                AppKit.NSBackingStoreBuffered,
                False,
            )
            titlebar_height = int(window.titlebarHeight())
            # print(titlebar_height)
            content_rect = window.contentRectForFrameRect_(window.frame())
            x1 = AppKit.NSMinX(content_rect)
            x2 = AppKit.NSMaxX(content_rect)
            border_width = int(frame_width - (x2 - x1))
            # print(border_width)
            result = titlebar_height, border_width
            WindowDelegate.results[b'getTitleBarHeightAndBorderWidth'] = result

    # https://www.macscripter.net/viewtopic.php?id=46336 --> Won't allow access to NSWindow objects, but interesting
    # Didn't find a way to get menu bar height using Apple Script
    # titleHeight, borderWidth = _getBorderSizes()
    titleHeight, borderWidth = WindowDelegate.run_on_main_thread(b'getTitleBarHeightAndBorderWidth')
    res = Rect(int(self.left + borderWidth), int(self.top + titleHeight), int(self.right - borderWidth), int(self.bottom - borderWidth))
    return res
super-ibby commented 1 year ago

Using Quartz was my first approach, with no AppleScript at all. The problems started when I began to test in Catalina and above... Look at this

Without the title, I found no option to handle windows (move, resize, ...). In addition to the window title (not present in most cases), kCGWindowNumber doesn't match any other value you can get using AppleScript or AppKit or whatever. Furthermore, again after Catalina, window id (which could be another possible potential stable, unique key to identify a window) was removed from AppleScript and returning nothing...

Besides, the objects retrieved using Quartz take a "lot" of time to be refreshed (perhaps it's because I'm using a VM... not sure).

Kind of a hell. I had to refactor the whole module and start using AppleScript, and the window title as "handle"... not the best solution in deed.

In short, and sorry for the long comment, Quartz came out not to be a proper solution (or at least MY Quartz wasn't).

Regarding the change you suggest, I'm affraid that kCGWindowName will not be available in most cases, making it unusable...

Finally, and just for information, using this:

on_screen_window_info_list = Quartz.CGWindowListCopyWindowInfo(Quartz.kCGWindowListOptionOnScreenOnly, Quartz.kCGNullWindowID)

returns a lot of "system" windows which are not the usual target of the module. In addition to that, windows should be filtered using kCGWindowLayer == 0

This is very useful background. Thank you.

super-ibby commented 1 year ago

What the hell?!?! I'm sure I tested your WindowDelegate solution in Catalina. It was pending to test in other versions, so I have started to test it in Yosemite and got this error. I then tested again in Catalina... but I get this very same error!

Traceback (most recent call last):
  File "/Volumes/Proyectos/PycharmProjects/PyWinCtl-dev/src/pywinctl/_pywinctl_macos.py", line 488, in <module>
    class WindowDelegate(AppKit.NSObject):  # Cannot put into a closure as subsequent calls will cause a re-registration error due to subclassing NSObject.
objc.error: WindowDelegate is overriding existing Objective-C class

Don't know what is happening, to be honest.

EDIT: Mistery solved. If you run _pwinctl_macos.py script directly, it loads pywinctl module twice, so it complains because WindowDelegate is already registered. This doesn't happen if you run it as a module. Most likely, when I first tested in Catalina, I didn't update the new module version, but just ran _pwinctl_macos.py directly. Since the existing pywinctl module version had no reference of WindowDelegate, it didn't fail. The question now is how to solve this... I will check!

EDIT 2: Doing this, it seems to work running the script directly as python3 _pywinctl_macos.py and within a separate script as import pywinctl. I have included two calls to getClientFrame() in test_MacOSNSWindow.py, and it doesn't fail, but you have a comment suggesting it's not the right way, correct?:

def getClientFrame(self):
    """
    Get the client area of window including scroll, menu and status bars, as a Rect (x, y, right, bottom)
    Notice that this method won't match non-standard window decoration style sizes

    :return: Rect struct

    WARNING: it will fail if not called within main thread
    """

    class WindowDelegate(AppKit.NSObject):  # Cannot put into a closure as subsequent calls will cause a re-registration error due to subclassing NSObject.
        """Helps run window operations on the main thread."""

        print("IN", registered)
        results: Dict[bytes, Any] = {}  # Store results here. Not ideal, but may be better than using a global.
        registered = True
        print("IN", registered)

        @staticmethod
        def run_on_main_thread(selector: bytes, obj: Optional[Any] = None, wait: Optional[bool] = True) -> Any:
            """Runs a method of this object on the main thread."""
            WindowDelegate.alloc().performSelectorOnMainThread_withObject_waitUntilDone_(selector, obj, wait)
            return WindowDelegate.results.get(selector)

        def getTitleBarHeightAndBorderWidth(self) -> None:
            """Updates results with title bar height and border width."""
            frame_width = 100
            window = AppKit.NSWindow.alloc().initWithContentRect_styleMask_backing_defer_(
                ((0, 0), (frame_width, 100)),
                AppKit.NSTitledWindowMask,
                AppKit.NSBackingStoreBuffered,
                False,
            )
            titlebar_height = int(window.titlebarHeight())
            # print(titlebar_height)
            content_rect = window.contentRectForFrameRect_(window.frame())
            x1 = AppKit.NSMinX(content_rect)
            x2 = AppKit.NSMaxX(content_rect)
            border_width = int(frame_width - (x2 - x1))
            # print(border_width)
            result = titlebar_height, border_width
            WindowDelegate.results[b'getTitleBarHeightAndBorderWidth'] = result

    # https://www.macscripter.net/viewtopic.php?id=46336 --> Won't allow access to NSWindow objects, but interesting
    # Didn't find a way to get menu bar height using Apple Script
    # titleHeight, borderWidth = _getBorderSizes()
    titleHeight, borderWidth = WindowDelegate.run_on_main_thread(b'getTitleBarHeightAndBorderWidth')
    res = Rect(int(self.left + borderWidth), int(self.top + titleHeight), int(self.right - borderWidth), int(self.bottom - borderWidth))
    return res

Using a closure doesn't work for me. For example, consider a file named test.py containing the following lines of code:

from typing import Any, Dict, Optional
import AppKit

def run():

    class WindowDelegate(AppKit.NSObject):  # Cannot put into a closure as subsequent calls will cause a re-registration error due to subclassing NSObject.
        """Helps run window operations on the main thread."""

        results: Dict[bytes, Any] = {}  # Store results here. Not ideal, but may be better than using a global.

        @staticmethod
        def run_on_main_thread(selector: bytes, obj: Optional[Any]=None, wait: Optional[bool]=True) -> Any:
            """Runs a method of this object on the main thread."""
            WindowDelegate.alloc().performSelectorOnMainThread_withObject_waitUntilDone_(selector, obj, wait)
            return WindowDelegate.results.get(selector)

        def getTitleBarHeightAndBorderWidth(self) -> None:
            """Updates results with title bar height and border width."""
            frame_width = 100
            window = AppKit.NSWindow.alloc().initWithContentRect_styleMask_backing_defer_(
                ((0, 0), (frame_width, 100)), 
                AppKit.NSTitledWindowMask, 
                AppKit.NSBackingStoreBuffered, 
                False,
            )
            titlebar_height = int(window.titlebarHeight())
            # print(titlebar_height)
            content_rect = window.contentRectForFrameRect_(window.frame())
            x1 = AppKit.NSMinX(content_rect)
            x2 = AppKit.NSMaxX(content_rect)
            border_width = int(frame_width - (x2 - x1))
            # print(border_width)
            result = titlebar_height, border_width
            WindowDelegate.results[b'getTitleBarHeightAndBorderWidth'] = result

    results = WindowDelegate.run_on_main_thread(b'getTitleBarHeightAndBorderWidth')
    return results

if __name__ == '__main__':
    print(run())

Running the file works fine for me. Now if I treat it as a module and import the run(), it fails:

import sys
sys.path.insert(0, r'/Users/ibby/Developer/PyWinCtl/src')
from pywinctl._test import run
run()

The error message is the same as before:

Screenshot 2023-02-21 at 6 21 13 PM

Actually, I get the same error if I do not wrap WindowDelegate into a closure. But a simple (and ugly) solution seems to be using a try-except block:

rom typing import Any, Dict, Optional
import AppKit

try:
    WindowDelegate = AppKit.WindowDelegate
except BaseException:
    class WindowDelegate(AppKit.NSObject):  # Cannot put into a closure as subsequent calls will cause a re-registration error due to subclassing NSObject.
        """Helps run window operations on the main thread."""

        results: Dict[bytes, Any] = {}  # Store results here. Not ideal, but may be better than using a global.

        @staticmethod
        def run_on_main_thread(selector: bytes, obj: Optional[Any]=None, wait: Optional[bool]=True) -> Any:
            """Runs a method of this object on the main thread."""
            WindowDelegate.alloc().performSelectorOnMainThread_withObject_waitUntilDone_(selector, obj, wait)
            return WindowDelegate.results.get(selector)

        def getTitleBarHeightAndBorderWidth(self) -> None:
            """Updates results with title bar height and border width."""
            frame_width = 100
            window = AppKit.NSWindow.alloc().initWithContentRect_styleMask_backing_defer_(
                ((0, 0), (frame_width, 100)), 
                AppKit.NSTitledWindowMask, 
                AppKit.NSBackingStoreBuffered, 
                False,
            )
            titlebar_height = int(window.titlebarHeight())
            # print(titlebar_height)
            content_rect = window.contentRectForFrameRect_(window.frame())
            x1 = AppKit.NSMinX(content_rect)
            x2 = AppKit.NSMaxX(content_rect)
            border_width = int(frame_width - (x2 - x1))
            # print(border_width)
            result = titlebar_height, border_width
            WindowDelegate.results[b'getTitleBarHeightAndBorderWidth'] = result

if __name__ == '__main__':
    results = WindowDelegate.run_on_main_thread(b'getTitleBarHeightAndBorderWidth')
    print(results)
Kalmat commented 1 year ago

The option of the closure, importing it as a module, works fine on my side, but not when invoking run() twice.

Though I don't think try-except block is a so ugly solution, I think I found a safer, smarter solution. Let me know what you think:

if not hasattr(AppKit, "WindowDelegate"):
    ... import/declare WindowDelegate class ...
else:
    WindowDelegate = AppKit.WindowDelegate
results = WindowDelegate.run_on_main_thread(b'getTitleBarHeightAndBorderWidth')

Thank you for your interest and help. I do really appreciate it!

EDIT: Good news are that your solution also works in Yosemite!!! (the previous one based on AppleScript, didn't)

super-ibby commented 1 year ago

Excellent @Kalmat !

Kalmat commented 1 year ago

Hi, ibby! I'm (sadly) closing this PR if you agree. Please, PR your getClientFrame() solution to master branch so we can merge it with all other changes and prepare a new release. Thank you!

Kalmat commented 1 year ago

Hi again! Please, PR your solution for getClientFrame() to master branch. I hope to be able to create a new module version (v0.1) in very next days / weeks, and I would like that part of the code to be fully attributed to your wonderful contribution!

Thank you!

super-ibby commented 1 year ago

Hi @Kalmat , do you still need me to do this? Apologies, I've been away from GitHub for some time.

Kalmat commented 1 year ago

Hi! No apologies. This is not an obligation at all! Of course I would like you to PR your solution to master branch, so your work is fully attributed to you!

Thank you!