I have created a custom line edit widget so that I can incorporate undo/redo commands on it into my application's general undo stack (rather than use the built-in undo/redo facilities that come with QLineEdit widgets). The undo/redo logic is fairly straightforward: when the line edit widget receives focus, its content is immediately assigned to an instance variable (self.init_text); and when the line edit widget loses focus, if the text content differs from that stored in self.init_text, then a new QUndoCommand object is created. The undo() method will re-set the content to whatever is in self.init_text, while the redo() method will re-set the content to whatever was captured when the line edit widget lost focus. (In either method, the line edit will again receive focus so that it will be obvious to the user what the undo or redo command actually affected.)

It seems to work just fine with one exception: if the user very quickly cycles through undo or redo commands through the QPushButtons, then the framework just breaks. I can't describe it much better than that because I'm not sure what's going on under the Qt hood, but, for example, the count() of the QUndoStack may be changed completely. The app continues to run with no errors reported on the terminal, but it is nonetheless a broken undo stack.

I have created a little QDialog app so you can try to re-create the issue. (Using Python 2.7.3/PySide 1.2.1 ... if you have a recent PyQt binding installed, I don't think you should need to replace anything except the first two import statements.) For example, in the first tab's QLineEdit, if you type 'hello', then tab out, then click back in and type 'world', then tab out again, try very swiftly clicking the undo button (down to and beyond the bottom of the undo stack) and redo button (up to and beyond the top of the undo stack). For me, that was enough to break it.

Qt Code:
  1. #!/usr/bin/python
  2. #coding=utf-8
  3. from PySide.QtCore import *
  4. from PySide.QtGui import *
  5. import sys
  6.  
  7. class CustomRightClick(QObject):
  8.  
  9. customRightClicked = Signal()
  10.  
  11. def __init__(self, parent=None):
  12. QObject.__init__(self, parent)
  13.  
  14. def eventFilter(self, obj, event):
  15. if event.type() == QEvent.ContextMenu:
  16. # emit signal so that your widgets can connect a slot to that signal
  17. self.customRightClicked.emit()
  18. return True
  19. else:
  20. # standard event processing
  21. return QObject.eventFilter(self, obj, event)
  22.  
  23. class CommandLineEdit(QUndoCommand):
  24.  
  25. def __init__(self, line_edit, init_text, tab_widget, tab_index, description):
  26. QUndoCommand.__init__(self, description)
  27. self._line_edit = line_edit
  28. self._current_text = line_edit.text()
  29. self._init_text = init_text
  30. self._tab_widget = tab_widget
  31. self._tab_index = tab_index
  32.  
  33. def undo(self):
  34. self._line_edit.setText(self._init_text)
  35. self._tab_widget.setCurrentIndex(self._tab_index)
  36. self._line_edit.setFocus(Qt.OtherFocusReason)
  37.  
  38. def redo(self):
  39. self._line_edit.setText(self._current_text)
  40. self._tab_widget.setCurrentIndex(self._tab_index)
  41. self._line_edit.setFocus(Qt.OtherFocusReason)
  42.  
  43. class CustomLineEdit(QLineEdit):
  44.  
  45. def __init__(self, parent, tab_widget, tab_index):
  46. super(CustomLineEdit, self).__init__(parent)
  47. self.parent = parent
  48. self.tab_widget = tab_widget
  49. self.tab_index = tab_index
  50. self.init_text = self.text()
  51. self.setContextMenuPolicy(Qt.CustomContextMenu)
  52.  
  53. undoAction=QAction("Undo", self)
  54. undoAction.triggered.connect(self.parent.undo_stack.undo)
  55.  
  56. self.customContextMenu = QMenu()
  57. self.customContextMenu.addAction(undoAction)
  58.  
  59. custom_clicker = CustomRightClick(self)
  60. self.installEventFilter(custom_clicker)
  61. self.right_clicked = False
  62. custom_clicker.customRightClicked.connect(self.menuShow)
  63.  
  64. def menuShow(self):
  65. 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
  66. self.customContextMenu.popup(QCursor.pos())
  67. self.right_clicked = False
  68.  
  69. # 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
  70. def focusInEvent(self, event):
  71. self.init_text = self.text()
  72. QLineEdit.focusInEvent(self, event)
  73.  
  74. # re-implement focusOutEvent() so that it pushes the current text to the undo stack.... but only if there was a change!
  75. def focusOutEvent(self, event):
  76. if self.text() != self.init_text and not self.right_clicked:
  77. 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())
  78. command = CommandLineEdit(self, self.init_text, self.tab_widget, self.tab_index, "editing a text box")
  79. self.parent.undo_stack.push(command)
  80. QLineEdit.focusOutEvent(self, event)
  81.  
  82. def keyPressEvent(self, event):
  83. if event.key() == Qt.Key_Z:
  84. if event.modifiers() & Qt.ControlModifier:
  85. self.parent.undo_stack.undo()
  86. else:
  87. QLineEdit.keyPressEvent(self, event)
  88. elif event.key() == Qt.Key_Y:
  89. if event.modifiers() & Qt.ControlModifier:
  90. self.parent.undo_stack.redo()
  91. else:
  92. QLineEdit.keyPressEvent(self, event)
  93. else:
  94. QLineEdit.keyPressEvent(self, event)
  95.  
  96. class Form(QDialog):
  97.  
  98. def __init__(self, parent=None):
  99. super(Form, self).__init__(parent)
  100.  
  101. self.undo_stack = QUndoStack()
  102.  
  103. self.tab_widget = QTabWidget()
  104.  
  105. self.line_edit1 = CustomLineEdit(self, self.tab_widget, 0)
  106. self.line_edit2 = CustomLineEdit(self, self.tab_widget, 1)
  107. self.undo_counter = QLineEdit()
  108.  
  109. tab1widget = QWidget()
  110. tab1layout = QHBoxLayout()
  111. tab1layout.addWidget(self.line_edit1)
  112. tab1widget.setLayout(tab1layout)
  113.  
  114. tab2widget = QWidget()
  115. tab2layout = QHBoxLayout()
  116. tab2layout.addWidget(self.line_edit2)
  117. tab2widget.setLayout(tab2layout)
  118.  
  119. self.tab_widget.addTab(tab1widget, "Tab 1")
  120. self.tab_widget.addTab(tab2widget, "Tab 2")
  121.  
  122. self.undo_button = QPushButton("Undo")
  123. self.redo_button = QPushButton("Redo")
  124. layout = QGridLayout()
  125. layout.addWidget(self.tab_widget, 0, 0, 1, 2)
  126. layout.addWidget(self.undo_button, 1, 0)
  127. layout.addWidget(self.redo_button, 1, 1)
  128. layout.addWidget(QLabel("Undo Stack Counter"), 2, 0)
  129. layout.addWidget(self.undo_counter)
  130. self.setLayout(layout)
  131.  
  132. self.undo_button.clicked.connect(self.undo_stack.undo)
  133. self.redo_button.clicked.connect(self.undo_stack.redo)
  134. self.undo_stack.indexChanged.connect(self.changeUndoCount)
  135.  
  136. def changeUndoCount(self, index):
  137. self.undo_counter.setText("%s / %s" % (index, self.undo_stack.count()))
  138.  
  139. app = QApplication(sys.argv)
  140. form = Form()
  141. form.show()
  142. app.exec_()
To copy to clipboard, switch view to plain text mode 

Is this a Qt bug? A PySide bug? Or is there a problem in my re-implementation? Any help is appreciated!