Search code examples
pyqt

How to make PyQt events of widget to run beforehand?


I have a script for a radiant menu that is intended to appear when a user presses the right-click mouse button. While holding the button down, the user can choose a "Tool." The radiant menu is designed to close when the user releases the mouse button.

However, I've encountered an issue with my script. When I initially press the right-click button for the first time, none of the events associated with the radiant button seem to run. In an attempt to troubleshoot, I decided to remove the closing function on button release. Surprisingly, I found that my events only fail to execute when I initially press the right-click button. Strangely, everything starts working correctly when I release the button. I suspect this behavior is related to how Qt handles events.

I've attempted to resolve this issue by implementing an eventFilter, but it doesn't seem to have helped. Could someone please explain how I can achieve the desired functionality? I want the radiant menu to appear while the right-click button is pressed, allowing the user to choose a "Tool," and then automatically close when the button is released.

Here is an example of the script which I wrote so far:

import math
import sys
from PyQt5.QtCore import *
from PyQt5.QtWidgets import *
from PyQt5.QtGui import *


class RadialMenu(QWidget):
    WINDOW_WIDTH = 320
    WINDOW_HEIGHT = 200

    OUTER_CIRCLE_RADIUS = 50
    OUTER_CIRCLE_DIAMETER = 2 * OUTER_CIRCLE_RADIUS

    INNER_CIRCLE_RADIUS = 10
    INNER_CIRCLE_DIAMETER = 2 * INNER_CIRCLE_RADIUS

    TEXT_WIDTH = 80
    TEXT_HEIGHT = 24
    TEXT_RADIUS = 0.5 * TEXT_HEIGHT

    NUM_ZONES = 8
    ZONE_SPAN_ANGLE = 360 / NUM_ZONES

    def __init__(self, hotkey, parent=None):
        super(RadialMenu, self).__init__(parent)
        self.setFixedSize(self.WINDOW_WIDTH, self.WINDOW_HEIGHT)
        self.setWindowFlags(Qt.FramelessWindowHint)
        self.setAttribute(Qt.WA_TranslucentBackground, True)
        self.setMouseTracking(True)
        self.hotkey = hotkey
        self.tools = [None] * self.NUM_ZONES

        self.center_point = QPoint(int(0.5 * self.width()), int(0.5 * self.height()))
        self.outer_circle_rect = QRect(self.center_point.x() - self.OUTER_CIRCLE_RADIUS,
                                              self.center_point.y() - self.OUTER_CIRCLE_RADIUS,
                                              self.OUTER_CIRCLE_DIAMETER, self.OUTER_CIRCLE_DIAMETER)
        self.inner_circle_rect = QRect(self.center_point.x() - self.INNER_CIRCLE_RADIUS,
                                              self.center_point.y() - self.INNER_CIRCLE_RADIUS,
                                              self.INNER_CIRCLE_DIAMETER, self.INNER_CIRCLE_DIAMETER)

        self.selected_section = -1
        self.active_zone = -1
        self.highlight_bounds = QRect(int(self.center_point.x() - 0.5 * self.height()), 0, int(self.height()),
                                             int(self.height()))
        self.highlight_start_angle = 0

        self.hotkey_down = False
        self.mouse_moved = False

    def set_tool(self, index, label, func):
        if index < 0 or index >= self.NUM_ZONES:
            return
        self.tools[index] = (label, func)

    def distance_from_center(self, x, y):
        return math.hypot(x - self.center_point.x(), y - self.center_point.y())

    def point_from_center(self, zone, distance):
        degrees = zone * self.ZONE_SPAN_ANGLE
        radians = math.radians(degrees)

        x1 = self.center_point.x() + (distance * math.cos(radians))
        y1 = self.center_point.y() + (distance * math.sin(radians))

        return x1, y1

    def label_point(self, zone):
        distance = self.OUTER_CIRCLE_RADIUS + self.TEXT_RADIUS
        x, y = self.point_from_center(zone, distance)

        if zone in [0, 1, 7]:
            x += 2
        elif zone in [2, 6]:
            x -= 0.5 * self.TEXT_WIDTH
        elif zone in [3, 4, 5]:
            x -= 2 + self.TEXT_WIDTH

        if zone in [0, 4]:
            y -= 0.5 * self.TEXT_HEIGHT
        elif zone in [1, 3]:
            y -= 0.3 * self.TEXT_HEIGHT
        elif zone in [5, 7]:
            y -= 0.7 * self.TEXT_HEIGHT
        elif zone == 2:
            y += 2
        elif zone == 6:
            y -= 2 + self.TEXT_HEIGHT

        return x, y

    def update_active_zone(self, pos):
        if self.distance_from_center(pos.x(), pos.y()) <= self.INNER_CIRCLE_RADIUS:
            self.clear_active_zone()
            return

        point = pos - self.center_point
        degrees = math.degrees(math.atan2(point.y(), point.x())) % 360

        start_angle_offset = -0.5 * self.ZONE_SPAN_ANGLE
        degrees = (degrees + start_angle_offset) % 360

        temp_section = math.floor(degrees / self.ZONE_SPAN_ANGLE)

        if self.selected_section != temp_section:
            self.selected_section = temp_section

            self.highlight_start_angle = (self.selected_section * self.ZONE_SPAN_ANGLE) - start_angle_offset
            self.active_zone = (self.selected_section + 1) % 8

            self.update()

    def clear_active_zone(self):
        self.active_zone = -1
        self.selected_section = -1

        self.update()

    def activate_selection(self):
        if self.active_zone >= 0 and self.tools[self.active_zone]:
            self.tools[self.active_zone][1]()
        self.hide()

    def show(self):
        pop_up_pos = QCursor.pos() - self.center_point
        self.move(pop_up_pos)

        super(RadialMenu, self).show()

    def showEvent(self, show_event):
        self.setFocus(Qt.PopupFocusReason)

        self.mouse_moved = False
        self.hotkey_down = True

    def keyPressEvent(self, key_event):
        key = key_event.key()

        if key == self.hotkey and not key_event.isAutoRepeat():
            self.hide()
        elif key == Qt.Key_Escape:
            self.hide()

    def keyReleaseEvent(self, key_event):
        if key_event.key() == self.hotkey and not key_event.isAutoRepeat():
            self.hotkey_down = False

            if self.mouse_moved:
                self.activate_selection()

    def mousePressEvent(self, mouse_event):
        if self.mouse_moved or not self.hotkey_down:
            self.activate_selection()

    def mouseReleaseEvent(self, mouse_event):
        if self.mouse_moved:
            self.activate_selection()

    def mouseMoveEvent(self, mouse_event):
        print('check')
        self.update_active_zone(mouse_event.pos())

        if (mouse_event.pos() - self.center_point).manhattanLength() > self.INNER_CIRCLE_RADIUS:
            self.mouse_moved = True

    def leaveEvent(self, leave_event):
        self.clear_active_zone()

    def paintEvent(self, paint_event):
        painter = QPainter(self)
        painter.setRenderHints(QPainter.Antialiasing)

        # Clickable area
        painter.setPen(Qt.transparent)
        painter.setBrush(QColor(0, 0, 0, 1))
        painter.drawRect(self.rect())

        # Outer Circle
        radial_grad = QRadialGradient(self.center_point, 50)
        radial_grad.setColorAt(.1, QColor(0, 0, 0, 255))
        radial_grad.setColorAt(1, QColor(0, 0, 0, 1))
        painter.setBrush(radial_grad)

        circle_pen = QPen(Qt.cyan, 2)
        painter.setPen(circle_pen)
        painter.drawEllipse(self.outer_circle_rect)

        # Zone Highlight
        if self.active_zone >= 0:
            radial_grad2 = QRadialGradient(self.center_point, 80)
            radial_grad2.setColorAt(0, QColor(255, 255, 255, 255))
            radial_grad2.setColorAt(0.9, QColor(0, 0, 0, 1))
            painter.setBrush(radial_grad2)

            painter.setPen(Qt.transparent)

            painter.drawPie(self.highlight_bounds, int(-self.highlight_start_angle * 16),
                            int(-self.ZONE_SPAN_ANGLE * 16))

        # Inner Circle
        painter.setBrush(Qt.black)
        painter.setPen(circle_pen)
        painter.drawEllipse(self.inner_circle_rect)

        for i in range(self.NUM_ZONES):
            if self.tools[i]:
                if i == self.active_zone:
                    painter.setBrush(QColor(255, 255, 0, 127))
                else:
                    painter.setBrush(QColor(0, 0, 0, 127))

                label_x, label_y = self.label_point(i)

                painter.setPen(Qt.transparent)
                painter.drawRoundedRect(int(label_x), int(label_y), int(self.TEXT_WIDTH), int(self.TEXT_HEIGHT),
                                        int(self.TEXT_RADIUS), int(self.TEXT_RADIUS))

                painter.setPen(Qt.white)
                painter.drawText(int(label_x), int(label_y), int(self.TEXT_WIDTH), int(self.TEXT_HEIGHT),
                                 Qt.AlignCenter, self.tools[i][0])

    def focusOutEvent(self, focus_event):
        self.hide()


class TestWindow(QMainWindow):
    def __init__(self, parent=None):
        super(TestWindow, self).__init__(parent=None)
        self.setWindowTitle("Radial Menu Example")
        self.setFixedSize(960, 540)
        self.setStyleSheet("background-color: #2c2f33;")
        self.radial_menus = []

        radial_menu = RadialMenu(Qt.RightButton)
        for i in range(0, RadialMenu.NUM_ZONES):
            radial_menu.set_tool(i, "Tool {0}".format(i), lambda index=i: self.activate_tool(index))
        self.radial_menus.append(radial_menu)

    def mousePressEvent(self, event):
        if event.type() == QEvent.MouseButtonPress:
            if event.button() == Qt.RightButton:
                for radial_menu in self.radial_menus:
                    if event.button() == radial_menu.hotkey:
                        radial_menu.show()

    def mouseReleaseEvent(self, event):
        if event.type() == QEvent.MouseButtonRelease:
            if event.button() == Qt.RightButton:
                for radial_menu in self.radial_menus:
                    if event.button() == radial_menu.hotkey:
                        pass
                        #radial_menu.close()

    def activate_tool(self, tool_index):
        print("Activate tool for index: {0}".format(tool_index))


if __name__ == "__main__":
    app = QApplication(sys.argv)
    window = TestWindow()
    window.show()
    app.exec_()

Solution

  • A widget can only get mouse move events only if it becomes the "mouse grabber", which can only happen as long as it received the mouse event itself or grabMouse() was called on it.

    Since synthesizing a further mouse press and posting it to the event queue does not guarantee that the widget will receive it after it has been actually shown, one solution could be to call grabMouse() in the show() override (or the showEvent() one).

    Note that you must also call releaseMouse() as soon as the widget is hidden.

        def show(self):
            ...
            self.grabMouse()
    
        def hideEvent(self, event):
            self.releaseMouse()
    

    A more appropriate approach, though, may be to use the Qt.Popup window flag, which indirectly grabs the mouse on the window, similarly to what happens to the QComboBox popup. Note that it might be required to explicitly call self.update() within show().

    class RadialMenu(QWidget):
        ...
        def __init__(self, hotkey, parent=None):
            ...
            self.setWindowFlags(Qt.FramelessWindowHint | Qt.Popup)
    
        def show(self):
            ...
            self.update()