Search code examples
pythonpyqtdrag-and-droppyqt5qtableview

Re-ordering QTableView rows that include spanned columns


I have a working drag and drop example below for reordering rows of the same column length for a qtableview using PyQt5 (with help from this StackOverflow question here). However I am looking to perform the same operation on a qtableview table where one or two rows have merged cells spanning the total number of columns (like the second row in the picture below).

enter image description here

How would be the best way to go about this? Should I remove the merge (clearSpans) at the point of drag/drop and then do a remerge based on the cell value (though when I tried this it did not work), or is there a way to drag/drop reorder with the cell merging intact?

Here's the code which works for row data of equal columns, but fails when a row is merged

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]
        return None

    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

    def relocateRow(self, row_source, row_target) -> None:
        row_a, row_b = max(row_source, row_target), min(row_source, row_target)
        self.beginMoveRows(QModelIndex(), row_a, row_a, QModelIndex(), row_b)
        self._data.insert(row_target, self._data.pop(row_source))
        self.endMoveRows()


class myTableView(QTableView):

    def __init__(self, parent):
        super().__init__(parent)
        self.verticalHeader().hide()
        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()
        #self.clearSpans()
        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):
            self.model().relocateRow(from_index, to_index)
            event.accept()
        super().dropEvent(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)

        self.show()


if __name__ == '__main__':
    app = QApplication([])
    test = sample_data()
    raise SystemExit(app.exec_())

Solution

  • The sections of the vertical header can be made movable, so there's no need to implement this functionality yourself. It obviously means the vertical header will be visible, but that can be mitigated by making the sections blank, which will result in a relatively narrow header:

    screenshot

    Note that moving sections around (rather than rows) is purely visual - the underlying model is never modified. That shouldn't really matter in practice, though, since the header provides methods to translate from logical to visual indices. And it does bring some additional benefits - for example, it's very easy to return to a previous state (i.e. by using the header's saveState and restoreState methods).

    Below is a working demo based on your example. The rows can be re-ordered by dragging and dropping the section headers, or by pressing Alt+Up / Alt+Down when a row is selected. The vertical header can be toggled by pressing F6. The logical rows can be printed by pressing F7.

    UPDATE:

    I also added support for moving sections around by dragging and dropping the rows themselves.

    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 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())
    
    
    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_()