PySide2 - Frameless window with custom titlebar - issues when dragging it to other screen (Windows)

Viewed 625

From time to time, I tried to create a custom title bar for my PyQt5/PySide2 application but haven't figured out how to do it properly. Hiding the original Windows title bar is easy, and so is overriding mouseMoveEvents to enable moving the frameless window. Resizing was a bit more challenging, but also possible. But the biggest problem for me was that I couldn't figure out how to maintain the Windows Aero Snap functionality (the ability to snap Windows in place by pressing the Windows key + an arrow key, or by dragging it to the screen borders).

Today, I found a code example on GitHub that solved this problem. It's working just perfectly, except when I move my application to another screen... The moment I'm dragging it to my second screen, I can't move it anymore and I can't press any button anymore. I'm just able to resize it. If I resize it back to the main screen, I'm able to drag it again.

enter image description here

Here's the code:

import sys
import ctypes
from ctypes import wintypes

import win32api
import win32con
import win32gui
from PySide2.QtCore import Qt, QRect
from PySide2.QtGui import QColor, QWindow, QScreen, QCursor
from PySide2.QtWidgets import QWidget, QPushButton, QApplication, \
    QVBoxLayout, QSizePolicy, QHBoxLayout
from PySide2.QtWinExtras import QtWin


class MINMAXINFO(ctypes.Structure):
    _fields_ = [
        ("ptReserved", wintypes.POINT),
        ("ptMaxSize", wintypes.POINT),
        ("ptMaxPosition", wintypes.POINT),
        ("ptMinTrackSize", wintypes.POINT),
        ("ptMaxTrackSize", wintypes.POINT),
    ]


class TitleBar(QWidget):

    def __init__(self):
        super().__init__()
        self._layout = QHBoxLayout()

        # set size
        self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
        self.setMinimumHeight(50)

        self.button = QPushButton("EXIT", clicked=app.exit)
        self.button.setStyleSheet("""
            QPushButton{
                border: none;
                outline: none;
                background-color: rgb(220,0,0);
                color: white;
                padding: 6px;
                width: 80px;
                font: 16px consolas;
            }

            QPushButton:hover{
            background-color: rgb(240,0,0);
            }
        """)

        # set background color
        self.setAutoFillBackground(True)
        p = self.palette()
        p.setColor(self.backgroundRole(), QColor("#212121"))
        self.setPalette(p)

        self._layout.addStretch()
        self._layout.addWidget(self.button)
        self.setLayout(self._layout)


class Window(QWidget):
    BorderWidth = 5

    def __init__(self):
        super().__init__()
        # get the available resolutions without taskbar
        self._rect = QApplication.instance().desktop().availableGeometry(self)
        self.resize(800, 600)
        self.setWindowFlags(Qt.Window
                            | Qt.FramelessWindowHint
                            | Qt.WindowSystemMenuHint
                            | Qt.WindowMinimizeButtonHint
                            | Qt.WindowMaximizeButtonHint
                            | Qt.WindowCloseButtonHint)

        self.current_screen = None

        # Create a thin frame
        style = win32gui.GetWindowLong(int(self.winId()), win32con.GWL_STYLE)
        win32gui.SetWindowLong(int(self.winId()), win32con.GWL_STYLE, style | win32con.WS_THICKFRAME)

        if QtWin.isCompositionEnabled():
            # Aero Shadow
            QtWin.extendFrameIntoClientArea(self, -1, -1, -1, -1)
            pass
        else:
            QtWin.resetExtendedFrame(self)

        # Window Widgets
        self._layout = QVBoxLayout()
        self._layout.setContentsMargins(0, 0, 0, 0)
        self._layout.setSpacing(0)

        self.titleBar = TitleBar()
        self.titleBar.setObjectName("titleBar")

        # main widget is here
        self.mainWidget = QWidget()
        self.mainWidgetLayout = QVBoxLayout()
        self.mainWidgetLayout.setContentsMargins(0, 0, 0, 0)

        # content
        self.test_button = QPushButton('Test')
        self.test_button.clicked.connect(self.on_test_button_clicked)

        self.mainWidgetLayout.addWidget(self.test_button)

        self.mainWidget.setLayout(self.mainWidgetLayout)
        self.mainWidget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)

        # set background color
        self.mainWidget.setAutoFillBackground(True)
        p = self.mainWidget.palette()
        p.setColor(self.mainWidget.backgroundRole(), QColor("#272727"))
        self.mainWidget.setPalette(p)

        self._layout.addWidget(self.titleBar)
        self._layout.addWidget(self.mainWidget)
        self.setLayout(self._layout)
        self.show()

    def on_test_button_clicked(self):
        self.updateGeometry()

    def nativeEvent(self, eventType, message):
        retval, result = super().nativeEvent(eventType, message)

        # if you use Windows OS
        if eventType == "windows_generic_MSG":
            msg = ctypes.wintypes.MSG.from_address(message.__int__())

            # Get the coordinates when the mouse moves.
            x = win32api.LOWORD(ctypes.c_long(msg.lParam).value) - self.frameGeometry().x()
            y = win32api.HIWORD(ctypes.c_long(msg.lParam).value) - self.frameGeometry().y()

            # Determine whether there are other controls(i.e. widgets etc.) at the mouse position.
            if self.childAt(x, y) is not None and self.childAt(x, y) is not self.findChild(QWidget, "titleBar"):
                # passing
                if self.width() - 5 > x > 5 and y < self.height() - 5:
                    return retval, result

            if msg.message == win32con.WM_NCCALCSIZE:
                # Remove system title
                return True, 0
            if msg.message == win32con.WM_GETMINMAXINFO:
                # This message is triggered when the window position or size changes.
                info = ctypes.cast(
                    msg.lParam, ctypes.POINTER(MINMAXINFO)).contents
                # Modify the maximized window size to the available size of the main screen.
                info.ptMaxSize.x = self._rect.width()
                info.ptMaxSize.y = self._rect.height()
                # Modify the x and y coordinates of the placement point to (0,0).
                info.ptMaxPosition.x, info.ptMaxPosition.y = 0, 0

            if msg.message == win32con.WM_NCHITTEST:
                w, h = self.width(), self.height()
                lx = x < self.BorderWidth
                rx = x > w - self.BorderWidth
                ty = y < self.BorderWidth
                by = y > h - self.BorderWidth
                if lx and ty:
                    return True, win32con.HTTOPLEFT
                if rx and by:
                    return True, win32con.HTBOTTOMRIGHT
                if rx and ty:
                    return True, win32con.HTTOPRIGHT
                if lx and by:
                    return True, win32con.HTBOTTOMLEFT
                if ty:
                    return True, win32con.HTTOP
                if by:
                    return True, win32con.HTBOTTOM
                if lx:
                    return True, win32con.HTLEFT
                if rx:
                    return True, win32con.HTRIGHT
                # Title
                return True, win32con.HTCAPTION

        return retval, result

    def moveEvent(self, event):
        if not self.current_screen:
            print('Initial Screen')
            self.current_screen = self.screen()
        elif self.current_screen != self.screen():
            print('Changed Screen')
            self.current_screen = self.screen()
            self.updateGeometry()

            win32gui.SetWindowPos(int(self.winId()), win32con.NULL, 0, 0, 0, 0,
                                  win32con.SWP_NOMOVE | win32con.SWP_NOSIZE | win32con.SWP_NOZORDER |
                                  win32con.SWP_NOOWNERZORDER | win32con.SWP_FRAMECHANGED | win32con.SWP_NOACTIVATE)

        event.accept()


if __name__ == '__main__':
    app = QApplication(sys.argv)
    w = Window()
    sys.exit(app.exec_())


Does someone have an idea of how to fix this issue?

1 Answers

Hey I am the owner of that github account. I fixed it along some other issues, you can check it out here. The solution is simple. you need to convert the x,y variables which are unsigned int to int. That's all.

if you print x,y when the window is on the second monitor, you can see the values are in the 65XXX range. since python does not have built-in unsigned int type, you need to convert them manually, like this:

x = win32api.LOWORD(ctypes.c_long(msg.lParam).value)
if x & 32768: x = x | -65536
y = win32api.HIWORD(ctypes.c_long(msg.lParam).value)
if y & 32768: y = y | -65536

x = x - self.frameGeometry().x()
y = y - self.frameGeometry().y()
Related