Reputation: 1
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 :
If the poly is visible, when the mouse enters, it highlights, when the mouse leaves, it reverts to normal.
If the poly is not visible, when the mouse enters, it highlights, when the mouse leaves, it reverts to invisible.
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
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