Maxe
Maxe

Reputation: 1

PyQt6 - handle events on QGraphicsItem

I'm fairly new to PyQt6, and I'm interested in event handling in QGraphicsItems.

I have QGraphicsItem(s), drawn on a QGraphicsScene and a view with a QGraphicsView. I'm mainly drawing two types of object on my scene:

I've discovered hoverEvent, which is very useful in my case, but when I draw my scene, not all the polygons are visible. However, they should all react to the mouse.

My desired behavior is :

The problem is that events are not distributed to invisible or disable items. How can I force hoverEvent propagation? Similarly, I can't handle mouseEvent for Release and Move (even when polygons are visible).

I'd also be very happy if an expert could explain how QEvent are propagated in general, as I haven't found any very detailed (or well explained) documentation. Reviews and comments on implementation choices are also welcome.

Here's my example code (change default_visibility to test it)

from PyQt6.QtCore import Qt, QRectF, QSize, QPointF
from PyQt6.QtGui import QImage, QPixmap, QColor, QPen, QBrush, QPainter, QPainterPath, QPolygonF
from PyQt6.QtWidgets import QGraphicsView, QGraphicsScene, QApplication, QGraphicsItem, QGraphicsPixmapItem


class QGraphicsCustomPolygonItem(QGraphicsItem):
    def __init__(self, polygon, default_visibility):
        super().__init__()

        self.default_visibility = default_visibility
        self.polygon = polygon

        self.main_color = QColor(255, 0, 0)

        self.pen_color = self.main_color
        self.pen_color.setAlpha(255)
        self.pen = QPen(self.pen_color)
        self.pen.setWidthF(0.5)

        self.brush_color = self.main_color
        self.brush_color.setAlpha(32)
        self.brush = QBrush(self.brush_color)

        self._highlight = False

        self.setVisible(default_visibility)
        self.setAcceptHoverEvents(True)

    @property
    def highlight(self) -> bool:
        return self._highlight

    @highlight.setter
    def highlight(self, value: bool) -> None:
        assert isinstance(value, bool)
        self._highlight = value
        self.brush_color = self.main_color
        if value is True:
            self.brush_color.setAlpha(64)
            self.setVisible(True)
        else:
            self.brush_color.setAlpha(32)
            self.setVisible(self.default_visibility)
        self.brush = QBrush(self.brush_color)
        self.update(self.boundingRect())

    def boundingRect(self) -> QRectF:
        return self.polygon.boundingRect()

    def paint(self, painter: QPainter, option, widget=None):
        painter.setRenderHint(QPainter.RenderHint.Antialiasing)
        painter.setPen(self.pen)
        painter.setBrush(self.brush)
        painter.drawPolygon(self.polygon, Qt.FillRule.OddEvenFill)

    def shape(self):
        path = QPainterPath()
        path.addPolygon(self.polygon)
        return path

    def mouseMoveEvent(self, event):
        print("mouse move")

    def mousePressEvent(self, event):
        print("mouse press")

    def mouseReleaseEvent(self, event):
        print("mouse release")

    def mouseDoubleClickEvent(self, event):
        print("mouse double click")

    def hoverMoveEvent(self, event):
        print("hover move")

    def hoverEnterEvent(self, event):
        print("hover enter")
        self.highlight = True

    def hoverLeaveEvent(self, event):
        print("hover leave")
        self.highlight = False


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

    scene = QGraphicsScene()

    # draw a background image
    background_image = QImage(QSize(300, 300), QImage.Format.Format_RGB888)
    for x in range(background_image.width()):
        for y in range(background_image.height()):
            if (x + y) % 2 == 0:
                background_image.setPixelColor(x, y, Qt.GlobalColor.white)
            else:
                background_image.setPixelColor(x, y, Qt.GlobalColor.black)
    background_item = QGraphicsPixmapItem(QPixmap.fromImage(background_image))
    scene.addItem(background_item)

    # draw a custom polygon
    poly = QPolygonF([QPointF(x, y) for x, y in [(20, 40), (200, 60), (120, 250), (50, 200)]])
    custom_polygon_item = QGraphicsCustomPolygonItem(poly, default_visibility=True)
    scene.addItem(custom_polygon_item)

    # init the viewport
    view = QGraphicsView(scene)

    view.setGeometry(0, 0, 600, 600)
    view.show()
    app.exec()

I tried to subclass the QGraphicsScene to manage event propagation manually. From what I understood, it's the scene's role to reformat events and distribute them to items, but I didn't succeed and anyway, I don't think it was the right way to do it.

Upvotes: 0

Views: 210

Answers (1)

musicamante
musicamante

Reputation: 48509

While, in theory, it could be possible to send a mouse event even to hidden items, it would be wrong to do so, other than difficult and probably either inefficient or unreliable (or both): you'd need to iterate through all items in the scene, including hidden ones, then determine if they are "normally" hidden or they should still react to mouse events; then, if the event is not accepted, you should send it to the next one in the scene item stack; and that's for any mouse movement, which is also extremely inefficient considering that it would be done in Python.
As you can see, it's not a good choice.

Just like it happens with events for hidden widgets, the same goes with hidden QGraphicsItems:

Invisible items are not painted, nor do they receive any events. In particular, mouse events pass right through invisible items, and are delivered to any item that may be behind. Invisible items are also unselectable, they cannot take input focus, and are not detected by QGraphicsScene's item location functions.

Since Qt visibility actually involves other aspects, and, in reality, you just want to make the item not visible but still available for collision detection (including mouse events), the solution is actually quite obvious: if you don't want to see it, don't paint it.

You're already overriding the paint() function, so you just need to actually paint it only if required. This can be achieved with a custom private flag, and possibly using a dedicated function that resembles setVisible():

class QGraphicsCustomPolygonItem(QGraphicsItem):
    _visible = True
    def __init__(self, polygon, default_visibility):
        ...
        # use this instead of setVisible()
        self.makeVisible(default_visibility)

    def makeVisible(self, visible):
        if self._visible != visible:
            self._visible = visible
            self.update()

    def paint(self, painter: QPainter, option, widget=None):
        if not self._visible:
            return
        ...

You could obviously do that by overwriting setVisible() (which would not be a real override, unlike the QWidget counterpart), but it's usually better to use separate functions for such purposes.

In any case, if you still want to use setVisible() for simplicity, remember that if you then want to use the default behavior (making it actually hidden or visible for Qt and its events), you should then call the default implementation instead (super().setVisible(...)):

class QGraphicsCustomPolygonItem(QGraphicsItem):
    _visible = True

    ...

    def setVisible(self, visible):
        if self._visible != visible:
            self._visible = visible
            super().update()

    def setVisibleForQt(self, visible):
        super().setVisible(visible)

About your mouse move/release note (which is based on your original code), the issue was caused by overriding mousePressEvent() and still calling the default implementation. Since the default behavior ignores mouse button presses, similarly to widgets, the item will not receive further move or release events. If you want to receive such further events, the press event must be accepted. If you still want to call the default implementation, ensure that you call event.accept() after doing so.

Note that it makes little point to subclass QGraphicsItem for QPolygonF, since Qt already provides QGraphicsPolygonItem. Your implementation is also inaccurate, since it doesn't properly consider the boundingRect() indications about the pen width, possibly causing painting artifacts ("ghosts") if the item geometry changes and even while scrolling, especially if the view uses scaling transforms.
Then, you could still implement the above, but more easily. Here is a more accurate and efficient version of your item (excluding mouse button event handlers):

class QGraphicsCustomPolygonItem(QGraphicsPolygonItem):
    _visible = True
    _highlight = False
    def __init__(self, polygon, default_visibility):
        super().__init__(polygon)
        self.setAcceptHoverEvents(True)

        self.default_visibility = default_visibility

        self.main_color = QColor(Qt.red)
        self.setPen(self.main_color)

        self.brush_color = QColor(self.main_color)
        self.brush_color.setAlpha(32)
        self.setBrush(self.brush_color)

        self.makeVisible(default_visibility)

    @property
    def highlight(self) -> bool:
        return self._highlight

    @highlight.setter
    def highlight(self, value: bool) -> None:
        assert isinstance(value, bool)
        if self._highlight == value:
            return

        self._highlight = value
        if value:
            self.brush_color.setAlpha(64)
        else:
            self.brush_color.setAlpha(32)

        self.setBrush(self.brush_color)
        self.makeVisible(value or self.default_visibility)

    def makeVisible(self, visible):
        if self._visible != visible:
            self._visible = visible
            self.update()

    def hoverEnterEvent(self, event):
        print("hover enter")
        self.highlight = True

    def hoverLeaveEvent(self, event):
        print("hover leave")
        self.highlight = False

    def paint(self, painter: QPainter, option, widget=None):
        if self._visible:
            painter.setRenderHint(QPainter.RenderHint.Antialiasing)
            super().paint(painter, option, widget)

Finally, if you want a "fake-gray" grid pattern made of white and black pixels, setting each pixel color is extremely inefficient (especially considering the Python bottleneck).
Qt already provides such basic patterns with Qt.BrushStyle, so you just need to use a QPainter on the pixmap along with fillRect():

    pixmap = QPixmap(300, 300)
    pixmap.fill(Qt.white)
    qp = QPainter(pixmap)
    qp.fillRect(pixmap.rect(), QBrush(Qt.BrushStyle.Dense4Pattern))
    qp.end()
    scene.addPixmap(pixmap)

Upvotes: 0

Related Questions