Qt/PyQt/PySide: 重新实现QLineEdit子类的撤销框架时遇到问题

0 投票
1 回答
1634 浏览
提问于 2025-04-17 22:19

我创建了一个自定义的文本输入框,这样我就可以把撤销和重做的功能整合到我应用程序的整体撤销栈里,而不是使用QLineEdit自带的撤销/重做功能。撤销和重做的逻辑其实很简单:当文本框获得焦点时,它的内容会立刻被保存到一个变量(self.init_text)里;当文本框失去焦点时,如果文本内容和self.init_text里的内容不一样,就会创建一个新的QUndoCommand对象。调用undo()方法时,内容会被重置为self.init_text里的内容,而redo()方法则会把内容重置为失去焦点时的内容。(在这两个方法中,文本框会再次获得焦点,这样用户就能清楚地看到撤销或重做的效果。)

这个功能基本上是正常的,但有一个例外:如果用户通过QPushButton快速点击撤销或重做按钮,框架就会崩溃。我也说不清楚具体发生了什么,因为我不太了解Qt内部的工作原理,但比如说,QUndoStack的计数可能会完全改变。应用程序继续运行,没有在终端上报错,但撤销栈却是坏掉的。

我创建了一个小的QDialog应用程序,你可以试着重现这个问题。(使用Python 2.7.3/PySide 1.2.1……如果你安装了最近的PyQt绑定,除了前两个导入语句外,应该不需要替换其他东西。)比如,在第一个标签页的QLineEdit中,如果你输入“hello”,然后切换到其他地方,再点击回去输入“world”,然后再切换出去,试着快速点击撤销按钮(一直点击到撤销栈底部甚至更低)和重做按钮(一直点击到重做栈顶部甚至更高)。对我来说,这样就足够让它崩溃了。

#!/usr/bin/python
#coding=utf-8
from PySide.QtCore import *
from PySide.QtGui import *
import sys

class CustomRightClick(QObject):

    customRightClicked = Signal()

    def __init__(self, parent=None):
        QObject.__init__(self, parent)

    def eventFilter(self, obj, event):
        if event.type() == QEvent.ContextMenu:
            # emit signal so that your widgets can connect a slot to that signal
            self.customRightClicked.emit()
            return True
        else:
            # standard event processing
            return QObject.eventFilter(self, obj, event)

class CommandLineEdit(QUndoCommand):

    def __init__(self, line_edit, init_text, tab_widget, tab_index, description):
        QUndoCommand.__init__(self, description)
        self._line_edit = line_edit
        self._current_text = line_edit.text()
        self._init_text = init_text
        self._tab_widget = tab_widget
        self._tab_index = tab_index

    def undo(self):
        self._line_edit.setText(self._init_text)
        self._tab_widget.setCurrentIndex(self._tab_index)
        self._line_edit.setFocus(Qt.OtherFocusReason)

    def redo(self):
        self._line_edit.setText(self._current_text)
        self._tab_widget.setCurrentIndex(self._tab_index)
        self._line_edit.setFocus(Qt.OtherFocusReason)

class CustomLineEdit(QLineEdit):

    def __init__(self, parent, tab_widget, tab_index):
        super(CustomLineEdit, self).__init__(parent)
        self.parent = parent
        self.tab_widget = tab_widget
        self.tab_index = tab_index
        self.init_text = self.text()
        self.setContextMenuPolicy(Qt.CustomContextMenu)

        undoAction=QAction("Undo", self)
        undoAction.triggered.connect(self.parent.undo_stack.undo)

        self.customContextMenu = QMenu()
        self.customContextMenu.addAction(undoAction)

        custom_clicker = CustomRightClick(self)
        self.installEventFilter(custom_clicker)
        self.right_clicked = False
        custom_clicker.customRightClicked.connect(self.menuShow)

    def menuShow(self):
        self.right_clicked = True   # set self.right_clicked to True so that the focusOutEvent won't push anything to the undo stack as a consequence of right-clicking
        self.customContextMenu.popup(QCursor.pos())
        self.right_clicked = False

    # re-implement focusInEvent() so that it captures as an instance variable the current value of the text *at the time of the focusInEvent(). This will be utilized for the undo stack command push below
    def focusInEvent(self, event):
        self.init_text = self.text()
        QLineEdit.focusInEvent(self, event)

    # re-implement focusOutEvent() so that it pushes the current text to the undo stack.... but only if there was a change!
    def focusOutEvent(self, event):
        if self.text() != self.init_text and not self.right_clicked:
            print "Focus out event. (self.text is %s and init_text is %s). Pushing onto undo stack. (Event reason is %s)" % (self.text(), self.init_text, event.reason())
            command = CommandLineEdit(self, self.init_text, self.tab_widget, self.tab_index, "editing a text box")
            self.parent.undo_stack.push(command)
        QLineEdit.focusOutEvent(self, event)

    def keyPressEvent(self, event):
        if event.key() == Qt.Key_Z:
            if event.modifiers() & Qt.ControlModifier:
                self.parent.undo_stack.undo()
            else:
                QLineEdit.keyPressEvent(self, event)
        elif event.key() == Qt.Key_Y:
            if event.modifiers() & Qt.ControlModifier:
                self.parent.undo_stack.redo()
            else:
                QLineEdit.keyPressEvent(self, event)
        else:
            QLineEdit.keyPressEvent(self, event)

class Form(QDialog):

    def __init__(self, parent=None):
        super(Form, self).__init__(parent)

        self.undo_stack = QUndoStack()

        self.tab_widget = QTabWidget()

        self.line_edit1 = CustomLineEdit(self, self.tab_widget, 0)
        self.line_edit2 = CustomLineEdit(self, self.tab_widget, 1)
        self.undo_counter = QLineEdit()

        tab1widget = QWidget()
        tab1layout = QHBoxLayout()
        tab1layout.addWidget(self.line_edit1)
        tab1widget.setLayout(tab1layout)

        tab2widget = QWidget()
        tab2layout = QHBoxLayout()
        tab2layout.addWidget(self.line_edit2)
        tab2widget.setLayout(tab2layout)

        self.tab_widget.addTab(tab1widget, "Tab 1")
        self.tab_widget.addTab(tab2widget, "Tab 2")

        self.undo_button = QPushButton("Undo")
        self.redo_button = QPushButton("Redo")
        layout = QGridLayout()
        layout.addWidget(self.tab_widget, 0, 0, 1, 2)
        layout.addWidget(self.undo_button, 1, 0)
        layout.addWidget(self.redo_button, 1, 1)
        layout.addWidget(QLabel("Undo Stack Counter"), 2, 0)
        layout.addWidget(self.undo_counter)
        self.setLayout(layout)

        self.undo_button.clicked.connect(self.undo_stack.undo)
        self.redo_button.clicked.connect(self.undo_stack.redo)
        self.undo_stack.indexChanged.connect(self.changeUndoCount)

    def changeUndoCount(self, index):
        self.undo_counter.setText("%s / %s" % (index, self.undo_stack.count()))

app = QApplication(sys.argv)
form = Form()
form.show()
app.exec_()

这是Qt的bug吗?还是PySide的bug?或者是我重新实现时出了问题?任何帮助都很感激!

(我在检查代码时突然想到,我可以重新实现contextMenuEvent,而不是安装事件过滤器,但我想这和问题无关。)

1 个回答

2

问题出在你在撤销(undo)和重做(redo)操作时设置了 QLineEdit 的焦点。文档中提到,当命令被推送到 QUndoStack 时,会调用 redo,所以一旦你在点击撤销时移除了 QLineEdit 的焦点,焦点会立刻被自动恢复,这是因为调用了 redo。接下来,undo 命令会运行(这是因为你刚才提到的按钮点击)。由于这个控件已经有了焦点,当从 undo 调用 _line_edit.setFocus() 时,focusInEvent 方法 不会被执行,所以 _line_edit.init_text 没有得到正确的更新。这就意味着,当你点击重做按钮时,文本框失去了焦点,并且因为 focusOutEvent 中的 if 语句比较失败(因为 init_text 存储了错误的值),一个新的命令被排队了。然后,一个新的命令被添加到撤销栈中,这会覆盖你想要恢复的那个命令!

这样说清楚了吗?

一个简单的解决办法是在 CommandLineEdit 的撤销/重做方法中,在设置 _line_edit 的文本后,添加以下一行代码。

def undo(self):
    self._line_edit.setText(self._init_text)
    self._line_edit.init_text = self._line_edit.text()
    self._tab_widget.setCurrentIndex(self._tab_index)
    self._line_edit.setFocus(Qt.OtherFocusReason)

def redo(self):
    self._line_edit.setText(self._current_text)
    self._line_edit.init_text = self._line_edit.text()
    self._tab_widget.setCurrentIndex(self._tab_index)
    self._line_edit.setFocus(Qt.OtherFocusReason)

这样你就可以去掉对 focusInEvent 的重新实现了。

一旦你理解了这个问题,可能值得从头开始设计你的撤销框架,而不是尝试实现我这个“临时”解决方案,因为可能有更干净的方式来修复它!

撰写回答