5

I (simply) want to be able to use a QTableViews Drag&Drop mechanism to move existing rows. I found lots of sources (e.g. here, here or here) which describe some aspects of dragging, dropping, inserting etc. but I'm still struggling to make it work for my case.

Here is what the solution I'm looking for should be capable of:

  • work on a 'Qt-free' data structure, e.g. a list of tuples.
  • operate on the data structure. i.e. when the order of items gets modified in the view it should be modified in the data structure
  • look and feel of standard drag&drop enabled lists:
    • select/move whole rows
    • show a drop indicator for the whole line
  • Further operations like deleting/editing of cells must still be possible i.e. not be touched by the drag&drop approach

This tutorial shows a solution which is very close to what I need but it uses a QStandardItemModel rather than QAbstractTableModel which looks semi-optimal to me because I have to operate on a 'mirrored' data structure based on QStandardItem which is needed by QStandardItemModel (am I right?)

The code which represents my current progress is appended below.

Currently I see two possible approaches:

Approach 1: Implement against QAbstractTableModel and implement all needed events/slots to modify the underlying data structure: * pro: most generic approach * pro: no redundant data * con: I don't know how to get informed about a finished drag&drop operation and what index got moved where

In the code I've appended I trace all related methods I know of and print out all arguments. Here is what I get when I drag line 2 onto line 3

dropMimeData(data: ['application/x-qabstractitemmodeldatalist'], action: 2, row: -1, col: -1, parent: '(row: 2, column: 0, valid: True)')
insertRows(row=-1, count=1, parent=(row: 2, column: 0, valid: True))
setData(index=(row: 0, column: 0, valid: True), value='^line1', role=0)
setData(index=(row: 0, column: 1, valid: True), value=1, role=0)
removeRows(row=1, count=1, parent=(row: -1, column: -1, valid: False))

This output raises the following questions for me:

  • why do moveRow/moveRows not get called? when would they be called?
  • why are insertRow/removeRow not called but only insertRows/removeRows?
  • what does a row index of -1 mean?
  • what can I do with mime data provided in dropMimeData? Should I use it to copy data later?

Approach 2: Use QStandardItemModel and modify your data in parallel to the data managed by QStandardItemModel. * pro: there's a working example * contra: you manage a redundant data structure which has to be consistent with another internally managed data structure. * contra: didn't find out how to do that exactly neither

Here is my current approach using QAbstractTableModel:

from PyQt5 import QtWidgets, QtCore, QtGui

class MyModel(QtCore.QAbstractTableModel):
    def __init__(self, data, parent=None, *args):
        super().__init__(parent, *args)
        self._data = data

    def columnCount(self, parent):
        return 2

    def rowCount(self, parent):
        return len(self._data)

    def headerData(self, column: int, orientation, role: QtCore.Qt.ItemDataRole):
        return (('Regex', 'Category')[column] 
                if role == QtCore.Qt.DisplayRole and orientation == QtCore.Qt.Horizontal
                else None)

    def data(self, index, role: QtCore.Qt.ItemDataRole):
        if role not in {QtCore.Qt.DisplayRole, QtCore.Qt.EditRole}:
            return None

        print("data(index=%s, role=%r)" % (self._index2str(index), self._role2str(role)))
        return (self._data[index.row()][index.column()] 
               if index.isValid()
               and role in {QtCore.Qt.DisplayRole, QtCore.Qt.EditRole} 
               and index.row() < len(self._data)
               else None)

    def setData(self, index: QtCore.QModelIndex, value, role: QtCore.Qt.ItemDataRole):

        print("setData(index=%s, value=%r, role=%r)" % (self._index2str(index), value, role))
        return super().setData(index, value, role)

    def flags(self, index):
        return (
           super().flags(index) 
            | QtCore.Qt.ItemIsDropEnabled
            | (QtCore.Qt.ItemIsEditable | QtCore.Qt.ItemIsDragEnabled)
              if index.isValid() else QtCore.Qt.NoItemFlags)

    def dropMimeData(self, data, action, row, col, parent: QtCore.QModelIndex):
        """Always move the entire row, and don't allow column 'shifting'"""
        print("dropMimeData(data: %r, action: %r, row: %r, col: %r, parent: %r)" % (
            data.formats(), action, row, col, self._index2str(parent)))
        assert action == QtCore.Qt.MoveAction
        return super().dropMimeData(data, action, row, 0, parent)

    def supportedDragActions(self):
        return QtCore.Qt.MoveAction

    def supportedDropActions(self):
        return QtCore.Qt.MoveAction | QtCore.Qt.CopyAction

    def removeRow(self, row: int, parent=None):
        print("removeRow(row=%r):" % (row))
        return super().removeRow(row, parent)

    def removeRows(self, row: int, count: int, parent=None):
        print("removeRows(row=%r, count=%r, parent=%s)" % (row, count, self._index2str(parent)))
        return super().removeRows(row, count, parent)

    def insertRow(self, index, parent=None):
        print("insertRow(row=%r, count=%r):" % (row, count))
        return super().insertRow(row, count, parent)

    def insertRows(self, row: int, count: int, parent: QtCore.QModelIndex = None):
        print("insertRows(row=%r, count=%r, parent=%s)" % (row, count, self._index2str(parent)))
        return super().insertRows(row, count, parent)

    @staticmethod
    def _index2str(index):
        return "(row: %d, column: %d, valid: %r)" % (index.row(), index.column(), index.isValid())

    @staticmethod
    def _role2str(role: QtCore.Qt.ItemDataRole) -> str:
        return "%s (%d)" % ({
            QtCore.Qt.DisplayRole: "DisplayRole",
            QtCore.Qt.DecorationRole: "DecorationRole",
            QtCore.Qt.EditRole: "EditRole",
            QtCore.Qt.ToolTipRole: "ToolTipRole",
            QtCore.Qt.StatusTipRole: "StatusTipRole",
            QtCore.Qt.WhatsThisRole: "WhatsThisRole",
            QtCore.Qt.SizeHintRole: "SizeHintRole",

            QtCore.Qt.FontRole: "FontRole",
            QtCore.Qt.TextAlignmentRole: "TextAlignmentRole",
            QtCore.Qt.BackgroundRole: "BackgroundRole",
            #QtCore.Qt.BackgroundColorRole:
            QtCore.Qt.ForegroundRole: "ForegroundRole",
            #QtCore.Qt.TextColorRole
            QtCore.Qt.CheckStateRole: "CheckStateRole",
            QtCore.Qt.InitialSortOrderRole: "InitialSortOrderRole",
        }[role], role)


class MyTableView(QtWidgets.QTableView):
    class DropmarkerStyle(QtWidgets.QProxyStyle):
        def drawPrimitive(self, element, option, painter, widget=None):
            """Draw a line across the entire row rather than just the column we're hovering over.
            This may not always work depending on global style - for instance I think it won't
            work on OSX."""
            if element == self.PE_IndicatorItemViewItemDrop and not option.rect.isNull():
                option_new = QtWidgets.QStyleOption(option)
                option_new.rect.setLeft(0)
                if widget:
                    option_new.rect.setRight(widget.width())
                option = option_new
            super().drawPrimitive(element, option, painter, widget)

    def __init__(self):
        super().__init__()
        self.setStyle(self.DropmarkerStyle())
        # only allow rows to be selected
        self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows)
        # disallow multiple rows to be selected
        self.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection)
        self.setDragEnabled(True)

        self.setDragDropMode(QtWidgets.QAbstractItemView.InternalMove)
        self.setDropIndicatorShown(True) # default
        self.setAcceptDrops(False)           # ?
        self.viewport().setAcceptDrops(True) # ?
        self.setDragDropOverwriteMode(False)


class HelloWindow(QtWidgets.QMainWindow):
    def __init__(self) -> None:
        super().__init__()

        model = MyModel([("^line0", 0),
                         ("^line1", 1),
                         ("^line2", 2),
                         ("^line3", 3)])

        table_view = MyTableView()
        table_view.setModel(model)
        table_view.verticalHeader().hide()
        table_view.setShowGrid(False)

        self.setCentralWidget(table_view)


def main():
    app = QtWidgets.QApplication([])
    window = HelloWindow()
    window.show()
    app.exec_()

if __name__ == "__main__":
    main()
frans
  • 8,868
  • 11
  • 58
  • 132

2 Answers2

3

I have no clue yet how to make QAbstractTableModel or QAbstractItemModel work as described but I finally found a way to make the QTableView handle drag & drop and just makes the model move a row.

Here's the code:

from PyQt5 import QtWidgets, QtCore

class ReorderTableModel(QtCore.QAbstractTableModel):
    def __init__(self, data, parent=None, *args):
        super().__init__(parent, *args)
        self._data = data

    def columnCount(self, parent=None) -> int:
        return 2

    def rowCount(self, parent=None) -> int:
        return len(self._data) + 1

    def headerData(self, column: int, orientation, role: QtCore.Qt.ItemDataRole):
        return (('Regex', 'Category')[column]
                if role == QtCore.Qt.DisplayRole and orientation == QtCore.Qt.Horizontal
                else None)

    def data(self, index: QtCore.QModelIndex, role: QtCore.Qt.ItemDataRole):
        if not index.isValid() or role not in {QtCore.Qt.DisplayRole, QtCore.Qt.EditRole}:
            return None
        return (self._data[index.row()][index.column()] if index.row() < len(self._data) else
                "edit me" if role == QtCore.Qt.DisplayRole else "")

    def flags(self, index: QtCore.QModelIndex) -> QtCore.Qt.ItemFlags:
        # https://doc.qt.io/qt-5/qt.html#ItemFlag-enum
        if not index.isValid():
            return QtCore.Qt.ItemIsDropEnabled
        if index.row() < len(self._data):
            return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsEditable | QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsDragEnabled
        return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsEditable

    def supportedDropActions(self) -> bool:
        return QtCore.Qt.MoveAction | QtCore.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(QtCore.QModelIndex(), row_a, row_a, QtCore.QModelIndex(), row_b)
        self._data.insert(row_target, self._data.pop(row_source))
        self.endMoveRows()


class ReorderTableView(QtWidgets.QTableView):
    """QTableView with the ability to make the model move a row with drag & drop"""

    class DropmarkerStyle(QtWidgets.QProxyStyle):
        def drawPrimitive(self, element, option, painter, widget=None):
            """Draw a line across the entire row rather than just the column we're hovering over.
            This may not always work depending on global style - for instance I think it won't
            work on OSX."""
            if element == self.PE_IndicatorItemViewItemDrop and not option.rect.isNull():
                option_new = QtWidgets.QStyleOption(option)
                option_new.rect.setLeft(0)
                if widget:
                    option_new.rect.setRight(widget.width())
                option = option_new
            super().drawPrimitive(element, option, painter, widget)

    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)
        self.setStyle(self.DropmarkerStyle())

    def dropEvent(self, event):
        if (event.source() is not self or
            (event.dropAction() != QtCore.Qt.MoveAction and
             self.dragDropMode() != QtWidgets.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):
            self.model().relocateRow(from_index, to_index)
            event.accept()
        super().dropEvent(event)


class Testing(QtWidgets.QMainWindow):
    """Demonstrate ReorderTableView"""
    def __init__(self):
        super().__init__()
        view = ReorderTableView(self)
        view.setModel(ReorderTableModel([
            ("a", 1),
            ("b", 2),
            ("c", 3),
            ("d", 4),
        ]))
        self.setCentralWidget(view)

        self.show()


if __name__ == '__main__':
    app = QtWidgets.QApplication([])
    test = Testing()
    raise SystemExit(app.exec_())
frans
  • 8,868
  • 11
  • 58
  • 132
1

MyData class should be inherited from QStandardItemModel revised your code to solve drag-drop and extension class function call issue.

from PyQt5 import (QtWidgets, QtCore)
from PyQt5.QtWidgets import (QApplication, QTableView)
from PyQt5.QtGui import (QStandardItem, QStandardItemModel)


class MyModel(QStandardItemModel):
    def __init__(self, data, parent=None, *args):
        super().__init__(parent, *args)
        self._data = data

        for (index, data) in enumerate(data):
            first = QStandardItem('Item {}'.format(index))
            first.setDropEnabled(False)
            first.setEditable(False)
            second = QStandardItem(data[0])
            second.setDropEnabled(False)
            second.setEditable(False)
            self.appendRow([first, second])

    def columnCount(self, parent):
        return 2

    def rowCount(self, parent):
        return len(self._data)

    def headerData(self, column: int, orientation, role: QtCore.Qt.ItemDataRole):
        return (('Regex', 'Category')[column]
                if role == QtCore.Qt.DisplayRole and orientation == QtCore.Qt.Horizontal
                else None)

    def data(self, index, role: QtCore.Qt.ItemDataRole):
        if role not in {QtCore.Qt.DisplayRole, QtCore.Qt.EditRole}:
            return None

        print("data(index=%s, role=%r)" % (self._index2str(index), self._role2str(role)))
        return (self._data[index.row()][index.column()]
                if index.isValid() and role in {QtCore.Qt.DisplayRole, QtCore.Qt.EditRole} and index.row() < len(
            self._data)
                else None)

    def setData(self, index: QtCore.QModelIndex, value, role: QtCore.Qt.ItemDataRole):
        print("setData(index=%s, value=%r, role=%r)" % (self._index2str(index), value, role))
        return super().setData(index, value, role)

    def flags(self, index):
        return (
            super().flags(index)
            | QtCore.Qt.ItemIsDropEnabled
            | (QtCore.Qt.ItemIsEditable | QtCore.Qt.ItemIsDragEnabled)
            if index.isValid() else QtCore.Qt.NoItemFlags)

    def dropMimeData(self, data, action, row, col, parent: QtCore.QModelIndex):
        """Always move the entire row, and don't allow column 'shifting'"""
        print("dropMimeData(data: %r, action: %r, row: %r, col: %r, parent: %r)" % (
            data.formats(), action, row, col, self._index2str(parent)))
        assert action == QtCore.Qt.MoveAction
        return super().dropMimeData(data, action, row, 0, parent)

    def supportedDragActions(self):
        return QtCore.Qt.MoveAction

    def supportedDropActions(self):
        return QtCore.Qt.MoveAction | QtCore.Qt.CopyAction

    def removeRow(self, row: int, parent=None):
        print("removeRow(row=%r):" % (row))
        return super().removeRow(row, parent)

    def removeRows(self, row: int, count: int, parent=None):
        print("removeRows(row=%r, count=%r, parent=%s)" % (row, count, self._index2str(parent)))
        return super().removeRows(row, count, parent)

    def insertRow(self, index, parent=None):
        print("insertRow(row=%r, count=%r):" % (row, count))
        return super().insertRow(row, count, parent)

    def insertRows(self, row: int, count: int, parent: QtCore.QModelIndex = None):
        print("insertRows(row=%r, count=%r, parent=%s)" % (row, count, self._index2str(parent)))
        return super().insertRows(row, count, parent)

    @staticmethod
    def _index2str(index):
        return "(row: %d, column: %d, valid: %r)" % (index.row(), index.column(), index.isValid())

    @staticmethod
    def _role2str(role: QtCore.Qt.ItemDataRole) -> str:
        return "%s (%d)" % ({
                                QtCore.Qt.DisplayRole: "DisplayRole",
                                QtCore.Qt.DecorationRole: "DecorationRole",
                                QtCore.Qt.EditRole: "EditRole",
                                QtCore.Qt.ToolTipRole: "ToolTipRole",
                                QtCore.Qt.StatusTipRole: "StatusTipRole",
                                QtCore.Qt.WhatsThisRole: "WhatsThisRole",
                                QtCore.Qt.SizeHintRole: "SizeHintRole",

                                QtCore.Qt.FontRole: "FontRole",
                                QtCore.Qt.TextAlignmentRole: "TextAlignmentRole",
                                QtCore.Qt.BackgroundRole: "BackgroundRole",
                                # QtCore.Qt.BackgroundColorRole:
                                QtCore.Qt.ForegroundRole: "ForegroundRole",
                                # QtCore.Qt.TextColorRole
                                QtCore.Qt.CheckStateRole: "CheckStateRole",
                                QtCore.Qt.InitialSortOrderRole: "InitialSortOrderRole",
                            }[role], role)


class MyTableView(QTableView):
    class DropMarkerStyle(QtWidgets.QProxyStyle):
        def drawPrimitive(self, element, option, painter, widget=None):
            """Draw a line across the entire row rather than just the column we're hovering over.
            This may not always work depending on global style - for instance I think it won't
            work on OSX."""
            if element == self.PE_IndicatorItemViewItemDrop and not option.rect.isNull():
                option_new = QtWidgets.QStyleOption(option)
                option_new.rect.setLeft(0)
                if widget:
                    option_new.rect.setRight(widget.width())
                option = option_new
            super().drawPrimitive(element, option, painter, widget)

    def __init__(self):
        super().__init__()
        self.setStyle(self.DropMarkerStyle())
        self.verticalHeader().hide()
        self.setShowGrid(False)
        # only allow rows to be selected
        self.setSelectionBehavior(self.SelectRows)
        # disallow multiple rows to be selected
        self.setSelectionMode(self.SingleSelection)
        self.setDragDropMode(self.InternalMove)
        self.setDragDropOverwriteMode(False)


class HelloWindow(QtWidgets.QMainWindow):
    def __init__(self) -> None:
        super().__init__()
        model = MyModel([("^line0", 0),
                         ("^line1", 1),
                         ("^line2", 2),
                         ("^line3", 3)])
        table_view = MyTableView()
        table_view.setModel(model)
        self.setCentralWidget(table_view)


def main():
    app = QApplication([])
    window = HelloWindow()
    window.show()
    app.exec_()


if __name__ == "__main__":
    main()
oetzi
  • 1,002
  • 10
  • 21
  • 1
    I also found this example which shows drag&drop with an `QStandardModel` you are likely referring to but it has some problems: e.g. when you fix `first = ...` to take `data[0]` and `second =` to take `data[1]` the app crashes (probably because of `data[1]` being an `int`). But still if I run your example as it is I'm ending up with lines being deleted when I try to drag & drop them.. – frans Apr 27 '20 at 06:20
  • i tried commenting out the events in MyModel class but didnt fix the drag-drop functionality issue. Code added in MyModel constructor to define data as QStandardItem and its drag-drop features is the main part of the working solution. – oetzi Apr 27 '20 at 15:28