Search code examples
python-3.xpyqtdrag-and-droppyqt5qtableview

PyQt5 dragging and dropping in QTableview causes selected row to disappear


Following on from a previous question here, I looked to add delete row on key press functionality to my qtableview table in PyQt5 by adding the removeRows function to my model. However, since adding this function it has disrupted my drag and drop functionality, whereby the dragged row disappears when dropping elsewhere in the qtableview table. Is there anyway I can prevent the dragged row from disappearing?

NB: Interestingly, when selecting the vertical header 'column' the drag/drop functionality works, but I'm keen to find a solution for dragging and dropping on row selection.

Here's my code below with the added removeRows function in the model, and also the keyPressEvent in the view

from PyQt5.QtGui import QBrush
from PyQt5.QtWidgets import *
from PyQt5.QtCore import QAbstractTableModel, Qt, QModelIndex

class myModel(QAbstractTableModel):
    def __init__(self, data, parent=None, *args):
        super().__init__(parent, *args)
        self._data = data or []
        self._headers = ['Type', 'result', 'count']

    def rowCount(self, index=None):
        return len(self._data)

    def columnCount(self, index=None):
        return len(self._headers)

    def headerData(self, section, orientation, role=Qt.DisplayRole):
        if role == Qt.DisplayRole:
            if orientation == Qt.Horizontal:
                if section < 0 or section >= len(self._headers):
                    return ""
                else:
                    return self._headers[section]
            else:
                return ''
        return None

    def removeRows(self, position, rows, QModelIndex):
        self.beginRemoveRows(QModelIndex, position, position + rows - 1)
        for i in range(rows):
            del (self._data[position])
        self.endRemoveRows()
        self.layoutChanged.emit()
        return True

    def data(self, index, role=None):
        if role == Qt.TextAlignmentRole:
            return Qt.AlignHCenter
        if role == Qt.ForegroundRole:
            return QBrush(Qt.black)
        if role == Qt.BackgroundRole:
            if (self.index(index.row(), 0).data().startswith('second')):
                return QBrush(Qt.green)
            else:
                if (self.index(index.row(), 1).data()) == 'abc':
                    return QBrush(Qt.yellow)
                if (self.index(index.row(), 1).data()) == 'def':
                    return QBrush(Qt.blue)
                if (self.index(index.row(), 1).data()) == 'ghi':
                    return QBrush(Qt.magenta)
        if role in (Qt.DisplayRole, Qt.EditRole):
            return self._data[index.row()][index.column()]

    def flags(self, index: QModelIndex) -> Qt.ItemFlags:
        return Qt.ItemIsDropEnabled | Qt.ItemIsEnabled | Qt.ItemIsEditable | Qt.ItemIsSelectable | Qt.ItemIsDragEnabled

    def supportedDropActions(self) -> bool:
        return Qt.MoveAction | Qt.CopyAction


class myTableView(QTableView):
    def __init__(self, parent):
        super().__init__(parent)
        header = self.verticalHeader()
        header.setSectionsMovable(True)
        header.setSectionResizeMode(QHeaderView.Fixed)
        header.setFixedWidth(10)
        QShortcut('F7', self, self.getLogicalRows)
        QShortcut('F6', self, self.toggleVerticalHeader)
        QShortcut('Alt+Up', self, lambda: self.moveRow(True))
        QShortcut('Alt+Down', self, lambda: self.moveRow(False))
        self.setSelectionBehavior(self.SelectRows)
        self.setSelectionMode(self.SingleSelection)
        self.setDragDropMode(self.InternalMove)
        self.setDragDropOverwriteMode(False)

    def dropEvent(self, event):
        if (event.source() is not self or
            (event.dropAction() != Qt.MoveAction and
             self.dragDropMode() != QAbstractItemView.InternalMove)):
            super().dropEvent(event)
        selection = self.selectedIndexes()
        from_index = selection[0].row() if selection else -1
        to_index = self.indexAt(event.pos()).row()
        if (0 <= from_index < self.model().rowCount() and
            0 <= to_index < self.model().rowCount() and
            from_index != to_index):
            header = self.verticalHeader()
            from_index = header.visualIndex(from_index)
            to_index = header.visualIndex(to_index)
            header.moveSection(from_index, to_index)
            event.accept()
        super().dropEvent(event)

    def toggleVerticalHeader(self):
        self.verticalHeader().setHidden(self.verticalHeader().isVisible())

    def moveRow(self, up=True):
        selection = self.selectedIndexes()
        if selection:
            header = self.verticalHeader()
            row = header.visualIndex(selection[0].row())
            if up and row > 0:
                header.moveSection(row, row - 1)
            elif not up and row < header.count() - 1:
                header.moveSection(row, row + 1)

    def getLogicalRows(self):
        header = self.verticalHeader()
        for vrow in range(header.count()):
            lrow = header.logicalIndex(vrow)
            index = self.model().index(lrow, 0)
            print(index.data())

    def keyPressEvent(self, event):
        if event.key() == Qt.Key_Delete:
            index = self.currentIndex()
            try:
                self.model().removeRows(index.row(), 1, index)
            except IndexError:
                pass
        else:
            super().keyPressEvent(event)


class sample_data(QMainWindow):
    def __init__(self):
        super().__init__()
        tv = myTableView(self)
        tv.setModel(myModel([
            ["first", 'abc', 123],
            ["second"],
            ["third", 'def', 456],
            ["fourth", 'ghi', 789],
        ]))
        self.setCentralWidget(tv)
        tv.setSpan(1, 0, 1, 3)

if __name__ == '__main__':

    app = QApplication(['Test'])
    test = sample_data()
    test.setGeometry(600, 100, 350, 185)
    test.show()
    app.exec_()

Solution

  • The main "problem" is that, by default, removeRows of QAbstractItemModel doesn't do anything (and returns False).

    The technical problem is a bit more subtle.
    A drag operation in an item view always begins with startDrag(), which creates a QDrag object and calls its exec(). When the user drops the data, that implementation also calls a private clearOrRemove function whenever the accepted drop action is MoveAction, which eventually overwrites the data or removes the row(s).

    You've used setDragDropOverwriteMode(False), so it will call removeRows. Your previous code used to work because, as said, the default implementation does nothing, but now you've reimplemented it, and it actually deletes rows in that case.

    The solution is to change the drop action whenever the drop event is a move (which is a bit unintuitive, but since the operation has been already performed, that shouldn't be an issue). Using IgnoreAction will avoid the unwanted behavior, as that clearOrRemove won't be called anymore in that case:

        def dropEvent(self, event):
            # ...
            if (0 <= from_index < self.model().rowCount() and
                0 <= to_index < self.model().rowCount() and
                from_index != to_index):
                # ...
                header.moveSection(from_index, to_index)
                event.accept()
                event.setDropAction(Qt.IgnoreAction)
            super().dropEvent(event)
    

    UPDATE

    When the items don't fill the whole viewport, drop events can occur outside the items range, resulting in an invalid QModelIndex from indexAt() not only when dropping beyond the last row, but also the last column.
    Since you're only interested in vertical movement, the solution is to get the to_index from the vertical header, and eventually set it to the last row whenever it's still invalid (-1):

        def dropEvent(self, event):
            if (event.source() is not self or
                (event.dropAction() != Qt.MoveAction and
                 self.dragDropMode() != QAbstractItemView.InternalMove)):
                super().dropEvent(event)
            selection = self.selectedIndexes()
            from_index = selection[0].row() if selection else -1
    
            globalPos = self.viewport().mapToGlobal(event.pos())
            header = self.verticalHeader()
            to_index = header.logicalIndexAt(header.mapFromGlobal(globalPos).y())
            if to_index < 0:
                to_index = header.logicalIndex(self.model().rowCount() - 1)
    
            if from_index != to_index:
                from_index = header.visualIndex(from_index)
                to_index = header.visualIndex(to_index)
                header.moveSection(from_index, to_index)
                event.accept()
                event.setDropAction(Qt.IgnoreAction)
            super().dropEvent(event)