如何在不丢失 PyQt5 中基本的拖放功能的情况下更改树视图中的 dropEvent 操作?

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

我正在使用带有自定义 QTreeView 的自定义项模型(从 QAbstractItemModel 子类化)。我想允许内部拖放移动 (MoveAction) ,当按下修改键或鼠标右键时,将 CopyAction 传递到我的模型(到 dropMimeData)以复制项目。但是,QTreeView 中 dropEvent() 的默认实现似乎(来自 C 代码)只能传递 MoveAction 但是当我尝试在我的 QTreeView 子类中重新实现 dropEvent() 时,如下所示:

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

... 它可以工作,但在用户交互方面工作得非常糟糕,因为在默认实现中有大量复杂的代码确定正确的索引以放置项目。 当我尝试修改操作并调用超类时:super(Tree, self).dropEvent(e) dropAction() 数据也丢失了。

我该怎么做才能修改 dropAction 而不会丢失 default dropEvent 为我做的所有花哨的事情?

我当前的 WIP 代码一团糟(我希望它是一个接近最小示例的地方)

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

setDragDropMode(QAbstractItemView.InternalMove) 只允许移动操作(顾名思义,尽管 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 属性 可能很有趣。

之前让我感到惊讶的一件事是,在模型的 dropMimeData() 方法中,如果您从 Qt.MoveAction return TrueQAbstractItemView 将自动从模型中删除拖动的项目(通过 removeRows()/removeColumns() 调用您的模型)。如果您的模型实际上已经移动了该行(并删除了旧行),这可能会导致一些令人费解的结果。我一直不太理解这种行为。 OTOH 如果你 return False 项目视图并不重要,只要数据实际上 moved/updated 正确。