Kalmat / PyWinCtl

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

Use AppKit instead of AppleScript to get titlebar height and border width #50

Closed super-ibby closed 1 year ago

super-ibby commented 1 year ago

Hi @Kalmat , I have found a way to get the titlebar height and border width using AppKit rather than subprocess calls to run an AppleScript. This should result in some performance improvements, though I've not measured this!

The new WindowDelegate class can be used to run operations on the main thread where required. I'm thinking this class can be extended with additional methods to replace other uses of AppleScript in the code.

Please let me know what you think.

Thanks!

Kalmat commented 1 year ago

Hi! This looks awesome!!! Thank you!

Let me check if this works in all versions and cases. Beforehand, it seems to be a much better solution than AppleScript, which is usually slow, tricky and complex. I can not merge it because the typing checks have failed. I don't know if you can see the results, that's why I'm attaching them here:

Captura de pantalla 2023-02-18 172556

Captura de pantalla 2023-02-18 172624

The new WindowDelegate class can be used to run operations on the main thread where required. I'm thinking this class can be extended with additional methods to replace other uses of AppleScript in the code.

This sounds like heaven to me. Please let me know any progress you make or if I can help in any way (I never heard about this new class neither I am an AppKit expert, but I can help to investigate, test, ...). Replacing AppleScript as much as possible is like a dream come true!!!

Thank you SO MUCH!

super-ibby commented 1 year ago

Hi @Kalmat , I've added type hints now.

Notes about the results member dictionary in WindowDelegate

  1. The key for results is meant to be the pyobjc selector as a bytestring.
  2. The value for results is set to Any to allow the class to be extended with additional methods. For example, at the moment, it has one method to get the titlebar height and border width. So the results would be a tuple in this case, but if we introduce another method in the future, the result may be a single value.

I hope that makes sense!

super-ibby commented 1 year ago

Replacing AppleScript as much as possible is like a dream come true!!!

I will have a look to see if I can replace more usages of AppleScript with AppKit. (On my spare time of course! 😄 )

Kalmat commented 1 year ago

I hope that makes sense!

It makes sense! We will see what it turns out to be in the future. Perhaps we can define a type indicator or similar. For now, it's ok!

I will have a look to see if I can replace more usages of AppleScript with AppKit. (On my spare time of course! 😄 )

Wow! That would be amazing. Of course in your spare time... this is just for fun! No obligation, no hurries... Thank you!

super-ibby commented 1 year ago

Hi @Kalmat ,

I've just noticed you have an entry point (if __name__ == '__main__') in _pywinctl_macos.py. When I run the file as a script, I get the following error:

Traceback (most recent call last):
  File "/Users/ibby/Developer/PyWinCtl/src/pywinctl/_pywinctl_macos.py", line 478, 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

(This is the re-registration error I allude to in the comment next to the class definition.)

However, if I use the file as a module and import from it, I do not get an error. Also, if I copy the WindowDelegate class definition into a new python file, and run that file, I get no errors. For example:

from typing import Any, Dict, Optional
import AppKit

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

    def foo(self):  # Just to test.
        print('foo')

if __name__ == '__main__':
    wd = WindowDelegate.alloc()
    wd.foo()

Therefore, something seems odd with the _pywinctl_macos.py entry point. I've not figured it out yet, but wanted to check with you if you've seen something similar.