为什么QUndoStack.push()会执行QUndoCommand.redo()?

4
我创建了一个自定义的QPushButton,可以从菜单或QColorDialog中选择颜色。由于它是“主题”编辑器的一部分,我还添加了对QUndoStack的支持:每次更改自定义按钮的颜色时,它都会创建QUndoCommand的子类,并将其推送到QUndoStack。
然后我意识到(根据this),每次执行QUndoStack.push(cmd)时,该命令都会被执行(这显然会导致递归,由PyQt自动“忽略”,但仍在stdout中报告)。
当调用redo()时,我通过阻止目标小部件上的信号来解决问题,但问题仍然存在:为什么推送的命令首先要执行?
在我看来,如果我将一个命令推送到撤消堆栈,则它已经被执行。
哪种情况需要(理论上已经)执行[subclassed] QUndoCommand的“再次”执行?
这种情况是否很常见,需要这种实现作为默认行为?
以下是一个最小化且不完整的示例(但足以显示问题),它不支持撤销/重做操作的信号处理,但这不是重点(我想?)。据我所理解,当调用QUndoCommand创建的信号与创建信号本身的槽重合时,问题就出现了:
#!/usr/bin/env python2

import sys
from PyQt5 import QtCore, QtGui, QtWidgets

class UndoCmd(QtWidgets.QUndoCommand):
    def __init__(self, widget, newColor, oldColor):
        QtWidgets.QUndoCommand.__init__(self)
        self.widget = widget
        self.newColor = newColor
        self.oldColor = oldColor

    def redo(self):
        self.widget.color = self.newColor


class ColorButton(QtWidgets.QPushButton):
    colorChanged = QtCore.pyqtSignal(QtGui.QColor)
    def __init__(self, parent=None):
        QtWidgets.QPushButton.__init__(self, 'colorButton', parent)
        self.oldColor = self._color = self.palette().color(self.palette().Button)
        self.clicked.connect(self.changeColor)

    @QtCore.pyqtProperty(QtGui.QColor)
    def color(self):
        return self._color

    @color.setter
    def color(self, color):
        self._color = color
        palette = self.palette()
        palette.setColor(palette.Button, color)
        self.setPalette(palette)
        self.colorChanged.emit(color)

    def changeColor(self):
        dialog = QtWidgets.QColorDialog()
        if dialog.exec_():
            self.color = dialog.selectedColor()

class Window(QtWidgets.QWidget):
    def __init__(self):
        QtWidgets.QWidget.__init__(self)
        layout = QtWidgets.QHBoxLayout()
        self.setLayout(layout)
        self.colorButton = ColorButton()
        layout.addWidget(self.colorButton)
        self.undoStack = QtWidgets.QUndoStack()
        self.colorButton.colorChanged.connect(lambda: self.colorChanged(self.colorButton.oldColor))

    def colorChanged(self, oldColor):
        self.undoStack.push(UndoCmd(self.colorButton, oldColor, self.colorButton._color))


app = QtWidgets.QApplication(sys.argv)
w = Window()
w.show()
sys.exit(app.exec_())

理解和解决这种类型的问题的最佳方法是提供一个 [mcve] :D - eyllanesc
2
根据示例: "撤销命令类知道如何重做(或仅在第一次执行时执行)和撤消操作。",即重做等同于“执行”。说实话,当我第一次使用撤销框架(几年前)时,我也感到惊讶,但事实就是这样。所以,回答你的问题 - 因为它是这样设计的。我认为这与特定的设计模式有关。看看命令模式 - scopchanov
@eyllanesc:我更新了答案,并提供了一个尽可能简洁的示例,谢谢。 @scopchanov 我能理解这一点,但我的问题并不是关于“如何修复”(我可以很容易地理解它),而是关于“为什么”。我知道调用QUndoCommand的信号不应该(可以?)与命令调用的槽相同,但我仍然不明白在push之后执行命令的必要性。 - musicamante
2
从我的角度来看,如果我将一个命令推送到撤销堆栈中,那么它已经被执行了。我已经检查了我的代码和示例,以确保我记得正确。在这两个地方,我都看到新的命令正在被推送,例如 undoStack->push(new MoveCommand(movedItem, oldPosition));。因此,该命令旨在通过被推送到撤销堆栈中来执行,而不是事先执行,因此需要在推送后执行该命令。 - scopchanov
1
@scopchanov 好的,我明白了。感谢你提供的视频。之前我使用QUndoStack时情况比较简单(spinbox变化),并不太关心推送后的执行情况,所以我完全忘记了这一点。现在我能理解这个模式,我只是以前没有从这个角度考虑过它。 - musicamante
显示剩余2条评论
2个回答

7
这在概述文档中有所描述:
Qt的撤销框架是命令模式的实现,用于在应用程序中实现撤销/重做功能。
命令模式基于一个想法:在应用程序中所有编辑都通过创建命令对象的实例来完成。命令对象对文档进行更改并存储在命令堆栈上。此外,每个命令都知道如何撤消其更改以将文档恢复到其先前的状态。只要应用程序仅使用命令对象来更改文档的状态,就可以通过向下遍历堆栈并依次调用每个命令的撤消来撤消一系列命令。也可以通过向上遍历堆栈并依次调用每个命令的重做来重做一系列命令。
要使用撤消框架,您应确保任何应该可撤销的操作仅由QUndoCommand子类执行。
因此,我假设您正在执行两次操作:一次直接执行,然后作为结果,通过撤消框架再次执行一次。相反,您应该只通过撤消框架执行操作。
推送操作被立即重做的原因可能很简单:redo()函数已经方便地执行了操作,为什么还要添加另一个函数来执行相同的操作呢?

“问题”在于小部件本身执行更改颜色的操作,但我实际上以一种更简单(并且有些错误)的方式考虑它。现在我对这个概念有了更好的理解,这很有意义。谢谢。 - musicamante
我曾经也有同样的困惑。我认为,为了使其有任何意义,您必须拥有一个与 UI 分离的模型/数据库/状态变量/文档。每当在 UI 中进行更改时,都会生成一个命令来更新状态。然后,每当状态发生更改(例如使用撤消操作),UI 都需要与当前应用程序状态同步。我认为这有点像 Qt 和其他 UI 库使用的 Model-View 等内容,以将 UI 与数据分离。至少这给了命令一些事情可做,并将您的状态存储在 UI 本身之外的地方。 - flutefreak7

6
redo()do()是同义词,不要被"re"这个部分所迷惑。API有两个关键函数,一个应用命令,另一个撤销命令。
同时,措辞的选择也存在一些次优问题,因为undo stack实际上应该被称为command stack
因此,逻辑上只有在命令被添加到command stack时才会apply命令。在此之前不应手动应用命令。

1
是的。让我困惑的是,我想象得更简单一些,比如文本编辑器,你最好在用户输入后已经更改了文本之后创建撤销操作,并且该更改不是由推送的撤销命令执行的。 - musicamante

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接