qtpyqtpyqt4pyside

Drag and drop rows within QTableWidget


Goal

My goal is to have a QTableWidget in which the user can drag/drop rows internally. That is, the user can drag and drop one entire row, moving it up or down in the table to a different location in between two other rows. The goal is illustrated in this figure:

the challenge

What I tried, and what happens

Once I have populated a QTableWidget with data, I set its properties as follows:

table.setDragDropMode(QtGui.QAbstractItemView.InternalMove)   
#select one row at a time
table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection) 
table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows)

Similar code makes QListWidget behave nicely: when you move an item internally, it is dropped between two elements of the list, and the rest of the items sort themselves out in a reasonable way, with no data overwritten (in other words, the view acts like the figure above, but it is a list).

In contrast, in a table modified with the code above, things don't work out as planned. The following figure shows what actually happens:

crud

In words: when row i is dropped, that row becomes blank in the table. Further, if I accidentally drop row i onto row j (instead of the space between two rows), the data from row i replaces the data in row j. That is, in that unfortunate case, in addition to row i becoming blank, row j is overwritten.

Note I also tried adding table.setDragDropOverwriteMode(False) but it didn't change the behavior.

A way forward?

This bug report might include a possible solution in C++: it seems they reimplemented dropEvent for QTableWidget, but I am not sure how to cleanly port to Python.

Related content:


Solution

  • This seems very bizarre default behaviour. Anyway, following the code in the bug report you linked to, I have successfully ported something to PyQt. It may, or may not be as robust as that code, but it at least seems to work for the simple test case you provide in your screenshots!

    The potential issues with the below implementation are:

    If you were using this code in a production environment, you would probably want to go over it with a fine-tooth-comb and make sure everything made sense. There are quite probably issues with my PyQt port, and possibly issues with the original c++ algorithm my port was based on. It does however serve as a proof that what you want can be achieved using a QTableWidget.

    Update: note there is an additional answer below for PyQt5 that also fixes some of the concerns I had above. You might want to check it out!

    Code:

    import sys, os
    from PyQt4.QtCore import *
    from PyQt4.QtGui import *
    
    class TableWidgetDragRows(QTableWidget):
        def __init__(self, *args, **kwargs):
            QTableWidget.__init__(self, *args, **kwargs)
    
            self.setDragEnabled(True)
            self.setAcceptDrops(True)
            self.viewport().setAcceptDrops(True)
            self.setDragDropOverwriteMode(False)
            self.setDropIndicatorShown(True)
    
            self.setSelectionMode(QAbstractItemView.SingleSelection) 
            self.setSelectionBehavior(QAbstractItemView.SelectRows)
            self.setDragDropMode(QAbstractItemView.InternalMove)   
    
        def dropEvent(self, event):
            if event.source() == self and (event.dropAction() == Qt.MoveAction or self.dragDropMode() == QAbstractItemView.InternalMove):
                success, row, col, topIndex = self.dropOn(event)
                if success:             
                    selRows = self.getSelectedRowsFast()                        
    
                    top = selRows[0]
                    # print 'top is %d'%top
                    dropRow = row
                    if dropRow == -1:
                        dropRow = self.rowCount()
                    # print 'dropRow is %d'%dropRow
                    offset = dropRow - top
                    # print 'offset is %d'%offset
    
                    for i, row in enumerate(selRows):
                        r = row + offset
                        if r > self.rowCount() or r < 0:
                            r = 0
                        self.insertRow(r)
                        # print 'inserting row at %d'%r
    
    
                    selRows = self.getSelectedRowsFast()
                    # print 'selected rows: %s'%selRows
    
                    top = selRows[0]
                    # print 'top is %d'%top
                    offset = dropRow - top                
                    # print 'offset is %d'%offset
                    for i, row in enumerate(selRows):
                        r = row + offset
                        if r > self.rowCount() or r < 0:
                            r = 0
    
                        for j in range(self.columnCount()):
                            # print 'source is (%d, %d)'%(row, j)
                            # print 'item text: %s'%self.item(row,j).text()
                            source = QTableWidgetItem(self.item(row, j))
                            # print 'dest is (%d, %d)'%(r,j)
                            self.setItem(r, j, source)
    
                    # Why does this NOT need to be here?
                    # for row in reversed(selRows):
                        # self.removeRow(row)
    
                    event.accept()
    
            else:
                QTableView.dropEvent(event)                
    
        def getSelectedRowsFast(self):
            selRows = []
            for item in self.selectedItems():
                if item.row() not in selRows:
                    selRows.append(item.row())
            return selRows
    
        def droppingOnItself(self, event, index):
            dropAction = event.dropAction()
    
            if self.dragDropMode() == QAbstractItemView.InternalMove:
                dropAction = Qt.MoveAction
    
            if event.source() == self and event.possibleActions() & Qt.MoveAction and dropAction == Qt.MoveAction:
                selectedIndexes = self.selectedIndexes()
                child = index
                while child.isValid() and child != self.rootIndex():
                    if child in selectedIndexes:
                        return True
                    child = child.parent()
    
            return False
    
        def dropOn(self, event):
            if event.isAccepted():
                return False, None, None, None
    
            index = QModelIndex()
            row = -1
            col = -1
    
            if self.viewport().rect().contains(event.pos()):
                index = self.indexAt(event.pos())
                if not index.isValid() or not self.visualRect(index).contains(event.pos()):
                    index = self.rootIndex()
    
            if self.model().supportedDropActions() & event.dropAction():
                if index != self.rootIndex():
                    dropIndicatorPosition = self.position(event.pos(), self.visualRect(index), index)
    
                    if dropIndicatorPosition == QAbstractItemView.AboveItem:
                        row = index.row()
                        col = index.column()
                        # index = index.parent()
                    elif dropIndicatorPosition == QAbstractItemView.BelowItem:
                        row = index.row() + 1
                        col = index.column()
                        # index = index.parent()
                    else:
                        row = index.row()
                        col = index.column()
    
                if not self.droppingOnItself(event, index):
                    # print 'row is %d'%row
                    # print 'col is %d'%col
                    return True, row, col, index
    
            return False, None, None, None
    
        def position(self, pos, rect, index):
            r = QAbstractItemView.OnViewport
            margin = 2
            if pos.y() - rect.top() < margin:
                r = QAbstractItemView.AboveItem
            elif rect.bottom() - pos.y() < margin:
                r = QAbstractItemView.BelowItem 
            elif rect.contains(pos, True):
                r = QAbstractItemView.OnItem
    
            if r == QAbstractItemView.OnItem and not (self.model().flags(index) & Qt.ItemIsDropEnabled):
                r = QAbstractItemView.AboveItem if pos.y() < rect.center().y() else QAbstractItemView.BelowItem
    
            return r
    
    
    class Window(QWidget):
        def __init__(self):
            super(Window, self).__init__()
    
            layout = QHBoxLayout()
            self.setLayout(layout) 
    
            self.table_widget = TableWidgetDragRows()
            layout.addWidget(self.table_widget) 
    
            # setup table widget
            self.table_widget.setColumnCount(2)
            self.table_widget.setHorizontalHeaderLabels(['Colour', 'Model'])
    
            items = [('Red', 'Toyota'), ('Blue', 'RV'), ('Green', 'Beetle')]
            for i, (colour, model) in enumerate(items):
                c = QTableWidgetItem(colour)
                m = QTableWidgetItem(model)
    
                self.table_widget.insertRow(self.table_widget.rowCount())
                self.table_widget.setItem(i, 0, c)
                self.table_widget.setItem(i, 1, m)
    
            self.show()
    
    
    app = QApplication(sys.argv)
    window = Window()
    sys.exit(app.exec_())