Search code examples
pythonpyqt5

How can I draw a QGraphicsRectItem that is (properly) movable by clicking on a QGraphicsView/QGraphicsScene canvas?


I want to draw rectangles on a QGrahicsView/QGrahicsScene canvas by subclassing QGraphicsRectItem. The rectangles should appear just by clicking on the canvas. After drawing the rectangles, the rectangles should be movable just by dragging them around.

  1. I subclassed QGraphicsRectItem to draw my custom rectangle and set it as movable via .setFlags(QGraphicsItem.ItemIsMovable).
  2. I subclassed QGraphicsView and implemented mousePressEvent() such that it instantiates my custom QGraphicsRectItem class with the coordinates of the clicked position.

I expect to be able to draw rectangles on the canvas just by clicking on it. Furthermore, I want to be able to move the rectangles around. I can do both, but only drawing the rectangles by clicking on the canvas works properly. Grabbing the rectangles to move them around is next to impossible because I have to grab the exact pixel (actually, it's the top-left intersection between the rectangle lines) to move the rectangles around.

I can grab a rectangle properly by clicking anywhere on its body if I do not spawn a rectangle by reimplementing mousePressEvent() but instead just instantiate it in the class's initiator. But then I lose the ability to spawn rectangles just by clicking on the canvas.

Is there a possibility to keep the functionality of spawning rectangles just by clicking on the canvas while also being able to easily move the rectangles around?

This is what I have got so far:

from PyQt5.QtCore import Qt
from PyQt5.QtGui import QBrush, QMouseEvent, QPen
from PyQt5.QtWidgets import (
    QApplication,
    QGraphicsItem,
    QGraphicsRectItem,
    QGraphicsScene,
    QGraphicsView,
)


class RectangleItem(QGraphicsRectItem):
    def __init__(self, x: float, y: float, width: float, height: float):
        super().__init__(x, y, width, height)

        self.setBrush(QBrush(Qt.red))
        self.setPen(QPen(Qt.black, 20))

        self.setFlags(QGraphicsItem.ItemIsMovable | QGraphicsItem.ItemIsSelectable)


class GraphicsView(QGraphicsView):
    def __init__(self, scene: QGraphicsScene):
        super().__init__(scene)

        # rectangle = RectangleItem(0, 0, 100, 100)
        # self.scene().addItem(rectangle)

    def mousePressEvent(self, event: QMouseEvent):
        scene_position = self.mapToScene(event.pos())
        rectangle = RectangleItem(scene_position.x(), scene_position.y(), 100, 100)
        self.scene().addItem(rectangle)


app = QApplication([])
scene = QGraphicsScene()
view = GraphicsView(scene)
view.show()
app.exec()

Solution

  • Thanks to the hint of @Homer521 and a bit of research, I found a solution:

    Instead of my above reimplementation of mousePressEvent, I used

    def mousePressEvent(self, event: QMouseEvent):
        item = self.itemAt(event.pos())
        if item:
            return super().mousePressEvent(event)
        scene_position = self.mapToScene(event.pos())
        rectangle = RectangleItem(scene_position.x(), scene_position.y(), 100, 100)
        self.scene().addItem(rectangle)
        return None
    

    Thsi first checks whether a QGraphicsItem (or a subclass of it) exists at the position you clicked at. If it does, it just returns to the original implementation of mousePressEvent which allows you to use all the mouse interaction related functionality that a QGraphicsItem comes with by default. If there is no item at that position, it just runs the code responsible for generating the function.

    Bonus: if you want to be able to use context menus on your QGraphicsItems, wrap the above definition of mousePressEvent in a if event.button() == Qt.LeftButton: so that it is only executed when you left click.