Home > Software engineering >  How to force the style update of a widget on leaveEvent?
How to force the style update of a widget on leaveEvent?

Time:12-14

I am trying to code an application using PySide6 and as a part of the project, I would like to implement my own title bar.

The issue is that when I click on the "Maximize" button, and then on the "Resize" button, the style of the "Maximize" button seems to persist as if the pointer of the mouse was hovering over it. By hiding the button, the leaveEvent is not called (it is normally called when the pointer of the mouse leaves the boundaries of the widget), but even if I manually call the leaveEvent (with a QEvent.Leave payload) and the update method on the maximize button, its style is still not updated.

You will find a working example below:


import sys
from PySide6.QtCore import Slot
from PySide6.QtWidgets import QApplication, QWidget, QFrame, QPushButton, QLabel, QHBoxLayout


def dict_to_stylesheet(properties: dict[str, dict[str, str]]) -> str:
    stylesheet = ""

    for q_object in properties:

        stylesheet  = q_object   " { "

        for style_property in properties[q_object]:
            stylesheet  = f"{style_property}: {properties[q_object][style_property]}; "

        stylesheet  = " } "

    return stylesheet


class MainWindow(QWidget):

    def __init__(self):
        super().__init__()

        # ---------- Attributes ----------
        self.mouse_position = None

        # ---------- Styling attributes ----------
        self.width = 1080
        self.height = 720
        self.minimum_width = 960
        self.minimum_height = 540
        self.background_color = "#EFEFEF"

        self.dict_stylesheet = {
            "QWidget": {
                "background-color": self.background_color
            }
        }

        # ---------- UI elements ----------
        self.title_bar = TitleBar(self)

        # ---------- Layout ----------
        self.layout = QHBoxLayout(self)

        # ---------- Initialize UI ----------
        self.setup_ui()

    def setup_ui(self):
        # ---------- QMainWindow (self) ----------
        self.setMinimumSize(self.minimum_width, self.minimum_height)
        self.resize(self.width, self.height)
        self.setStyleSheet(dict_to_stylesheet(self.dict_stylesheet))

        # ---------- Layout ----------
        self.layout.setSpacing(0)
        self.layout.setContentsMargins(0, 0, 0, 0)
        self.layout.addWidget(self.title_bar)


class TitleBar(QFrame):

    def __init__(self, main_window):
        super().__init__()

        # ---------- Attributes ----------
        self.main_window = main_window

        # ---------- Styling attributes ----------
        self.height = 30
        self.background_color = "#AAAAAA"

        self.dict_stylesheet = {
            "QFrame": {
                "background-color": self.background_color,
            },
            "QPushButton": {
                "border": "none",
                "background-color": self.background_color,
                "margin-left": "5px",
                "margin-right": "5px",
                "padding-left": "2px",
                "padding-right": "2px"
            },
            "QPushButton:hover": {
                "background-color": "#888888",
            },
            "QPushButton:pressed": {
                "background-color": "#666666"
            }
        }

        # ---------- UI elements ----------

        # QPushButtons
        self.minimize_button = QPushButton("Minimize")
        self.maximize_button = QPushButton("Maximize")
        self.resize_button = QPushButton("Resize")
        self.close_button = QPushButton("Close")

        # QLabels
        self.title_label = QLabel("A title")

        # ---------- Layout ----------
        self.layout = QHBoxLayout(self)

        # ---------- Event handling ----------
        self.minimize_button.clicked.connect(self.minimize_app)
        self.maximize_button.clicked.connect(self.maximize_app)
        self.resize_button.clicked.connect(self.resize_app)
        self.close_button.clicked.connect(self.close_app)

        # ---------- Initialize UI ----------
        self.setup_ui()

    def setup_ui(self):
        # ---------- QFrame (self) ----------
        self.setFixedHeight(self.height)
        self.setStyleSheet(dict_to_stylesheet(self.dict_stylesheet))

        # ---------- Title QLabel ----------
        self.title_label.setFixedHeight(self.height)
        self.title_label.setStyleSheet("margin-left: 5px")

        # ---------- QPushButtons ----------
        self.minimize_button.setFixedHeight(self.height)
        self.maximize_button.setFixedHeight(self.height)
        self.resize_button.setFixedHeight(self.height)
        self.resize_button.setHidden(True)
        self.close_button.setFixedHeight(self.height)

        # ---------- Layout ----------
        self.layout.setSpacing(0)
        self.layout.setContentsMargins(0, 0, 0, 0)
        self.layout.addWidget(self.title_label)
        self.layout.addStretch(1)
        self.layout.addWidget(self.minimize_button)
        self.layout.addWidget(self.maximize_button)
        self.layout.addWidget(self.resize_button)
        self.layout.addWidget(self.close_button)

    @Slot()
    def minimize_app(self):
        self.main_window.showMinimized()

    @Slot()
    def maximize_app(self):
        self.main_window.showMaximized()

        # Update layout
        self.toggle_resize_and_maximize_buttons()

    @Slot()
    def resize_app(self):
        self.main_window.showNormal()

        # Update layout
        self.toggle_resize_and_maximize_buttons()

    @Slot()
    def close_app(self):
        self.main_window.close()

    def toggle_resize_and_maximize_buttons(self) -> None:
        hide_maximize_button = True if self.maximize_button.isVisible() else False
        hide_resize_button = not hide_maximize_button

        self.maximize_button.setHidden(hide_maximize_button)
        self.resize_button.setHidden(hide_resize_button)


if __name__ == '__main__':
    app = QApplication()

    main = MainWindow()
    main.show()

    sys.exit(app.exec())

Steps to reproduce the undesired behavior:

  1. Click on the "Maximize" button
  2. Click on the "Resize" button

If you have any idea as how to fix this bug, I would appreciate it.

CodePudding user response:

I cannot always reproduce the issue, but I see the point.

The problem resides on the fact that changing the window state (maximizing or "normalizing") is not directly done by Qt, but it's a request done to the OS, so the resizing might not happen instantly. The result is that, during that process, the widget (or, better, Qt) still believes that the widget is under the mouse cursor, even if it isn't, and that's because Qt hasn't processed the whole resizing event queue yet.

Right now I don't see a direct solution to that (and I doubt that there would be, as it completely depends on the underlying window manager), but when the widget receives a resize event we do know that the widget has been resized. This means that we can check for the geometry and cursor position and update the Qt.WA_UnderMouse attribute (which is normally set by Qt after the resizing has been completed) on our own.

class TitleBar(QFrame):
    # ...
    def resizeEvent(self, event):
        super().resizeEvent(event)
        cursor = QCursor.pos()
        for button in self.resize_button, self.maximize_button:
            rect = button.rect().translated(button.mapToGlobal(QPoint()))
            if not button.isVisible():
                continue
            underMouse = rect.contains(cursor)
            if button.testAttribute(Qt.WA_UnderMouse) != underMouse:
                button.setAttribute(Qt.WA_UnderMouse, underMouse)
                button.update()

Unfortunately, this has some drawbacks. The resizing can be done from an external source, and the button could still be virtually under the mouse even if it's actually partially covered by another window.

A possible approach would be to check for the actual widget under the mouse using QApplication.widgetAt() whenever the window is resized, but, as the documentation also suggests, the function can be slow. If opaque resizing is enabled (all widget contents are dynamically resized when resizing the window from the edges), the function is actually very costly in terms of performance, as even a single pixel change in size will potentially check the whole widget tree for the given coordinates.

Since you will probably be interested in updating the buttons only when the window state changes, the work around is to set an internal state flag for the state change and call updates only when required by installing an event filter on the main window (the parent of the title bar) that eventually triggers the function when the state has actually been changed:

class TitleBar(QFrame):
    windowStateChange = False
    def __init__(self, main_window):
        super().__init__()
        main_window.installEventFilter(self)
        # ...

    def eventFilter(self, obj, event):
        if event.type() == QEvent.Type.WindowStateChange and obj.windowState():
            self.windowStateChange = True
        elif event.type() == QEvent.Type.Resize and self.windowStateChange:
            self.windowStateChange = False
            cursor = QCursor.pos()
            widgetUnderMouse = QApplication.widgetAt(cursor)
            for button in self.resize_button, self.maximize_button:
                if button.isVisible():
                    underMouse = widgetUnderMouse == button
                    if underMouse != button.testAttribute(Qt.WA_UnderMouse):
                        button.setAttribute(Qt.WA_UnderMouse, underMouse)
                    button.update()
        return super().eventFilter(obj, event)

Note that the following line is unnecessarily complex:

hide_maximize_button = True if self.maximize_button.isVisible() else False

isVisible() already returns a boolean value; change it to:

hide_maximize_button = self.maximize_button.isVisible()
  • Related