Kalmat / PyWinCtl

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

MacOS: AssertionError in both tests #47

Closed nishyp closed 1 year ago

nishyp commented 1 year ago

Hello, I'm fairly new to all this and I'm getting AssertionErrors when I run the two tests

System Info: MacOS Catalina 10.15.7 - 9700k, 64GB RAM, RX 5700 XT Python 3.10.9 PyWinCtl 0.0.42

When running test_MacNSWindow.py

Hello, World! <CoreFoundation.CGRect origin=<CoreFoundation.CGPoint x=400.0 y=800.0> size=<CoreFoundation.CGSize width=250.0 height=122.0>>
ACTIVE WINDOW: Hello, World!
RESIZE Size(width=600, height=400)
RESIZEREL Size(width=610, height=420)
MOVE Point(x=600, y=743)
2023-02-04 17:27:22.234 Python[70925:5982037] <class 'AssertionError'>: 
Now I'm ACTIVE
Window has been closed
Window has been closed

When running test_pywinctl.py

Traceback (most recent call last):
  File "/PyWinCtl-0.0.42/tests/test_pywinctl.py", line 569, in <module>
    main()
  File "/PyWinCtl-0.0.42/tests/test_pywinctl.py", line 565, in main
    test_basic()
  File "/PyWinCtl-0.0.42/tests/test_pywinctl.py", line 55, in test_basic
    basic_macOS(npw)
  File "/PyWinCtl-0.0.42/tests/test_pywinctl.py", line 472, in basic_macOS
    assert npw.bottom == 800
AssertionError
Kalmat commented 1 year ago

Hi! Thank you for your interest and your feedback!

Regarding test_MacOSNSWindow.py, this is my output in Catalina:

Hello, World! <NSRect origin=<NSPoint x=400.0 y=800.0> size=<NSSize width=250.0 height=122.0>> <NSPoint x=400.0 y=800.0> <NSSize width=250.0 height=122.0> <class 'NSRect'> <class 'NSPoint'>  --> My objects are NS*, whilst yours are CG*
ACTIVE WINDOW: Hello, World!
RESIZE Size(width=600, height=400)
RESIZEREL Size(width=610, height=420)
MOVE Point(x=600, y=300)  --> My output stands that the window will be moved to position (600, 300), whilst yours says (600, 743)
... --> My output continues, with no errors

EDIT: I first thought the problem was related to NS (in my case) vs. CG (in your case) objects... but after updating my version (3.9) to your very same version (3.10.9), I now get the same objects types, but doesn't fail in my case. The difference about the target point is still there. In my system the target is (600, 300), whilst in yours it's (600, 743). Let me investigate a little bit to try to understand. It's possible I have to use you as tester, if you agree, since I don't know how to reproduce it.

In the mean time, can you please tell me your screen resolution, check if your python is 32 or 64 bits, and run this on a terminal?:

python3 -m pip install pywinctl --force-reinstall

this last will assure we have the same version of all related packages.

Thanks again!

nishyp commented 1 year ago

Thanks for getting back to me on this, I'm more than happy to be a tester!

I've gone ahead and done the force reinstall and can confirm that python is 64bit retesting also brings the same result

My screen info: I'm running 3x Dell U2412Mb monitors The resolution is 1920 x 1200 for each one

Attached is the layout in case that's needed also Screen Shot 2023-02-05 at 2 01 11 AM

Kalmat commented 1 year ago

wow! THREE monitors!!! I will definitely not be able to reproduce it!! HAHAHAHA!

First, when you have the time (of course no obligation, no hurries), please try this very simple script (it's just to check what might be happening with the screen positions in your case). Please pay visual attention where the window is placed in every one of the three steps (there is a print() statement to signal them) and check if the window is placed where it's expected.

import sys
import time

from AppKit import (
    NSApp, NSObject, NSApplication, NSMakeRect, NSWindow, NSWindowStyleMaskTitled, NSWindowStyleMaskClosable,
    NSWindowStyleMaskMiniaturizable, NSWindowStyleMaskResizable, NSBackingStoreBuffered, NSMakeRect)

import pywinctl
print(pywinctl.getAllScreens())

def _moveTo(handle, newLeft: int, newTop: int, width, height):
    handle.setFrame_display_animate_(
        NSMakeRect(newLeft, pywinctl.getScreenSize().height - newTop - height, width, height),
        True, True)

def _moveToUnflipped(handle, newLeft: int, newTop: int, width, height):
    handle.setFrame_display_animate_(NSMakeRect(newLeft, newTop, width, height), True, True)

class Delegate(NSObject):

    npw = None
    demoMode = False

    def getDemoMode(self):
        return self.demoMode

    def setDemoMode(self):
        self.demoMode = True

    def unsetDemoMode(self):
        self.demoMode = False

    def applicationDidFinishLaunching_(self, aNotification: None):
        NSApp().activateIgnoringOtherApps_(True)
        for win in NSApp().orderedWindows():
            print(win.title(), win.frame(), type(win.frame().origin))

        if self.demoMode:

            if not self.npw:
                self.npw = pywinctl.getActiveWindow(NSApp())

                if self.npw:
                    print("ACTIVE WINDOW:", self.npw.title)
                else:
                    print("NO ACTIVE WINDOW FOUND")
                    return

            print("BEFORE MOVE", self.npw.box, "Window should be at the left and near the bottom")
            time.sleep(1)
            _moveToUnflipped(self.npw.getHandle(), 1800, 50, self.npw.width, self.npw.height)
            time.sleep(1)
            print("AFTER MOVE", self.npw.box, "Window should be at the right and near the bottom")
            time.sleep(1)
            _moveTo(self.npw.getHandle(), 50, 50, self.npw.width, self.npw.height)
            time.sleep(1)
            print("AFTER FLIPPED MOVE", self.npw.box, "Window should be at left and near the top")

    def windowWillClose_(self, aNotification: None):
        print("Window has been closed")
        NSApp().terminate_(self)

    def windowDidBecomeKey_(self, aNotification: None):
        print("Now I'm ACTIVE")

def demo():
    a = NSApplication.sharedApplication()
    delegate = Delegate.alloc().init()
    delegate.setDemoMode()
    a.setDelegate_(delegate)

    frame = NSMakeRect(50, 50, 250, 100)
    mask = NSWindowStyleMaskTitled | NSWindowStyleMaskClosable | NSWindowStyleMaskMiniaturizable | NSWindowStyleMaskResizable
    w = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_(frame, mask, NSBackingStoreBuffered, False)

    w.setDelegate_(delegate)
    w.setTitle_(u'Hello, World!')
    w.orderFrontRegardless()

    w.display()
    a.run()

if __name__ == '__main__':
    demo()

Second, disconnect two of your monitors (please include the one which is vertical), leaving just one, and re-test everything, I mean test_pywinctl.py, test_MacOSNSWindow.py and the script above, and let me know what happens!

Thank you!!!

Kalmat commented 1 year ago

Hi! Sorry to bother you. I just wanted to check if you had the chance to test this so we can try to solve it and include the potential solution in the next release. Thank you!

nishyp commented 1 year ago

This is with a single screen

So I just tried the script you provided by copy/pasting into a file called test.py

{'DELL U2412M': {'id': 724063885, 'is_primary': True, 'pos': Point(x=0, y=0), 'size': Size(width=1920, height=1200), 'workarea': Rect(left=0, top=0, right=1920, bottom=1177), 'scale': (100, 100), 'dpi': (72, 72), 'orientation': 0, 'frequency': 0.0, 'colordepth': 32}}
Hello, World! <Foundation.NSRect origin=<Foundation.NSPoint x=50.0 y=50.0> size=<Foundation.NSSize width=250.0 height=122.0>> <class 'Foundation.NSPoint'>
ACTIVE WINDOW: Hello, World!
BEFORE MOVE Box(left=50, top=1028, width=250, height=122) Window should be at the left and near the bottom
AFTER MOVE Box(left=1800, top=1028, width=250, height=122) Window should be at the right and near the bottom
AFTER FLIPPED MOVE Box(left=50, top=50, width=250, height=122) Window should be at left and near the top
Now I'm ACTIVE

and once I close the box

Window has been closed

test_pywinctl.py - this didn't show any info when just typing python3 PATH so I did python3 -m pytest -vv PATH

================================================================================================= test session starts ==================================================================================================
platform darwin -- Python 3.10.9, pytest-7.2.1, pluggy-1.0.0 -- /Library/Frameworks/Python.framework/Versions/3.10/bin/python3
cachedir: .pytest_cache
rootdir: /Users/myusername/Downloads/PyWinCtl-0.0.42-test
collected 1 item                                                                                                                                                                                                       

Downloads/PyWinCtl-0.0.42-test/tests/test_pywinctl.py::test_basic PASSED                                                                                                                                         [100%]

============================================================================================ 1 passed in 107.68s (0:01:47) =============================================================================================

test_MacNSWindow.py

Hello, World! <Foundation.NSRect origin=<Foundation.NSPoint x=400.0 y=800.0> size=<Foundation.NSSize width=250.0 height=122.0>>
ACTIVE WINDOW: Hello, World!
RESIZE Size(width=600, height=400)
RESIZEREL Size(width=610, height=420)
MOVE Point(x=600, y=300)
MOVEREL Point(x=601, y=302)
RESIZE Size(width=601, height=401)
MOVE Point(x=100, y=600)
MOVE Point(x=200, y=600)
MOVE Point(x=-401, y=600)
MOVE Point(x=-401, y=200)
MOVE Point(x=-401, y=399)
MOVE Point(x=300, y=400)
MOVE Point(x=-301, y=400)
MOVE Point(x=300, y=299)
MOVE Point(x=-301, y=499)
MOVE Point(x=300, y=200)
MOVE Point(x=-301, y=200)
MOVE Point(x=0, y=400)
MOVE Point(x=0, y=299)
MOVE Point(x=0, y=200)
MOVE Point(x=700, y=200)
MOVE Point(x=700, y=100)
RESIZE Size(width=600, height=401)
LOWER
RAISE
ALWAYS ON TOP
DEACTIVATE AOT
ALWAYS AT BOTTOM
DEACTIVATE AOB
SEND BEHIND
BRING FROM BEHIND
GET PARENT
HIDE
SHOW
CLOSE
Window has been closed
Window has been closed
nishyp commented 1 year ago

This is with all screens connected for the test.py

{'DELL U2412M': {'id': 724063885, 'is_primary': False, 'pos': Point(x=0, y=-1200), 'size': Size(width=1920, height=1200), 'workarea': Rect(left=0, top=-1200, right=1920, bottom=1200), 'scale': (100, 100), 'dpi': (72, 72), 'orientation': 0, 'frequency': 0.0, 'colordepth': 32}}
Hello, World! <Foundation.NSRect origin=<Foundation.NSPoint x=50.0 y=50.0> size=<Foundation.NSSize width=250.0 height=122.0>> <class 'Foundation.NSPoint'>
ACTIVE WINDOW: Hello, World!
BEFORE MOVE Box(left=50, top=1028, width=250, height=122) Window should be at the left and near the bottom
AFTER MOVE Box(left=1800, top=1028, width=250, height=122) Window should be at the right and near the bottom
AFTER FLIPPED MOVE Box(left=50, top=50, width=250, height=122) Window should be at left and near the top
Now I'm ACTIVE

once i close the box

Window has been closed
Window has been closed
Kalmat commented 1 year ago

Thank you!

First bug I can detect is that in getAllScreens() I have to add the index to the monitor name or, in case the physical names of the monitors are the same (which is your case), its info it's overwritten (this is why there is just one monitor in the dict output you pasted).

Second, if I am interpreting correctly the output with just one monitor plugged, it is working OK with one monitor. If so, I think the issue is here:

{'DELL U2412M': {'id': 724063885, 'is_primary': False, 'pos': Point(x=0, y=-1200), 'size': Size(width=1920, height=1200), 'workarea': Rect(left=0, top=-1200, right=1920, bottom=1200), 'scale': (100, 100), 'dpi': (72, 72), 'orientation': 0, 'frequency': 0.0, 'colordepth': 32}}

More specifically, here: Point(x=0, y=-1200). One of your monitors starts in Y coordinate -1200.

Then, the problem is how the bottom property is calculated in PyRect (external module used by PyWinCtl):

@property
def bottom(self):
    """The y coordinate for the bottom edge of the rectangle.

    >>> r = Rect(0, 0, 10, 20)
    >>> r.bottom
    20
    >>> r.bottom = 30
    >>> r
    Rect(left=0, top=10, width=10, height=20)
    """
    if self.onRead is not None:
        self.onRead(BOTTOM)
    return self._top + self._height

If top is 300 and height is 122, the result is 422, what is right. But what happens if top is -300? The result is -188, what it's wrong... Since PyRect is not intended for multi-monitor setups, at this moment I see no other alternative apart from replacing PyRect by custom code.

Besides, if you intend to work with PyWinCtl in a multi-monitor setup, and once I figure out how to solve the issue above, you have to bear in mind these display positions. Let me explain myself in detail.

math.copysign(1, displayX) * (abs(displayX) + targetX), math.copysign(1, displayY) * (abs(displayY) + targetY) = 100, -1300

Please, let me know if you need further help on this.

Kalmat commented 1 year ago

Hi again!

I have a preliminary version of the module in which I replaced PyRect by a (hopefully) multi-monitor custom class. If you have the time, I would really appreciate if you could test this new version (of course, no obligation, no hurries...). I'm attaching this new wheel so you can uninstall previous pywinctl version (python3 -m pip uninstall pywinctl), install this new one (python3 -m pip install wherever/you/placed/it/PyWinCtl-0.1-py3-none-any.whl) and run test_pywinctl.py and test_MacNSWindow.py again.

Thanks a lot for your help!

PyWinCtl-0.1-py3-none-any.zip

nishyp commented 1 year ago

I apologise for the delay in getting back to you! I've downloaded 0.1 and ran the tests from 0.0.43

Here are the results

test_pywinctl.py

==================================================================== test session starts ====================================================================
platform darwin -- Python 3.10.9, pytest-7.2.1, pluggy-1.0.0 -- /Library/Frameworks/Python.framework/Versions/3.10/bin/python3.10
cachedir: .pytest_cache
rootdir: /Users/myusername/Downloads/PyWinCtl-0.0.43
collected 1 item                                                                                                                                            

Downloads/PyWinCtl-0.0.43/tests/test_pywinctl.py::test_basic FAILED                                                                                   [100%]

========================================================================= FAILURES ==========================================================================
________________________________________________________________________ test_basic _________________________________________________________________________

    def test_basic():

        if sys.platform == "win32":
            subprocess.Popen('notepad')
            time.sleep(0.5)

            testWindows = [pywinctl.getActiveWindow()]
            # testWindows = pywinctl.getWindowsWithTitle('Untitled - Notepad')   # Not working in other languages
            assert len(testWindows) == 1

            npw = testWindows[0]

            basic_win32(npw)

        elif sys.platform == "linux":
            subprocess.Popen('gedit')
            time.sleep(5)

            testWindows = [pywinctl.getActiveWindow()]
            assert len(testWindows) == 1

            npw = testWindows[0]

            basic_linux(npw)

        elif sys.platform == "darwin":
            if not pywinctl.checkPermissions(activate=True):
                exit()
            subprocess.Popen(['touch', 'test.py'])
            time.sleep(2)
            subprocess.Popen(['open', '-a', 'TextEdit', 'test.py'])
            time.sleep(5)

            testWindows = pywinctl.getWindowsWithTitle('test.py')
            assert len(testWindows) == 1

            npw = testWindows[0]
            assert isinstance(npw, pywinctl.Window)

            npw = testWindows[0]

>           basic_macOS(npw)

Downloads/PyWinCtl-0.0.43/tests/test_pywinctl.py:55: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

npw = MacOSWindow(hWnd=<NSRunningApplication: 0x7f85ee64a010 (com.apple.TextEdit - 53430) LSASN:{hi=0x0;lo=0x391391}>)

    def basic_macOS(npw: pywinctl.Window):

        assert npw is not None

        wait = True
        timelap = 0.50

        # Test maximize/minimize/restore.
        if npw.isMaximized:  # Make sure it starts un-maximized
            npw.restore(wait=wait)

        assert not npw.isMaximized

        npw.maximize(wait=wait)
        time.sleep(timelap)
        assert npw.isMaximized
        npw.restore(wait=wait)
        time.sleep(timelap)
        assert not npw.isMaximized

        npw.minimize(wait=wait)
        time.sleep(timelap)
        assert npw.isMinimized
        npw.restore(wait=wait)
        time.sleep(timelap)
        assert not npw.isMinimized

        # Test resizing
        npw.resizeTo(600, 400, wait=wait)
        time.sleep(timelap)
        assert npw.size == (600, 400)
        assert npw.width == 600
        assert npw.height == 400

        npw.resizeRel(10, 20, wait=wait)
        assert npw.size == (610, 420)
        assert npw.width == 610
        assert npw.height == 420

        # Test moving
        npw.moveTo(50, 54, wait=wait)
        assert npw.topleft == (50, 54)
        assert npw.left == 50
        assert npw.top == 54
        assert npw.right == 660
        assert npw.bottom == 474
        assert npw.bottomright == (660, 474)
        assert npw.bottomleft == (50, 474)
        assert npw.topright == (660, 54)

        npw.moveRel(1, 2, wait=wait)
        assert npw.topleft == (51, 56)
        assert npw.left == 51
        assert npw.top == 56
        assert npw.right == 661
        assert npw.bottom == 476
        assert npw.bottomright == (661, 476)
        assert npw.bottomleft == (51, 476)
        assert npw.topright == (661, 56)

        # Move via the properties
        npw.resizeTo(601, 401, wait=wait)
        npw.moveTo(100, 250, wait=wait)

        npw.left = 200
        time.sleep(timelap)
        assert npw.left == 200

        npw.right = 200
        time.sleep(timelap)
        assert npw.right == 200

        npw.top = 200
        time.sleep(timelap)
        assert npw.top == 200

        npw.bottom = 800
        time.sleep(timelap)
>       assert npw.bottom == 800
E       assert 904 == 800
E        +  where 904 = MacOSWindow(hWnd=<NSRunningApplication: 0x7f85ee64a010 (com.apple.TextEdit - 53430) LSASN:{hi=0x0;lo=0x391391}>).bottom

Downloads/PyWinCtl-0.0.43/tests/test_pywinctl.py:472: AssertionError
================================================================== short test summary info ==================================================================
FAILED Downloads/PyWinCtl-0.0.43/tests/test_pywinctl.py::test_basic - assert 904 == 800
=============================================================== 1 failed in 63.15s (0:01:03) ================================================================

test_MacNSWindow.py

Hello, World! <CoreFoundation.CGRect origin=<CoreFoundation.CGPoint x=400.0 y=800.0> size=<CoreFoundation.CGSize width=250.0 height=122.0>>
ACTIVE WINDOW: Hello, World!
RESIZE Size(width=600, height=400)
RESIZEREL Size(width=610, height=420)
MOVE Point(x=600, y=300)
MOVEREL Point(x=601, y=302)
RESIZE Size(width=601, height=401)
MOVE Point(x=100, y=600)
MOVE Point(x=200, y=600)
MOVE Point(x=-401, y=600)
MOVE Point(x=-401, y=200)
MOVE Point(x=-401, y=399)
MOVE Point(x=300, y=400)
MOVE Point(x=-301, y=400)
MOVE Point(x=300, y=299)
MOVE Point(x=-301, y=503)
2023-05-18 01:51:07.897 Python[53913:2000695] <class 'AssertionError'>: 
Now I'm ACTIVE
Kalmat commented 1 year ago

Hi! Thank you SO MUCH! Please, no apologies at all!

Since then, I have been working in a totally new version (hopefully multi-monitor-ready... or near). I have also developed and uploaded a very preliminary, experimental new module (PyMonCtl, used by PyWinCtl) to handle monitors in multi-monitor (or single-monitor, of course) setups. If you can give a try to both, it would be very very very helpful!!!

I couldn't test anything of this since I still have no access to an actual macOS setup, and VMs don't allow (or I don't know how to) set multiple monitors.

Thanks!