Search code examples
pythondrag-and-droppyqt5qtreeviewqstandarditemmodel

QStandardItem does not clone correctly when the items are moved


As indicated in the following code, when you drag-drop an item (subclassed from QStandardItem with a clone() method) you get a QStandardItem and not a subclass. Furthermore - data stored in the class or as part of the setData is lost. I suspect this is because of the inability to 'serialize' the data. But I am clueless how to 'save' the data - or the meta. How can I preserve the QObject? The following code works fine, but once you move a branch node, all the nodes in the branch and the branch become QStandardItem's and not myItem and lose the data (if they had any).

# -*- coding: utf-8 -*-
"""
Created on Mon Nov  4 09:10:16 2019

Test of Tree view with subclassed QStandardItem and Drag and Drop
enabled.  When you move a parent the parent looses the subclass and thus
the meta - however, it also looses the data:  This is likely because
the data cannot be serialized.  How to fix?

@author: tcarnaha
"""
import sys
from PyQt5 import QtGui, QtWidgets, QtCore


class myData():
    def __init__(self, title):
        self._title = title
        self._stuff = dict()
        self._obj = QtCore.QObject()

    @property
    def obj(self):
        return self._obj

    @obj.setter
    def obj(self, value):
        self._obj = value

    @property
    def title(self):
        return self._title

    @title.setter
    def title(self, value):
        self._title = value


class myItem(QtGui.QStandardItem):
    def __init__(self, parent=None):
        super(myItem, self).__init__(parent)
        self._meta = None

    @property
    def meta(self):
        return self._meta

    @meta.setter
    def meta(self, value):
        self._meta = value

    def clone(self):
        print "My cloning"
        old_data = self.data()
        print "Old data [{}]".format(old_data)
        old_meta = self.meta
        obj = myItem()
        obj.setData(old_data)
        print "New data [{}]".format(obj.data())
        obj.meta = old_meta
        print "Clone is a ", obj.__class__
        return obj

class mainWidget(QtWidgets.QMainWindow):
    def __init__(self):
        super(mainWidget, self).__init__()
        self.model = QtGui.QStandardItemModel()
        self.model.setItemPrototype(myItem())
        self.view = QtWidgets.QTreeView()
        self.view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
        self.view.customContextMenuRequested.connect(self.list_click)
        self.view.setDragDropMode(QtWidgets.QAbstractItemView.InternalMove)
        self.view.setDefaultDropAction(QtCore.Qt.MoveAction)
        self.view.setDragDropOverwriteMode(False)
        self.view.setAcceptDrops(True)
        self.view.setDropIndicatorShown(True)
        self.view.setDragEnabled(True)
        self.view.setModel(self.model)
        dataA = myData('A thing')
        parentA = myItem()
        parentA.setText('A')
        parentA.setDragEnabled(True)
        parentA.setDropEnabled(True)
        parentA.setData(dataA)
        parentA.meta = QtCore.QObject()
        childa = myItem()
        childa.setText('a')
        childb = myItem()
        childb.setText('b')
        childc = myItem()
        childc.setText('c')
        parentA.appendRows([childa, childb, childc])
        dataB = myData('B thing')
        parentB = myItem()
        parentB.setText('B')
        parentB.setDragEnabled(True)
        parentB.setDropEnabled(True)
        parentB.setData(dataB)
        parentB.meta = QtCore.QObject()
        childd = myItem()
        childd.setText('d')
        childe = myItem()
        childe.setText('e')
        childf = myItem()
        childf.setText('f')
        parentB.appendRows([childd, childe, childf])
        self.model.appendRow(parentA)
        self.model.appendRow(parentB)

        classAct = QtWidgets.QAction('Class', self)
        classAct.triggered.connect(self.classIs)
        dataAct = QtWidgets.QAction('Data', self)
        dataAct.triggered.connect(self.dataIs)
        metaAct = QtWidgets.QAction('Meta', self)
        metaAct.triggered.connect(self.metaIs)
        self.menu = QtWidgets.QMenu("Item info")
        self.menu.addAction(classAct)
        self.menu.addAction(dataAct)
        self.menu.addAction(metaAct)

        self.setCentralWidget(self.view)

    @QtCore.pyqtSlot(QtCore.QPoint)
    def list_click(self, position):
        self.menu.popup(self.view.viewport().mapToGlobal(position))

    def classIs(self):
        selected_indexes = self.view.selectedIndexes()
        for index in selected_indexes:
            item = self.model.itemFromIndex(index)
            print "Item {} Class {} ".format(item.text(), item.__class__())

    def dataIs(self):
        selected_indexes = self.view.selectedIndexes()
        for index in selected_indexes:
            item = self.model.itemFromIndex(index)
            try:
                print "Item {} data {} Object {}".format(item.text(),
                                                         item.data().title,
                                                         item.data().obj)
            except Exception as exc:
                print "Data exception ", exc

    def metaIs(self):
        selected_indexes = self.view.selectedIndexes()
        for index in selected_indexes:
            item = self.model.itemFromIndex(index)
            try:
                print "Item {} meta {} ".format(item.text(), item.meta)
            except Exception as exc:
                print "Meta exception ", exc


if __name__ == '__main__':
    app = QtWidgets.QApplication(sys.argv)
    main = mainWidget()
    main.show()
    app.exec_()

Solution

  • There are a couple of problems here related to how objects get serialized by Qt and also by PyQt. Firstly, when cloning a QStandardItem, only the flags and data get copied - everything else is ignored (including dynamic python attributes). Secondly, there is no way to directly copy a QObject. This is because it cannot be cast to a QVariant (which Qt uses for serialization) and it cannot be pickled (which PyQt uses for serialization).

    To solve the second problem, we need to keep separate references to all the QObject instances, and then use indirect keys to access them again later. There are probably many different way to achieve this, but here's a very simple approach that illustrates the basic idea:

    objects = {}
    
    class MyObject(QtCore.QObject):
        def __init__(self, parent=None):
            super(MyObject, self).__init__(parent)
            self.setProperty('key', max(objects.keys() or [0]) + 1)
            objects[self.property('key')] = self
    

    So this automatically adds each instance to a global cache and gives it a unique lookup key so that it can be easily found later on. With this in place, the myData class now needs to be adapted to use the MyObject class so that pickling is handled correctly:

    class myData():
        def __init__(self, title):
            self._title = title
            self._stuff = dict()
            self._obj = MyObject()
    
        def __setstate__(self, state):
            self._obj = objects.get(state['obj'])
            self._stuff = state['stuff']
            self._title = state['title']
    
        def __getstate__(self):
            return {
                'obj': self._obj and self._obj.property('key'),
                'title': self._title,
                'stuff': self._stuff,
                }
    

    Solving the first problem is much simpler: we just need to make sure any dynamic python properties store their underlying values in the item's data using custom data-roles. In this particular case, the value must be the key of the item's MyObject instance, so that it can be retrieved after a drag and drop operation:

    class myItem(QtGui.QStandardItem):
        MetaRole = QtCore.Qt.UserRole + 1000
    
        @property
        def meta(self):
            return objects.get(self.data(myItem.MetaRole))
    
        @meta.setter
        def meta(self, value):
            self.setData(value.property('key'), myItem.MetaRole)
    
        def clone(self):
            print "My cloning"
            obj = myItem(self)
            print "Clone is a ", obj.__class__
            return obj
    

    Below is a working version of your original script that implements all the above. But please bear in mind that you will almost certainly need to adapt this to work properly with your real code. This is just a working proof-of-concept that shows how to deal with the two issues outlined above.

    # -*- coding: utf-8 -*-
    import sys
    from PyQt5 import QtGui, QtWidgets, QtCore
    
    objects = {}
    
    class MyObject(QtCore.QObject):
        def __init__(self, parent=None):
            super(MyObject, self).__init__(parent)
            self.setProperty('key', max(objects.keys() or [0]) + 1)
            objects[self.property('key')] = self
    
    class myData():
        def __init__(self, title):
            self._title = title
            self._stuff = dict()
            self._obj = MyObject()
    
        def __setstate__(self, state):
            self._obj = objects.get(state['obj'])
            self._stuff = state['stuff']
            self._title = state['title']
    
        def __getstate__(self):
            return {
                'obj': self._obj.property('key'),
                'title': self._title,
                'stuff': self._stuff,
                }
    
        @property
        def obj(self):
            return self._obj
    
        @obj.setter
        def obj(self, value):
            self._obj = value
    
        @property
        def title(self):
            return self._title
    
        @title.setter
        def title(self, value):
            self._title = value
    
    class myItem(QtGui.QStandardItem):
        MetaRole = QtCore.Qt.UserRole + 1000
    
        @property
        def meta(self):
            return objects.get(self.data(myItem.MetaRole))
    
        @meta.setter
        def meta(self, value):
            self.setData(value.property('key'), myItem.MetaRole)
    
        def clone(self):
            print "My cloning"
            obj = myItem(self)
            print "Clone is a ", obj.__class__
            return obj
    
    class mainWidget(QtWidgets.QMainWindow):
        def __init__(self):
            super(mainWidget, self).__init__()
            self.model = QtGui.QStandardItemModel()
            self.model.setItemPrototype(myItem())
            self.view = QtWidgets.QTreeView()
            self.view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
            self.view.customContextMenuRequested.connect(self.list_click)
            self.view.setDragDropMode(QtWidgets.QAbstractItemView.InternalMove)
            self.view.setDefaultDropAction(QtCore.Qt.MoveAction)
            self.view.setDragDropOverwriteMode(False)
            self.view.setAcceptDrops(True)
            self.view.setDropIndicatorShown(True)
            self.view.setDragEnabled(True)
            self.view.setModel(self.model)
            dataA = myData('A thing')
            parentA = myItem()
            parentA.setText('A')
            parentA.setDragEnabled(True)
            parentA.setDropEnabled(True)
            parentA.setData(dataA)
            parentA.meta = MyObject()
            childa = myItem()
            childa.setText('a')
            childb = myItem()
            childb.setText('b')
            childc = myItem()
            childc.setText('c')
            parentA.appendRows([childa, childb, childc])
            dataB = myData('B thing')
            parentB = myItem()
            parentB.setText('B')
            parentB.setDragEnabled(True)
            parentB.setDropEnabled(True)
            parentB.setData(dataB)
            parentB.meta = MyObject()
            childd = myItem()
            childd.setText('d')
            childe = myItem()
            childe.setText('e')
            childf = myItem()
            childf.setText('f')
            parentB.appendRows([childd, childe, childf])
            self.model.appendRow(parentA)
            self.model.appendRow(parentB)
    
            classAct = QtWidgets.QAction('Class', self)
            classAct.triggered.connect(self.classIs)
            dataAct = QtWidgets.QAction('Data', self)
            dataAct.triggered.connect(self.dataIs)
            metaAct = QtWidgets.QAction('Meta', self)
            metaAct.triggered.connect(self.metaIs)
            self.menu = QtWidgets.QMenu("Item info")
            self.menu.addAction(classAct)
            self.menu.addAction(dataAct)
            self.menu.addAction(metaAct)
    
            self.setCentralWidget(self.view)
    
        @QtCore.pyqtSlot(QtCore.QPoint)
        def list_click(self, position):
            self.menu.popup(self.view.viewport().mapToGlobal(position))
    
        def classIs(self):
            selected_indexes = self.view.selectedIndexes()
            for index in selected_indexes:
                item = self.model.itemFromIndex(index)
                print "Item {} Class {} ".format(item.text(), item.__class__())
    
        def dataIs(self):
            selected_indexes = self.view.selectedIndexes()
            for index in selected_indexes:
                item = self.model.itemFromIndex(index)
                try:
                    print "Item {} data {} Object {}".format(item.text(),
                                                             item.data().title,
                                                             item.data().obj)
                except Exception as exc:
                    print "Data exception ", exc
    
        def metaIs(self):
            selected_indexes = self.view.selectedIndexes()
            for index in selected_indexes:
                item = self.model.itemFromIndex(index)
                try:
                    print "Item {} meta {} ".format(item.text(), item.meta)
                except Exception as exc:
                    print "Meta exception ", exc
    
    
    if __name__ == '__main__':
    
        app = QtWidgets.QApplication(sys.argv)
        main = mainWidget()
        main.show()
        app.exec_()