Home > Back-end >  Undo\Redo with multi items movement QGraphicScene - pyqt5
Undo\Redo with multi items movement QGraphicScene - pyqt5

Time:01-27

I have implemented QUndoStack with QGraphicScene and everything is working like charm with individual QGraphicItem movement/rotation, but when it comes to the multi-items movement, I couldn't find any way to do so, since I didn't find any flag/signal that will be emitted after all selected item position has been changed [ NOT DURING].

Here is a sketch code that will represent my goals.

import sys
from typing import Dict

from PyQt5.QtCore import Qt, QPoint
from PyQt5.QtGui import QPen, QColor, QBrush
from PyQt5.QtWidgets import QApplication, QGraphicsItem, QMainWindow, QGraphicsScene, QGraphicsView, \
    QGraphicsEllipseItem, QUndoStack, QUndoCommand
import random


class CustomUndoRedoStack(QUndoStack):
    instance = None

    def __init__(self):
        if not CustomUndoRedoStack.instance:
            CustomUndoRedoStack.instance = self
            super(CustomUndoRedoStack, self).__init__()

    @staticmethod
    def get_instance():
        return CustomUndoRedoStack.instance


class MovingMultiItemsCommand(QUndoCommand):

    def __init__(self, new_items_pos: Dict[QGraphicsItem], old_items_pos: Dict[QGraphicsItem]):
        super(MovingMultiItemsCommand, self).__init__()
        self.new_items_pos = new_items_pos
        self.old_items_pos = old_items_pos
        CustomUndoRedoStack.get_instance().push(self)

    def undo(self) -> None:
        for item in self.old_items_pos:
            item.setPos(self.old_items_pos[item])
        super(MovingMultiItemsCommand, self).undo()

    def redo(self) -> None:
        for item in self.new_items_pos:
            item.setPos(self.new_items_pos[item])
        super(MovingMultiItemsCommand, self).redo()


class CustomQGraphicsEllipseItem(QGraphicsEllipseItem):

    def __init__(self, x, y):
        super(CustomQGraphicsEllipseItem, self).__init__(x, y, 20, 20)
        self.setFlag(QGraphicsItem.ItemIsSelectable)
        self.setFlag(QGraphicsItem.ItemIsMovable)
        self.setPen(QPen(QColor(255, 128, 0), 0.5, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin))
        self.setBrush(QBrush(QColor(255, 128, 20, 128)))


class MyGraphicsView(QGraphicsView):
    def __init__(self):
        super(MyGraphicsView, self).__init__()
        self.setDragMode(QGraphicsView.RubberBandDrag)
        self._isPanning = False
        self._mousePressed = False
        self.setCacheMode(QGraphicsView.CacheBackground)
        self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
        self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)


class MyGraphicsScene(QGraphicsScene):
    def __init__(self):
        super(MyGraphicsScene, self).__init__()
        self.setBackgroundBrush(QBrush(QColor(50, 50, 50)))
        self.old_items_pos = {}
        self.new_items_pos = {}

    #  ------- I want something like the following -----------------------------

    def selected_items_before_change(self):
        for item in self.selectedItems():
            self.old_items_pos[item] = item.pos()

    def selected_items_after_change(self):
        for item in self.selectedItems():
            self.new_items_pos[item] = item.pos()

    def selected_item_position_changed(self):
        """
        This method must be triggered only and only once after selected items position has been changed (NOT WHILE)
        """
        MovingMultiItemsCommand(self.new_items_pos, self.old_items_pos)

    # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^


class MyMainWindow(QMainWindow):
    def __init__(self):
        super(MyMainWindow, self).__init__()
        self.setWindowTitle("Test")
        self.resize(800, 600)
        self.gv = MyGraphicsView()
        self.gv.setScene(MyGraphicsScene())
        self.setCentralWidget(self.gv)
        self.populate()

    def populate(self):
        scene = self.gv.scene()

        for i in range(500):
            x = random.randint(0, 1000)
            y = random.randint(0, 1000)
            rect = CustomQGraphicsEllipseItem(x, y)
            scene.addItem(rect)


def main():
    app = QApplication(sys.argv)
    ex = MyMainWindow()
    ex.show()
    sys.exit(app.exec_())


if __name__ == '__main__':
    main()

C answers will be good as well, but Python is preferable :)

CodePudding user response:

The mouse movement of selectable items is handled by the graphics scene, starting with its mousePressEvent() and ending with the corresponding mouseReleaseEvent().

This means that you can create a QUndoCommand based on the selected items (if any) after the default implementation of mouse button press/release is called. This will be valid for any movement, including when just one item has been moved.

class ItemMovedUndoCommand(QUndoCommand):
    def __init__(self, oldPositions, newPositions):
        if len(oldPositions) == 1:
            pos = oldPositions.values()[0]
            text = 'Item moved to {}x{}'.format(
                pos.x(), pos.y())
        else:
            text = '{} items moved'.format(len(oldPositions))
        super().__init__(text)
        self.oldPositions = oldPositions
        self.newPositions = newPositions

    def redo(self):
        for item, pos in self.newPositions.items():
            item.setPos(pos)

    def undo(self):
        for item, pos in self.oldPositions.items():
            item.setPos(pos)


class CustomQGraphicsEllipseItem(QGraphicsEllipseItem):
    def __init__(self, x, y):
        super(CustomQGraphicsEllipseItem, self).__init__(-10, -10, 20, 20)
        self.setPos(x, y)
        self.setFlag(QGraphicsItem.ItemIsSelectable)
        self.setFlag(QGraphicsItem.ItemIsMovable)
        self.setPen(QPen(QColor(255, 128, 0), 
            0.5, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin))
        self.setBrush(QBrush(QColor(255, 128, 20, 128)))


class MyGraphicsView(QGraphicsView):
    def __init__(self):
        super(MyGraphicsView, self).__init__()
        self.setDragMode(QGraphicsView.RubberBandDrag)
        self._isPanning = False
        self._mousePressed = False
        self.setCacheMode(QGraphicsView.CacheBackground)
        self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
        self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)


class MyGraphicsScene(QGraphicsScene):
    itemsMoved = pyqtSignal(object, object)
    def __init__(self):
        super(MyGraphicsScene, self).__init__()
        self.setBackgroundBrush(QBrush(QColor(50, 50, 50)))
        self.oldPositions = {}

    def mousePressEvent(self, event):
        super().mousePressEvent(event)
        if event.button() == Qt.LeftButton:
            self.oldPositions = {i:i.pos() for i in self.selectedItems()}

    def mouseReleaseEvent(self, event):
        super().mouseReleaseEvent(event)
        if event.button() == Qt.LeftButton and self.oldPositions:
            self.itemsMoved.emit(self.oldPositions, 
                {i:i.pos() for i in self.oldPositions.keys()})
        self.oldPositions = {}


class MyMainWindow(QMainWindow):
    def __init__(self):
        super(MyMainWindow, self).__init__()
        self.setWindowTitle("Test")
        self.resize(800, 600)
        self.gv = MyGraphicsView()
        self.scene = MyGraphicsScene()
        self.gv.setScene(self.scene)
        self.setCentralWidget(self.gv)

        toolBar = QToolBar()
        self.addToolBar(Qt.TopToolBarArea, toolBar)
        self.undoStack = QUndoStack()
        toolBar.addAction(self.undoStack.createUndoAction(self.scene))
        toolBar.addAction(self.undoStack.createRedoAction(self.scene))

        self.populate()

        self.scene.itemsMoved.connect(self.itemsMoved)

    def itemsMoved(self, oldPositions, newPositions):
        self.undoStack.push(ItemMovedUndoCommand(oldPositions, newPositions))

    def populate(self):
        scene = self.gv.scene()

        for i in range(500):
            x = random.randint(0, 1000)
            y = random.randint(0, 1000)
            rect = CustomQGraphicsEllipseItem(x, y)
            scene.addItem(rect)
  • Related