Search code examples
pythonpyqtdrag-and-droppyqt5qtreeview

How to alter dropEvent action in treeview without loosing basic drag-n-drop functionality in PyQt5?


I'm using my custom item model (subclassed from QAbstractItemModel) with custom QTreeView. I want to allow internal drag-n-drop movement (MoveAction) and, when modifier key or right mouse button is pressed, pass CopyAction to my model (to dropMimeData) to copy items. However, default implementation of dropEvent() in QTreeView seems (from C code) only capable of passing MoveAction but when I try to reimplement dropEvent() in my QTreeView subclass like this:

def dropEvent(self, e):
    index = self.indexAt(e.pos())
    parent = index.parent()
    self.model().dropMimeData(e.mimeData(), e.dropAction(), index.row(), index.column(), parent)
    e.accept()

... it works, but works horribly in terms of user interaction because there are tons of comlex code determining right index to drop item on in default implementation. When i'm trying to modify action and call to superclass: super(Tree, self).dropEvent(e) dropAction() data is also lost.

What can I do in order to modify dropAction without loosing all fancy things that default dropEvent is doing for me?

Horrible mess of my current WIP code (i hope it's somewhere near minimal example)

from copy import deepcopy

import pickle

import config_editor
from PyQt5 import QtCore, QtGui, QtWidgets
from PyQt5.QtCore import Qt as Qt
from PyQt5.QtGui import QCursor, QStandardItemModel
from PyQt5.QtWidgets import QAbstractItemView, QTreeView, QMenu


class ConfigModelItem:
    def __init__(self, label, value="", is_section=False, state='default', parent=None):
        self.itemData = [label, value]
        self.is_section = is_section
        self.state = state

        self.childItems = []
        self.parentItem = parent

        if self.parentItem is not None:
            self.parentItem.appendChild(self)

    def appendChild(self, item):
        self.childItems.append(item)
        item.parentItem = self

    def addChildren(self, items, row):
        if row == -1:
            row = 0
        self.childItems[row:row] = items

        for item in items:
            item.parentItem = self

    def child(self, row):
        return self.childItems[row]

    def childCount(self):
        return len(self.childItems)

    def columnCount(self):
        return 2

    def data(self, column):
        try:
            return self.itemData[column]
        except IndexError:
            return None

    def set_data(self, data, column):
        try:
            self.itemData[column] = data
        except IndexError:
            return False

        return True

    def parent(self):
        return self.parentItem

    def row(self):
        if self.parentItem is not None:
            return self.parentItem.childItems.index(self)
        return 0

    def removeChild(self, position):
        if position < 0 or position > len(self.childItems):
            return False
        child = self.childItems.pop(position)
        child.parentItem = None
        return True

    def __repr__(self):
        return str(self.itemData)


class ConfigModel(QtCore.QAbstractItemModel):
    def __init__(self, data, parent=None):
        super(ConfigModel, self).__init__(parent)

        self.rootItem = ConfigModelItem("Option", "Value")
        self.setup(data)

    def headerData(self, section, orientation, role):
        if role == Qt.DisplayRole and orientation == Qt.Horizontal:
            return self.rootItem.data(section)

    def columnCount(self, parent):
        return 2

    def rowCount(self, parent):
        if parent.column() > 0:
            return 0

        if not parent.isValid():
            parentItem = self.rootItem
        else:
            parentItem = parent.internalPointer()

        return parentItem.childCount()

    def index(self, row, column, parent):
        if not self.hasIndex(row, column, parent):
            return QtCore.QModelIndex()

        parentItem = self.nodeFromIndex(parent)
        childItem = parentItem.child(row)

        if childItem:
            return self.createIndex(row, column, childItem)
        else:
            return QtCore.QModelIndex()

    def parent(self, index):
        if not index.isValid():
            return QtCore.QModelIndex()

        childItem = index.internalPointer()
        parentItem = childItem.parent()

        if parentItem == self.rootItem or parentItem is None:
            return QtCore.QModelIndex()

        return self.createIndex(parentItem.row(), 0, parentItem)

    def nodeFromIndex(self, index):
        if index.isValid():
            return index.internalPointer()
        return self.rootItem

    def data(self, index, role):
        if not index.isValid():
            return None

        item = index.internalPointer()

        if role == Qt.DisplayRole or role == Qt.EditRole:
            return item.data(index.column())

        return None

    def setData(self, index, value, role=Qt.EditRole):
        if not index.isValid():
            return False

        item = index.internalPointer()
        if role == Qt.EditRole:
            item.set_data(value, index.column())

        self.dataChanged.emit(index, index, (role,))

        return True

    def flags(self, index):
        if not index.isValid():
            return QtCore.Qt.ItemIsDragEnabled | QtCore.Qt.ItemIsDropEnabled  # Qt.NoItemFlags
        item = index.internalPointer()

        flags = Qt.ItemIsEnabled | Qt.ItemIsSelectable

        if index.column() == 0:
            flags |= int(QtCore.Qt.ItemIsDragEnabled)
            if item.is_section:
                flags |= int(QtCore.Qt.ItemIsDropEnabled)

        if index.column() == 1 and not item.is_section:
            flags |= Qt.ItemIsEditable

        return flags

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

    def mimeTypes(self):
        return ['app/configitem', 'text/xml']

    def mimeData(self, indexes):
        mimedata = QtCore.QMimeData()
        index = indexes[0]
        mimedata.setData('app/configitem', pickle.dumps(self.nodeFromIndex(index)))
        return mimedata

    def dropMimeData(self, mimedata, action, row, column, parentIndex):
        print('action', action)
        if action == Qt.IgnoreAction:
            return True

        droppedNode = deepcopy(pickle.loads(mimedata.data('app/configitem')))

        print('copy', action & Qt.CopyAction)
        print(droppedNode.itemData, 'node')
        self.insertItems(row, [droppedNode], parentIndex)
        self.dataChanged.emit(parentIndex, parentIndex)
        if action & Qt.CopyAction:
            return False  # to not delete original item
        return True

    def removeRows(self, row, count, parent):
        print('rem', row, count)
        self.beginRemoveRows(parent, row, row+count-1)
        parentItem = self.nodeFromIndex(parent)

        for x in range(count):
            parentItem.removeChild(row)

        self.endRemoveRows()
        print('removed')
        return True

    @QtCore.pyqtSlot()
    def removeRow(self, index):
        parent = index.parent()
        self.beginRemoveRows(parent, index.row(), index.row())

        parentItem = self.nodeFromIndex(parent)
        parentItem.removeChild(index.row())

        self.endRemoveRows()
        return True

    def insertItems(self, row, items, parentIndex):
        print('ins', row)
        parent = self.nodeFromIndex(parentIndex)
        self.beginInsertRows(parentIndex, row, row+len(items)-1)

        parent.addChildren(items, row)
        print(parent.childItems)

        self.endInsertRows()
        self.dataChanged.emit(parentIndex, parentIndex)
        return True

    def setup(self, data: dict, parent=None):
        if parent is None:
            parent = self.rootItem

        for key, value in data.items():
            if isinstance(value, dict):
                item = ConfigModelItem(key, parent=parent, is_section=True)
                self.setup(value, parent=item)
            else:
                parent.appendChild(ConfigModelItem(key, value))

    def to_dict(self, parent=None) -> dict:
        if parent is None:
            parent = self.rootItem

        data = {}
        for item in parent.childItems:
            item_name, item_data = item.itemData
            if item.childItems:
                data[item_name] = self.to_dict(item)
            else:
                data[item_name] = item_data

        return data

    @property
    def dict(self):
        return self.to_dict()


class ConfigDialog(config_editor.Ui_config_dialog):
    def __init__(self, data):
        super(ConfigDialog, self).__init__()
        self.model = ConfigModel(data)

    def setupUi(self, config_dialog):
        super(ConfigDialog, self).setupUi(config_dialog)

        self.config_view = Tree()
        self.config_view.setObjectName("config_view")
        self.config_view.setModel(self.model)
        self.gridLayout.addWidget(self.config_view, 0, 0, 1, 1)

        self.config_view.expandAll()
        #self.config_view.setDragDropMode(True)
        #self.setDragDropMode(QAbstractItemView.InternalMove)
        #self.setDragEnabled(True)
        #self.setAcceptDrops(True)
        #self.setDropIndicatorShown(True)

        self.delete_button.pressed.connect(self.remove_selected)

    def remove_selected(self):
        index = self.config_view.selectedIndexes()[0]
        self.model.removeRow(index)\


class Tree(QTreeView):
    def __init__(self):
        QTreeView.__init__(self)

        self.setContextMenuPolicy(Qt.CustomContextMenu)
        self.customContextMenuRequested.connect(self.open_menu)

        self.setSelectionMode(self.SingleSelection)
        self.setDragDropMode(QAbstractItemView.InternalMove)
        self.setDragEnabled(True)
        self.setAcceptDrops(True)
        self.setDropIndicatorShown(True)
        self.setAnimated(True)

    def dropEvent(self, e):
        print(e.dropAction(), 'baseact', QtCore.Qt.CopyAction)
        # if e.keyboardModifiers() & QtCore.Qt.AltModifier:
        #     #e.setDropAction(QtCore.Qt.CopyAction)
        #     print('copy')
        # else:
        #     #e.setDropAction(QtCore.Qt.MoveAction)
        #     print("drop")

        print(e.dropAction())
        #super(Tree, self).dropEvent(e)
        index = self.indexAt(e.pos())
        parent = index.parent()
        print('in', index.row())
        self.model().dropMimeData(e.mimeData(), e.dropAction(), index.row(), index.column(), parent)

        e.accept()

    def open_menu(self):
        menu = QMenu()
        menu.addAction("Create new item")
        menu.exec_(QCursor.pos())


if __name__ == '__main__':
    import sys

    def except_hook(cls, exception, traceback):
        sys.__excepthook__(cls, exception, traceback)

    sys.excepthook = except_hook

    app = QtWidgets.QApplication(sys.argv)
    Dialog = QtWidgets.QDialog()

    data = {"section 1": {"opt1": "str", "opt2": 123, "opt3": 1.23, "opt4": False, "...": {'subopt': 'bal'}},
            "section 2": {"opt1": "str", "opt2": [1.1, 2.3, 34], "opt3": 1.23, "opt4": False, "...": ""}}

    ui = ConfigDialog(data)
    ui.setupUi(Dialog)

    print(Qt.DisplayRole)
    Dialog.show()
    print(app.exec_())

    print(Dialog.result())
    print(ui.model.to_dict())

    sys.exit()

Solution

  • setDragDropMode(QAbstractItemView.InternalMove) only allows move operations (as the name would suggest, although the docs do leave some uncertainty in the way this is stated). You probably want to set it to QAbstractItemView.DragDrop mode. You can set the default action with setDefaultDropAction(). Other than that, it's up to the model to return the right item flags and supportedDropActions()/canDropMimeData(), which it looks like yours does. There's also a dragDropOverwriteMode property which may be interesting.

    One thing that has surprised me before is that in the model's dropMimeData() method if you return True from a Qt.MoveAction, the QAbstractItemView will remove the dragged item from the model automatically (with a removeRows()/removeColumns() call to your model). This can cause some puzzling results if your model has already actually moved that row (and deleted the old one). I never quite understood that behavior. OTOH if you return False it doesn't matter to the item view, as long as the data is actually moved/updated properly.