如何撤消 PySide/PyQt 中 QStandardItem 的编辑?

How to undo edit of QStandardItem in PySide/PyQt?

使用 作为指导,我正在尝试制作一个 QStandardItemModel,我可以在其中撤消对项目的编辑。

从下面的 SSCCE 中可以看出,我几乎完全复制了示例,但进行了一些小的调整,因为 currentItemChanged 不适用于 QStandardItemModel。为了解决这个问题,我使用 clicked 信号来修复项目的先前文本。

奇怪的是,undostack 中显示了正确的描述,但是当我单击 undo 按钮时,它实际上并没有撤消任何内容。

请注意,当前问题在表面上与 this question 相同。在另一个版本中接受的答案与其说是一个答案,不如说是一个提示。这是我试图在这里实现的提示,但它还没有工作。由于这个问题更加具体和详细,因此不应算作重复,IMO。

SSCCE

from PySide import QtGui, QtCore
import sys

class CommandItemEdit(QtGui.QUndoCommand):
    def __init__(self, model, item, textBeforeEdit, description = "Item edited"):
        QtGui.QUndoCommand.__init__(self, description)
        self.model = model
        self.item = item
        self.textBeforeEdit = textBeforeEdit
        self.textAfterEdit = item.text()

    def redo(self):
        self.model.blockSignals(True)  
        self.item.setText(self.textAfterEdit)
        self.model.blockSignals(False)

    def undo(self):
        self.model.blockSignals(True)
        self.item.setText(self.textBeforeEdit)
        self.model.blockSignals(False)     


class UndoableTree(QtGui.QWidget):
    def __init__(self, parent = None):
        QtGui.QWidget.__init__(self, parent = None)
        self.setAttribute(QtCore.Qt.WA_DeleteOnClose)
        self.view = QtGui.QTreeView()
        self.model = self.createModel()
        self.view.setModel(self.model)
        self.view.expandAll()
        self.undoStack = QtGui.QUndoStack(self)
        undoView = QtGui.QUndoView(self.undoStack)
        buttonLayout = self.buttonSetup()
        mainLayout = QtGui.QHBoxLayout(self)
        mainLayout.addWidget(undoView)
        mainLayout.addWidget(self.view)
        mainLayout.addLayout(buttonLayout)
        self.setLayout(mainLayout)
        self.makeConnections()
        #For undo/redo editing
        self.textBeforeEdit = ""

    def makeConnections(self):
        self.view.clicked.connect(self.itemClicked)
        self.model.itemChanged.connect(self.itemChanged)
        self.quitButton.clicked.connect(self.close)
        self.undoButton.clicked.connect(self.undoStack.undo)
        self.redoButton.clicked.connect(self.undoStack.redo)

    def itemClicked(self, index):
        item = self.model.itemFromIndex(index)
        self.textBeforeEdit = item.text()  

    def itemChanged(self, item):
        command = CommandItemEdit(self.model, item, self.textBeforeEdit, 
            "Renamed '{0}' to '{1}'".format(self.textBeforeEdit, item.text()))
        self.undoStack.push(command)


    def buttonSetup(self):
        self.undoButton = QtGui.QPushButton("Undo")
        self.redoButton = QtGui.QPushButton("Redo")
        self.quitButton = QtGui.QPushButton("Quit")
        buttonLayout = QtGui.QVBoxLayout()
        buttonLayout.addStretch()
        buttonLayout.addWidget(self.undoButton)
        buttonLayout.addWidget(self.redoButton)
        buttonLayout.addStretch()
        buttonLayout.addWidget(self.quitButton)
        return buttonLayout

    def createModel(self):
        model = QtGui.QStandardItemModel()
        model.setHorizontalHeaderLabels(['Titles', 'Summaries'])
        rootItem = model.invisibleRootItem()
        #First top-level row and children 
        item0 = [QtGui.QStandardItem('Title0'), QtGui.QStandardItem('Summary0')]
        item00 = [QtGui.QStandardItem('Title00'), QtGui.QStandardItem('Summary00')]
        item01 = [QtGui.QStandardItem('Title01'), QtGui.QStandardItem('Summary01')]
        rootItem.appendRow(item0)
        item0[0].appendRow(item00)
        item0[0].appendRow(item01)
        #Second top-level item and its children
        item1 = [QtGui.QStandardItem('Title1'), QtGui.QStandardItem('Summary1')]
        item10 = [QtGui.QStandardItem('Title10'), QtGui.QStandardItem('Summary10')]
        item11 = [QtGui.QStandardItem('Title11'), QtGui.QStandardItem('Summary11')]
        rootItem.appendRow(item1)
        item1[0].appendRow(item10)
        item1[0].appendRow(item11)

        return model


def main():
    app = QtGui.QApplication(sys.argv)
    newTree = UndoableTree()
    newTree.show()
    sys.exit(app.exec_())


if __name__ == "__main__":
    main()

问题的出现似乎是因为 blockSignals() 阻止了 treeview 被告知重新绘制。我认为这是因为当模型中的数据被修改时,模型会向树视图发出一个信号,当您调用 model.blockSignals(True) 时,这显然被阻止了。如果您在单击 undo/redo 后手动调整 window 的大小(显然只有在 undo/redo 有内容时才有效),您会看到 undo/redo 实际上已被应用,它只是没有'最初显示它。

为了解决这个问题,我修改了代码,而不是阻止信号,我们断开相关信号并重新连接它。这允许模型和树视图在 undo/redo 进行时继续正确通信。

看下面的代码

from PySide import QtGui, QtCore
import sys

class CommandItemEdit(QtGui.QUndoCommand):
    def __init__(self, connectSignals, disconnectSignals, model, item, textBeforeEdit, description = "Item edited"):
        QtGui.QUndoCommand.__init__(self, description)
        self.model = model
        self.item = item
        self.textBeforeEdit = textBeforeEdit
        self.textAfterEdit = item.text()
        self.connectSignals = connectSignals
        self.disconnectSignals = disconnectSignals

    def redo(self):
        self.disconnectSignals()
        self.item.setText(self.textAfterEdit)
        self.connectSignals()

    def undo(self):
        self.disconnectSignals()
        self.item.setText(self.textBeforeEdit)
        self.connectSignals()


class UndoableTree(QtGui.QWidget):
    def __init__(self, parent = None):
        QtGui.QWidget.__init__(self, parent = None)
        self.setAttribute(QtCore.Qt.WA_DeleteOnClose)
        self.view = QtGui.QTreeView()
        self.model = self.createModel()
        self.view.setModel(self.model)
        self.view.expandAll()
        self.undoStack = QtGui.QUndoStack(self)
        undoView = QtGui.QUndoView(self.undoStack)
        buttonLayout = self.buttonSetup()
        mainLayout = QtGui.QHBoxLayout(self)
        mainLayout.addWidget(undoView)
        mainLayout.addWidget(self.view)
        mainLayout.addLayout(buttonLayout)
        self.setLayout(mainLayout)
        self.makeConnections()
        #For undo/redo editing
        self.textBeforeEdit = ""

    def makeConnections(self):
        self.view.clicked.connect(self.itemClicked)
        self.model.itemChanged.connect(self.itemChanged)
        self.quitButton.clicked.connect(self.close)
        self.undoButton.clicked.connect(self.undoStack.undo)
        self.redoButton.clicked.connect(self.undoStack.redo)

    def disconnectSignal(self):    
        self.model.itemChanged.disconnect(self.itemChanged)

    def connectSignal(self):
        self.model.itemChanged.connect(self.itemChanged)

    def itemClicked(self, index):
        item = self.model.itemFromIndex(index)
        self.textBeforeEdit = item.text()  

    def itemChanged(self, item):
        command = CommandItemEdit(self.connectSignal, self.disconnectSignal, self.model, item, self.textBeforeEdit, 
            "Renamed '{0}' to '{1}'".format(self.textBeforeEdit, item.text()))
        self.undoStack.push(command)


    def buttonSetup(self):
        self.undoButton = QtGui.QPushButton("Undo")
        self.redoButton = QtGui.QPushButton("Redo")
        self.quitButton = QtGui.QPushButton("Quit")
        buttonLayout = QtGui.QVBoxLayout()
        buttonLayout.addStretch()
        buttonLayout.addWidget(self.undoButton)
        buttonLayout.addWidget(self.redoButton)
        buttonLayout.addStretch()
        buttonLayout.addWidget(self.quitButton)
        return buttonLayout

    def createModel(self):
        model = QtGui.QStandardItemModel()
        model.setHorizontalHeaderLabels(['Titles', 'Summaries'])
        rootItem = model.invisibleRootItem()
        #First top-level row and children 
        item0 = [QtGui.QStandardItem('Title0'), QtGui.QStandardItem('Summary0')]
        item00 = [QtGui.QStandardItem('Title00'), QtGui.QStandardItem('Summary00')]
        item01 = [QtGui.QStandardItem('Title01'), QtGui.QStandardItem('Summary01')]
        rootItem.appendRow(item0)
        item0[0].appendRow(item00)
        item0[0].appendRow(item01)
        #Second top-level item and its children
        item1 = [QtGui.QStandardItem('Title1'), QtGui.QStandardItem('Summary1')]
        item10 = [QtGui.QStandardItem('Title10'), QtGui.QStandardItem('Summary10')]
        item11 = [QtGui.QStandardItem('Title11'), QtGui.QStandardItem('Summary11')]
        rootItem.appendRow(item1)
        item1[0].appendRow(item10)
        item1[0].appendRow(item11)

        return model


def main():
    app = QtGui.QApplication(sys.argv)
    newTree = UndoableTree()
    newTree.show()
    sys.exit(app.exec_())


if __name__ == "__main__":
    main()

附加信息

我发现如果您在解锁信号后显式调用 self.model.layoutChanged.emit(),您可以使用 CommandItemEdit 的原始实现。这会强制更新树视图,而不会导致调用 UndoableTree.itemChanged() 槽。

注意,树视图连接到模型信号,而树视图又连接到 UndoableTree.itemChanged() 插槽。

我也尝试发出 dataChanged() 信号,但这最终会调用仍然连接的 UndoableTree.itemChanged() 插槽,从而导致无限递归。我认为这是信号是调用 model.blockSignals() 的目标,因此不显式调用它是有意义的!

所以最后,虽然这些额外的方法之一确实有效,但我仍然会采用我的第一个答案,即明确断开信号。这仅仅是因为我认为最好让模型和树视图之间的通信保持完整,而不是在手动触发您仍然想要的信号的同时限制某些通信。后一种方法可能会产生意想不到的副作用并且调试起来很麻烦。

一个密切相关的问题:

The clicked signal seems to be entirely the wrong way to track changes. How are you going to deal with changes made via the keyboard? And what about changes that are made programmatically? For an undo stack to work correctly, every change has to be recorded, and in exactly the same order that it was made.

同样的 post 继续建议创建一个自定义信号,在数据实际更改时发出 old/new 数据。最终,我使用了我无耻地从 SO 那里偷来的三个想法。首先,需要disconnect来避免无限递归。其次,子类 QStandardItemModel 以定义一个新的 itemDataChanged 信号,将以前的数据和新的数据发送到一个槽。第三,子类 QStandardItem 并让它在数据更改时发出此信号:这是在 setData()).

的重新实现中处理的

完整代码如下:

# -*- coding: utf-8 -*-

from PySide import QtGui, QtCore
import sys

class CommandTextEdit(QtGui.QUndoCommand):
    def __init__(self, tree, item, oldText, newText, description):
        QtGui.QUndoCommand.__init__(self, description)
        self.item = item
        self.tree = tree
        self.oldText = oldText
        self.newText = newText

    def redo(self):      
        self.item.model().itemDataChanged.disconnect(self.tree.itemDataChangedSlot) 
        self.item.setText(self.newText)
        self.item.model().itemDataChanged.connect(self.tree.itemDataChangedSlot) 

    def undo(self):
        self.item.model().itemDataChanged.disconnect(self.tree.itemDataChangedSlot) 
        self.item.setText(self.oldText)
        self.item.model().itemDataChanged.connect(self.tree.itemDataChangedSlot) 


class CommandCheckStateChange(QtGui.QUndoCommand):
    def __init__(self, tree, item, oldCheckState, newCheckState, description):
        QtGui.QUndoCommand.__init__(self, description)
        self.item = item
        self.tree = tree
        self.oldCheckState = QtCore.Qt.Unchecked if oldCheckState == 0 else QtCore.Qt.Checked
        self.newCheckState = QtCore.Qt.Checked if oldCheckState == 0 else QtCore.Qt.Unchecked

    def redo(self): #disoconnect to avoid recursive loop b/w signal-slot
        self.item.model().itemDataChanged.disconnect(self.tree.itemDataChangedSlot) 
        self.item.setCheckState(self.newCheckState)
        self.item.model().itemDataChanged.connect(self.tree.itemDataChangedSlot) 

    def undo(self):
        self.item.model().itemDataChanged.disconnect(self.tree.itemDataChangedSlot)
        self.item.setCheckState(self.oldCheckState)
        self.item.model().itemDataChanged.connect(self.tree.itemDataChangedSlot) 


class StandardItemModel(QtGui.QStandardItemModel):
    itemDataChanged = QtCore.Signal(object, object, object, object)


class StandardItem(QtGui.QStandardItem):
    def setData(self, newValue, role=QtCore.Qt.UserRole + 1):
        if role == QtCore.Qt.EditRole:
            oldValue = self.data(role)
            QtGui.QStandardItem.setData(self, newValue, role)
            model = self.model()
            #only emit signal if newvalue is different from old
            if model is not None and oldValue != newValue:
                model.itemDataChanged.emit(self, oldValue, newValue, role)
            return True
        if role == QtCore.Qt.CheckStateRole:
            oldValue = self.data(role)
            QtGui.QStandardItem.setData(self, newValue, role)
            model = self.model()
            if model is not None and oldValue != newValue:
                model.itemDataChanged.emit(self, oldValue, newValue, role)
            return True
        QtGui.QStandardItem.setData(self, newValue, role)


class UndoableTree(QtGui.QWidget):
    def __init__(self, parent = None):
        QtGui.QWidget.__init__(self, parent = None)
        self.setAttribute(QtCore.Qt.WA_DeleteOnClose)
        self.view = QtGui.QTreeView()
        self.model = self.createModel()
        self.view.setModel(self.model)
        self.view.expandAll()
        self.undoStack = QtGui.QUndoStack(self)
        undoView = QtGui.QUndoView(self.undoStack)
        buttonLayout = self.buttonSetup()
        mainLayout = QtGui.QHBoxLayout(self)
        mainLayout.addWidget(undoView)
        mainLayout.addWidget(self.view)
        mainLayout.addLayout(buttonLayout)
        self.setLayout(mainLayout)
        self.makeConnections()

    def makeConnections(self):
        self.model.itemDataChanged.connect(self.itemDataChangedSlot)
        self.quitButton.clicked.connect(self.close)
        self.undoButton.clicked.connect(self.undoStack.undo)
        self.redoButton.clicked.connect(self.undoStack.redo)

    def itemDataChangedSlot(self, item, oldValue, newValue, role):
        if role == QtCore.Qt.EditRole:
            command = CommandTextEdit(self, item, oldValue, newValue,
                "Text changed from '{0}' to '{1}'".format(oldValue, newValue))
            self.undoStack.push(command)
            return True
        if role == QtCore.Qt.CheckStateRole:
            command = CommandCheckStateChange(self, item, oldValue, newValue, 
                "CheckState changed from '{0}' to '{1}'".format(oldValue, newValue))
            self.undoStack.push(command)
            return True

    def buttonSetup(self):
        self.undoButton = QtGui.QPushButton("Undo")
        self.redoButton = QtGui.QPushButton("Redo")
        self.quitButton = QtGui.QPushButton("Quit")
        buttonLayout = QtGui.QVBoxLayout()
        buttonLayout.addStretch()
        buttonLayout.addWidget(self.undoButton)
        buttonLayout.addWidget(self.redoButton)
        buttonLayout.addStretch()
        buttonLayout.addWidget(self.quitButton)
        return buttonLayout

    def createModel(self):
        model = StandardItemModel()
        model.setHorizontalHeaderLabels(['Titles', 'Summaries'])
        rootItem = model.invisibleRootItem()
        item0 = [StandardItem('Title0'), StandardItem('Summary0')]
        item00 = [StandardItem('Title00'), StandardItem('Summary00')]
        item01 = [StandardItem('Title01'), StandardItem('Summary01')]
        item0[0].setCheckable(True)
        item00[0].setCheckable(True)
        item01[0].setCheckable(True)
        rootItem.appendRow(item0)
        item0[0].appendRow(item00)
        item0[0].appendRow(item01)
        return model


def main():
    app = QtGui.QApplication(sys.argv)
    newTree = UndoableTree()
    newTree.show()
    sys.exit(app.exec_())    

if __name__ == "__main__":
    main()

总的来说,这似乎比使用 clicked 更好。