[PYQT] Drag and drop reorder in QTreeModelView

I’ve been beating my head against a wall for three days now trying to figure this one out, so I’m hoping someone out there has managed to get this working at some point.

I’m trying to implement a tree model that supports reordering of items via drag and drop. I’ve got it working, thanks to Yasin’s Model View tutorials and a bunch of Stack Overflow threads – but there’s a bug that I can’t seem to wrap my head around. Incidentally, there’s an example on stack overflow that exhibits the same exact issue.

I wrote up a simpler version of what I’m working with for example purposes, it’s very similar to the example on Stack Overflow here.

from PyQt4 import QtCore, QtGui
import sys
import cPickle as pickle
import cStringIO
import copy


class Item(object):
    def __init__(self, name, parent=None):
        self.name = name
        self.children = []
        self.parent = parent
        
        if parent is not None:
            self.parent.addChild( self )
        
    def addChild(self, child):
        self.children.append(child)
        child.parent = self
    
    def removeChild(self, row):
        self.children.pop(row)
    
    def child(self, row):
        return self.children[row]
    
    def __len__(self):
        return len(self.children)
    
    def row(self):
        if self.parent is not None:
            return self.parent.children.index(self)
        
#====================================================================

class PyObjMime(QtCore.QMimeData):
    MIMETYPE = QtCore.QString('application/x-pyobj')

    def __init__(self, data=None): 
        super(PyObjMime, self).__init__() 
 
        self.data = data 
        if data is not None: 
            # Try to pickle data
            try: 
                pdata = pickle.dumps(data) 
            except: 
                return 

            self.setData(self.MIMETYPE, pickle.dumps(data.__class__) + pdata) 

    def itemInstance(self): 
        if self.data is not None: 
            return self.data

        io = cStringIO.StringIO(str(self.data(self.MIMETYPE))) 

        try: 
            # Skip the type. 
            pickle.load(io) 

            # Recreate the data. 
            return pickle.load(io) 
        except: 
            pass 

        return None
    
#====================================================================

class TreeModel(QtCore.QAbstractItemModel):
    def __init__(self, root, parent=None):
        super(TreeModel, self).__init__(parent)
        self.root = root
        
    def itemFromIndex(self, index):
        if index.isValid():
            return index.internalPointer()
        return self.root
    
    def rowCount(self, index):
        item = self.itemFromIndex(index)
        return len(item)
    
    def columnCount(self, index):
        return 1
    
    def flags(self, index):
        if not index.isValid():
            return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsDropEnabled
        return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsDropEnabled | QtCore.Qt.ItemIsDragEnabled | QtCore.Qt.ItemIsSelectable
    
    def supportedDropActions(self):
        return QtCore.Qt.MoveAction | QtCore.Qt.CopyAction
    
    def data(self, index, role):
        if role == QtCore.Qt.DisplayRole:
            item = self.itemFromIndex(index)
            return item.name
    
    def index(self, row, column, parentIndex):
        parent = self.itemFromIndex(parentIndex)
        return self.createIndex( row, column, parent.child(row) )
    
    def parent(self, index):
        item = self.itemFromIndex(index)
        parent = item.parent
        if parent == self.root:
            return QtCore.QModelIndex()
        return self.createIndex(parent.row(), 0, parent)
    
    def insertRows(self, row, count, parentIndex):
        self.beginInsertRows(parentIndex, row, row+count-1)
        self.endInsertRows()
        return True
    
    def removeRows(self, row, count, parentIndex):
        self.beginRemoveRows(parentIndex, row, row+count-1)
        parent = self.itemFromIndex(parentIndex)
        parent.removeChild(row)
        self.endRemoveRows()
        return True
    
    def mimeTypes(self):
        types = QtCore.QStringList()
        types.append('application/x-pyobj')
        return types
    
    def mimeData(self, index):
        item = self.itemFromIndex(index[0])
        mimedata = PyObjMime(item)
        return mimedata
    
    def dropMimeData(self, mimedata, action, row, column, parentIndex):
        item = mimedata.itemInstance()
        dropParent = self.itemFromIndex(parentIndex)
        itemCopy = copy.deepcopy(item)
        dropParent.addChild(itemCopy)
        self.insertRows(len(dropParent)-1, 1, parentIndex)
        self.dataChanged.emit(parentIndex, parentIndex)
        return True
    
if __name__ == '__main__':
    
    app = QtGui.QApplication(sys.argv) 
    
    root = Item( 'root' )
    itemA = Item( 'ItemA', root )
    itemB = Item( 'ItemB', root )
    itemC = Item( 'ItemC', root )
    itemD = Item( 'ItemD', itemA )
    itemE = Item( 'ItemE', itemB )
    itemF = Item( 'ItemF', itemC )
    
    model = TreeModel(root) 
    tree = QtGui.QTreeView()
    tree.setModel( model ) 
    tree.setDragEnabled(True)
    tree.setAcceptDrops(True)
    tree.setDragDropMode( QtGui.QAbstractItemView.InternalMove )
    tree.show()
    tree.expandAll()
    sys.exit(app.exec_()) 

To see the issue that I’m talking about, drag ‘ItemF’ onto ‘ItemB’, and then drag ‘ItemF’ back onto ‘ItemC’. It should throw a list index error.
This seems to happen any time you drag an item with siblings onto another item… items without siblings seem to be fine (hence the first drag and drop doesn’t have an issue).
I’ve stepped through the code and as far as I can tell, the error is raised when the ‘removeRows’ function (which is automatically called at the end of the move operation) calls the internal ‘beginRemoveRows’ function. Somewhere in that function, a parent node is queried for a child from its children list using an index that’s out of range. I don’t know why the internal function is doing that – I’ve even poked through the C++ QT source code trying to figure it out - no dice.

The last thing I’ve figured out is that if I replace the model’s ‘index’ function with this one that catches the out of range index:

def index(self, row, column, parent): 
    item = self.itemFromIndex(parent) 
    if row < len(item):
        return self.createIndex(row, column, item.child(row))
    else:
        return QtCore.QModelIndex() 

it works just fine.

In a pinch, I can use that alternate index function – but I’d sure like to know why the heck this whole issue is happening in the first place.

In case anyone else is interested, I figured this out, and I wrote up a blog post about implementing drag and drop for a PyQt tree view.
Hopefully it helps someone else out.

Thanks for sharing!