Search code examples
pythonpyqt5qgraphicsitemqgraphicsrectitem

PyQt5: Resizable `QGraphicsRectItem`: How to properly update its position (in scene coordinate)


I create a resizable QGraphicsRectItem, I am able to resize it just fine, but I am not able to update the position of the new resized item in its scene

import typing
import sys
from PyQt5.QtGui import QPen, QBrush, QColor, QResizeEvent
from PyQt5.QtCore import QRectF, QSize
from PyQt5.QtWidgets import QApplication, QGraphicsView, QGraphicsScene, QGraphicsItem, QGraphicsRectItem, QMainWindow, QVBoxLayout, QWidget

class ResizableRect(QGraphicsRectItem):
    def __init__(self, *args):
        super().__init__(*args)
        self.setFlag(QGraphicsItem.ItemIsMovable, True)
        self.setPen(QPen(QBrush(QColor('blue')), 5))
        self.selected_edge = None
        self.click_pos = self.click_rect = None

    def mousePressEvent(self, event):
        """ The mouse is pressed, start tracking movement. """
        self.click_pos = event.pos()
        self.newY = self.pos().y()
        rect = self.rect()
        if abs(rect.left() - self.click_pos.x()) < 5:
            self.selected_edge = 'left'
        elif abs(rect.right() - self.click_pos.x()) < 5:
            self.selected_edge = 'right'
        elif abs(rect.top() - self.click_pos.y()) < 5:
            self.selected_edge = 'top'
        elif abs(rect.bottom() - self.click_pos.y()) < 5:
            self.selected_edge = 'bottom'
        else:
            self.selected_edge = None
        self.click_pos = event.pos()
        self.click_rect = rect
        super().mousePressEvent(event)

    def mouseMoveEvent(self, event):
        """ Continue tracking movement while the mouse is pressed. """
        # Calculate how much the mouse has moved since the click.
        pos = event.pos()
        x_diff = pos.x() - self.click_pos.x()
        y_diff = pos.y() - self.click_pos.y()

        # Start with the rectangle as it was when clicked.
        rect = QRectF(self.click_rect)

        # Then adjust by the distance the mouse moved.
        if self.selected_edge is None:
            rect.translate(x_diff, y_diff)
        elif self.selected_edge == 'top':
            rect.adjust(0, y_diff, 0, 0)
            # Test when resize rectangle upward; not working properly for now
            if y_diff < 0:              
                    newCenter = (rect.bottom() - pos.y()) / 2
                    self.newY = self.pos().y() - newCenter

        elif self.selected_edge == 'left':
            rect.adjust(x_diff, 0, 0, 0)
        elif self.selected_edge == 'bottom':
            rect.adjust(0, 0, 0, y_diff)
        elif self.selected_edge == 'right':
            rect.adjust(0, 0, x_diff, 0)


        # Also check if the rectangle has been dragged inside out.
        if rect.width() < 5:
            if self.selected_edge == 'left':
                rect.setLeft(rect.right() - 5)
            else:
                rect.setRight(rect.left() + 5)
        if rect.height() < 5:
            if self.selected_edge == 'top':
                rect.setTop(rect.bottom() - 5)
            else:
                rect.setBottom(rect.top() + 5)

        # Finally, update the rect that is now guaranteed to stay in bounds.
        self.setY(self.newY)
        self.setRect(rect)

        
    def mouseReleaseEvent(self, event): # for printing only i.e., after resizing
        print(f"item.pos(): {self.pos()}")


class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        central = QWidget(self)
        self.setCentralWidget(central)

        self.rect = ResizableRect(-100, -50, 200, 100)
        scene = QGraphicsScene(0, 0, 300, 300)
        scene.addItem(self.rect)
        self.view = QGraphicsView(central)
        self.view.setScene(scene)

        layout = QVBoxLayout(central)
        layout.addWidget(self.view)


def main():
    app = QApplication(sys.argv)
    window = MainWindow()
    window.show()

    app.exec_()


main()

For now, I am testing updating item.pos() when resize upward only, It is not working properly and I need advice to correct this implementation. In mouseMoveEvent(), when self.selected_edge == top, I calculate the center of the new rectangle. Then I compute the newY value that I will use to update the item's position in scene later on, i.e., self.setY(self.newY). The result is that the item keeps moving upward as I resize. Where did I do wrong?

Thank you for helping!


Solution

  • An important aspect that is often misunderstood is the coordinate system used for graphics items.

    While the documentation addresses three "major" coordinate systems, it's important to understand that:

    • items that are children of an item have their pos() relative to their parent;
    • any item coordinate (local) is relative to the item position;

    The documentation addresses this in the primitive functions (like addRect()):

    the item's geometry is provided in item coordinates, and its position is initialized to (0, 0)

    This is extremely important to understand, especially when dealing with mouse events.

    Let's suppose you make a QGraphicsRectItem subclass and create two instances of them:

    • itemA: created with a simple myRectItem(100, 50, 300, 200);
    • itemB: created with myRectItem(0, 0, 300, 200) and then moved with itemB.setPos(100, 50);

    If you implement the mousePressEvent() in that class and print the event.pos(), you will see two very different results. Suppose you click in the center of those items:

    • itemA will show QPointF(250, 150);
    • itemB will show QPointF(150, 100);

    This is because the position is in item coordinates: while the rectangle of itemB is always starting from (0, 0) (the origin point of the item), the rectangle of itemB is actually "translated" from the item position, so you get the point relative to the rectangle, added by the position of that rectangle.

    If you want to allow resizing on all edges of an item, you must consider these aspects, and also decide if the resizing should actually change the item position or the geometry of its rectangle.

    The most common and suggested way is to use the first approach, as it's usually more consistent and immediate.
    The only difference is about choosing the origin point of the item, which just depends on your needs: generally you just have contents that start from the item origin point and go bottom-right (similarly to how windows behave), but in some cases the contents should be "around" the center of the item position (common for "control points").

    Finally, resizing of a rectangle should normally consider its corners. A good approach doesn't use strings to identify the "side", but integer values, or, even better, bitwise values. Qt provides some basic enums that allow OR combinations, and we can use the Qt.Edges flag for our needs.

    In this way not only we can provide resizing from corners, but also set appropriate cursors for the item that add visual hints about the resizing feature.

    In the following code I've implemented all of the above and further more, considering:

    • the existing implementation for ItemIsMovable (which you ignored by completely overriding it in the mouseMoveEvent());
    • pen size (border detection must be based on the pen width);
    • variable content positioning (set at (0, 0) or around the center);
    • mouse cursor changes whenever it moves to an edge or corner of the item;
    class ResizableRect(QGraphicsRectItem):
        selected_edge = None
        def __init__(self, x, y, width, height, onCenter=False):
            if onCenter:
                super().__init__(-width / 2, -height / 2, width, height)
            else:
                super().__init__(0, 0, width, height)
            self.setPos(x, y)
            self.setFlags(QGraphicsItem.ItemIsMovable)
            self.setAcceptHoverEvents(True)
            self.setPen(QPen(QBrush(Qt.blue), 5))
    
            # a child item that shows the current position; note that this is only
            # provided for explanation purposes, a *proper* implementation should
            # use the ItemSendsGeometryChanges flag for *this* item and then
            # update the value within an itemChange() override that checks for
            # ItemPositionHasChanged changes.
            self.posItem = QGraphicsSimpleTextItem(
                '{}, {}'.format(self.x(), self.y()), parent=self)
            self.posItem.setPos(
                self.boundingRect().x(), 
                self.boundingRect().y() - self.posItem.boundingRect().height()
            )
    
        def getEdges(self, pos):
            # return a proper Qt.Edges flag that reflects the possible edge(s) at
            # the given position; note that this only works properly as long as the
            # shape() override is consistent and for *pure* rectangle items; if you
            # are using other shapes (like QGraphicsEllipseItem) or items that have
            # a different boundingRect or different implementation of shape(), the
            # result might be unexpected.
            # Finally, a simple edges = 0 could suffice, but considering the new
            # support for Enums in PyQt6, it's usually better to use the empty flag
            # as default value.
    
            edges = Qt.Edges()
            rect = self.rect()
            border = self.pen().width() / 2
    
            if pos.x() < rect.x() + border:
                edges |= Qt.LeftEdge
            elif pos.x() > rect.right() - border:
                edges |= Qt.RightEdge
            if pos.y() < rect.y() + border:
                edges |= Qt.TopEdge
            elif pos.y() > rect.bottom() - border:
                edges |= Qt.BottomEdge
    
            return edges
    
        def mousePressEvent(self, event):
            if event.button() == Qt.LeftButton:
                self.selected_edge = self.getEdges(event.pos())
                self.offset = QPointF()
            else:
                self.selected_edge = Qt.Edges()
            super().mousePressEvent(event)
    
        def mouseMoveEvent(self, event):
            if self.selected_edge:
                mouse_delta = event.pos() - event.buttonDownPos(Qt.LeftButton)
                rect = self.rect()
                pos_delta = QPointF()
                border = self.pen().width()
    
                if self.selected_edge & Qt.LeftEdge:
                    # ensure that the width is *always* positive, otherwise limit
                    # both the delta position and width, based on the border size
                    diff = min(mouse_delta.x() - self.offset.x(), rect.width() - border)
                    if rect.x() < 0:
                        offset = diff / 2
                        self.offset.setX(self.offset.x() + offset)
                        pos_delta.setX(offset)
                        rect.adjust(offset, 0, -offset, 0)
                    else:
                        pos_delta.setX(diff)
                        rect.setWidth(rect.width() - diff)
                elif self.selected_edge & Qt.RightEdge:
                    if rect.x() < 0:
                        diff = max(mouse_delta.x() - self.offset.x(), border - rect.width())
                        offset = diff / 2
                        self.offset.setX(self.offset.x() + offset)
                        pos_delta.setX(offset)
                        rect.adjust(-offset, 0, offset, 0)
                    else:
                        rect.setWidth(max(border, event.pos().x() - rect.x()))
    
                if self.selected_edge & Qt.TopEdge:
                    # similarly to what done for LeftEdge, but for the height
                    diff = min(mouse_delta.y() - self.offset.y(), rect.height() - border)
                    if rect.y() < 0:
                        offset = diff / 2
                        self.offset.setY(self.offset.y() + offset)
                        pos_delta.setY(offset)
                        rect.adjust(0, offset, 0, -offset)
                    else:
                        pos_delta.setY(diff)
                        rect.setHeight(rect.height() - diff)
                elif self.selected_edge & Qt.BottomEdge:
                    if rect.y() < 0:
                        diff = max(mouse_delta.y() - self.offset.y(), border - rect.height())
                        offset = diff / 2
                        self.offset.setY(self.offset.y() + offset)
                        pos_delta.setY(offset)
                        rect.adjust(0, -offset, 0, offset)
                    else:
                        rect.setHeight(max(border, event.pos().y() - rect.y()))
    
                if rect != self.rect():
                    self.setRect(rect)
                    if pos_delta:
                        self.setPos(self.pos() + pos_delta)
            else:
                # use the default implementation for ItemIsMovable
                super().mouseMoveEvent(event)
    
            self.posItem.setText('{},{} ({})'.format(
                self.x(), self.y(), self.rect().getRect()))
            self.posItem.setPos(
                self.boundingRect().x(), 
                self.boundingRect().y() - self.posItem.boundingRect().height()
            )
    
        def mouseReleaseEvent(self, event):
            self.selected_edge = Qt.Edges()
            super().mouseReleaseEvent(event)
    
        def hoverMoveEvent(self, event):
            edges = self.getEdges(event.pos())
            if not edges:
                self.unsetCursor()
            elif edges in (Qt.TopEdge | Qt.LeftEdge, Qt.BottomEdge | Qt.RightEdge):
                self.setCursor(Qt.SizeFDiagCursor)
            elif edges in (Qt.BottomEdge | Qt.LeftEdge, Qt.TopEdge | Qt.RightEdge):
                self.setCursor(Qt.SizeBDiagCursor)
            elif edges in (Qt.LeftEdge, Qt.RightEdge):
                self.setCursor(Qt.SizeHorCursor)
            else:
                self.setCursor(Qt.SizeVerCursor)
    
    
    class MainWindow(QMainWindow):
        def __init__(self):
            super().__init__()
    
            scene = QGraphicsScene(0, 0, 300, 300)
            self.view = QGraphicsView(scene)
    
            self.rect = ResizableRect(0, 50, 200, 100, True)
            scene.addItem(self.rect)
    
            central = QWidget()
    
            layout = QVBoxLayout(central)
            layout.addWidget(self.view)
    
            self.setCentralWidget(central)