Search code examples
pythonpyqtpyqt5pyqtgrapheventfilter

Capturing hover events with sceneEventFilter in pyqt


I have user-adjustable annotations in a graphics scene. The size/rotation of annotations is handled by dragging corners of a rectangle about the annotation. I'm using a custom rect (instead of the boundingRect) so it follows the rotation of the parent annotation. The control corners are marked by two ellipses whose parent is the rect so transformations of rect/ellipse/annotation are seamless.

I want to detect when the cursor is over one of the corners, which corner it is, and the exact coordinates. For this task it seems that I should filter the hoverevents with the parent rect using a sceneEventFilter.

I've tried umpty zilch ways of implementing the sceneEventFilter to no avail. All events go directly to the hoverEnterEvent function. I've only found a few bits of example code that do something like this but I'm just plain stuck. btw, I'm totally self taught on Python and QT over the past 3 months, so please bear with me. I'm sure I'm missing something very basic. The code is a simplified gui with two ellipses. We're looking to capture events in the sceneEventFilter but always goes to hoverEnterEvent.

from pyqtgraph.Qt import QtGui, QtCore
import pyqtgraph as pg
from PyQt5.QtWidgets import QGraphicsScene, QGraphicsView, QGraphicsItem
import sys

class myHandle(QtGui.QGraphicsEllipseItem):
    def __init__(self, parent = None):
        super(myHandle, self).__init__(parent)

    def addTheHandle(self, h_parent = 'null', kind = 'null'):
        handle_w = 40
        if kind == 'scaling handle':
            handle_x = h_parent.boundingRect().topRight().x() - handle_w/2
            handle_y = h_parent.boundingRect().topRight().y() - handle_w/2
        if kind == 'rotation handle':
            handle_x = h_parent.boundingRect().topLeft().x() - handle_w/2
            handle_y = h_parent.boundingRect().topLeft().y() - handle_w/2
        the_handle = QtGui.QGraphicsEllipseItem(QtCore.QRectF(handle_x, handle_y, handle_w, handle_w))
        the_handle.setPen(QtGui.QPen(QtGui.QColor(255, 100, 0), 3))
        the_handle.setParentItem(h_parent)
        the_handle.setAcceptHoverEvents(True)
        the_handle.kind = kind

        return the_handle

class myRect(QtGui.QGraphicsRectItem):
    def __init__(self, parent = None):
        super(myRect, self).__init__(parent)

    def rectThing(self, boundingrectangle):
        self.setAcceptHoverEvents(True)
        self.setRect(boundingrectangle)
        mh = myHandle()
        rotation_handle = mh.addTheHandle(h_parent = self, kind = 'rotation handle')
        scaling_handle  = mh.addTheHandle(h_parent = self, kind = 'scaling handle')        
        self.installSceneEventFilter(rotation_handle)
        self.installSceneEventFilter(scaling_handle)

        return self, rotation_handle, scaling_handle

    def sceneEventFilter(self, event):    
        print('scene ev filter')
        return False

    def hoverEnterEvent(self, event):
        print('hover enter event')

class Basic(QtGui.QMainWindow):
    def __init__(self):
        super(Basic, self).__init__()
        self.initUI()

    def eventFilter(self, source, event):
        return QtGui.QMainWindow.eventFilter(self, source, event)

    def exit_the_program(self):
        pg.exit()

    def initUI(self):
        self.resize(300, 300)

        self.centralwidget = QtGui.QWidget()
        self.setCentralWidget(self.centralwidget)        
        self.h_layout = QtGui.QHBoxLayout(self.centralwidget)

        self.exit_program = QtGui.QPushButton('Exit')
        self.exit_program.clicked.connect(self.exit_the_program)        
        self.h_layout.addWidget(self.exit_program)

        self.this_scene = QGraphicsScene()
        self.this_view = QGraphicsView(self.this_scene)
        self.this_view.setMouseTracking(True)
        self.this_view.viewport().installEventFilter(self)

        self.h_layout.addWidget(self.this_view)        
        self.circle = self.this_scene.addEllipse(QtCore.QRectF(40, 40, 65, 65), QtGui.QPen(QtCore.Qt.black))

        mr = myRect()
        the_rect, rotation_handle, scaling_handle = mr.rectThing(self.circle.boundingRect())
        the_rect.setPen(QtGui.QPen(QtCore.Qt.black))
        the_rect.setParentItem(self.circle)

        self.this_scene.addItem(the_rect)
        self.this_scene.addItem(rotation_handle)
        self.this_scene.addItem(scaling_handle)        

def main():
    app = QtGui.QApplication([])
    main = Basic()
    main.show()
    sys.exit(app.exec_())

if __name__ == '__main__':
    main()

Solution

  • The main problem is that you are installing the event filter of the target items on the rectangle: the event filter of the rectangle will never receive anything. Moreover, sceneEventFilter accepts two arguments (the watched item and the event), but you only used one.

    What you should do is to install the event filter of the rectangle on the target items:

        rotation_handle.installSceneEventFilter(self)
        scaling_handle.installSceneEventFilter(self)
    

    That said, if you want to use those ellipse items for scaling or rotation of the source circle, your approach is a bit wrong to begin with.

    from math import sqrt
    # ...
    
    class myRect(QtGui.QGraphicsRectItem):
        def __init__(self, parent):
            super(myRect, self).__init__(parent)
            self.setRect(parent.boundingRect())
            # rotation is usually based on the center of an object
            self.parentItem().setTransformOriginPoint(self.parentItem().rect().center())
    
            # a rectangle that has a center at (0, 0)
            handleRect = QtCore.QRectF(-20, -20, 40, 40)
    
            self.rotation_handle = QtGui.QGraphicsEllipseItem(handleRect, self)
            self.scaling_handle = QtGui.QGraphicsEllipseItem(handleRect, self)
            # position the handles by centering them at the right corners
            self.rotation_handle.setPos(self.rect().topLeft())
            self.scaling_handle.setPos(self.rect().topRight())
            for source in (self.rotation_handle, self.scaling_handle):
                # install the *self* event filter on the handles
                source.installSceneEventFilter(self)
                source.setPen(QtGui.QPen(QtGui.QColor(255, 100, 0), 3))
    
        def sceneEventFilter(self, source, event):
            if event.type() == QtCore.QEvent.GraphicsSceneMouseMove:
                # map the handle event position to the ellipse parent item; we could
                # also map to "self", but using the parent is more consistent
                localPos = self.parentItem().mapFromItem(source, event.pos())
                if source == self.rotation_handle:
                    # create a temporary line to get the rotation angle
                    line = QtCore.QLineF(self.boundingRect().center(), localPos)
                    # add the current rotation to the angle between the center and the
                    # top left corner, then subtract the new line angle
                    self.parentItem().setRotation(135 + self.parentItem().rotation() - line.angle())
                    # note that I'm assuming that the ellipse is a circle, so the top
                    # left angle will always be at 135°; if it's not a circle, the
                    # rect width and height won't match and the angle will be
                    # different, so you'll need to compute that
    
                    # parentRect = self.parentItem().rect()
                    # oldLine = QtCore.QLineF(parentRect.center(), parentRect.topLeft())
                    # self.parentItem().setRotation(
                    #     oldLine.angle() + self.parentItem().rotation() - line.angle())
    
                elif source == self.scaling_handle:
                    # still assuming a perfect circle, so the rectangle is a square;
                    # the line from the center to the top right corner is used to
                    # compute the square side size, which is the double of a
                    # right-triangle cathetus where the hypotenuse is the line
                    # between the center and any of its corners;
                    # if the ellipse is not a perfect circle, you'll have to
                    # compute both of the catheti
                    hyp = QtCore.QLineF(self.boundingRect().center(), localPos)
                    size = sqrt(2) * hyp.length()
                    rect = QtCore.QRectF(0, 0, size, size)
                    rect.moveCenter(self.rect().center())
                    self.parentItem().setRect(rect)
                    self.setRect(rect)
                    # update the positions of both handles
                    self.rotation_handle.setPos(self.rect().topLeft())
                    self.scaling_handle.setPos(self.rect().topRight())
                    return True
            elif event.type() == QtCore.QEvent.GraphicsSceneMousePress:
                # return True to the press event (which is almost as setting it as
                # accepted, so that it won't be processed further more by the scene,
                # allowing the sceneEventFilter to capture the following mouseMove
                # events that the watched graphics items will receive
                return True
            return super(myRect, self).sceneEventFilter(source, event)
    
    class Basic(QtGui.QMainWindow):
        # ...
        def initUI(self):
            # ...
            self.circle = self.this_scene.addEllipse(QtCore.QRectF(40, 40, 65, 65), QtGui.QPen(QtCore.Qt.black))
    
            mr = myRect(self.circle)
            self.this_scene.addItem(mr)