python-3.xpyqt5qcomboboxundo-redo

How can I implement undo-redo for a QComboBox in PyQt5?


There are many questions regarding the basics of QUndoCommand on StackOverflow, but I cannot find a minimal working example of undo/redo for a QComboBox in PyQt5, so I am trying to create one. However, I am stuck with segmentation fault errors with the code below.


Questions:


Goal:


To reproduce an error (using code below)


import sys
from PyQt5 import QtWidgets, QtCore

class MyUndoCommand(QtWidgets.QUndoCommand):
    def __init__(self, combobox, ind0, ind1):
        super().__init__()
        self.combobox = combobox
        self.ind0     = ind0
        self.ind1     = ind1

    def redo(self):
        self.combobox.setCurrentIndex( self.ind1 )

    def undo(self):
        self.combobox.setCurrentIndex( self.ind0 )


class MyComboBox(QtWidgets.QComboBox):
    def __init__(self, *args):
        super().__init__(*args)
        self.addItems( ['a', 'b', 'c'] )
        self.ind0  = 0
        self.undostack = QtWidgets.QUndoStack()
        self.currentIndexChanged.connect( self.on_index_changed )
    
    def keyPressEvent(self, e):
        z         = e.key() == QtCore.Qt.Key_Z
        ctrl      = e.modifiers() == QtCore.Qt.ControlModifier
        ctrlshift = e.modifiers() == QtCore.Qt.ControlModifier | QtCore.Qt.ShiftModifier
        if ctrl and z:
            if self.undostack.canUndo():
                self.undostack.undo()
        if ctrlshift and z:
            if self.undostack.canRedo():
                self.undostack.redo()

    def on_index_changed(self, ind):
        cmd = MyUndoCommand(self, self.ind0, ind)
        self.undostack.push( cmd )
        self.ind0  = ind



if __name__ == '__main__':
    app = QtWidgets.QApplication( sys.argv )
    widget = MyComboBox()
    widget.show()
    sys.exit(app.exec_())

Solution

  • The problem is that you connected the currentIndexChanged signal to a function that creates an undo command no matter what, and since MyUndoCommand does change the current index, the result is that you get a recursive call for it.

    A possible solution is to create a flag that is checked whenever the index is changed and does not create a further undo command whenever that index change is triggered by another undo/redo.

    class MyComboBox(QtWidgets.QComboBox):
        undoActive = False
        def __init__(self, *args):
            super().__init__(*args)
            self.addItems(['a', 'b', 'c'])
            self.ind0 = 0
            self.undostack = QtWidgets.QUndoStack()
            self.currentIndexChanged.connect(self.on_index_changed)
        
        def keyPressEvent(self, e):
            if e.key() == QtCore.Qt.Key_Z:
                ctrl = e.modifiers() == QtCore.Qt.ControlModifier
                ctrlshift = e.modifiers() == QtCore.Qt.ControlModifier | QtCore.Qt.ShiftModifier
                if ctrl and self.undostack.canUndo():
                    self.undoActive = True
                    self.undostack.undo()
                    self.undoActive = False
                    return
                elif ctrlshift and self.undostack.canRedo():
                    self.undoActive = True
                    self.undostack.redo()
                    self.undoActive = False
                    return
            super().keyPressEvent(e)
    
        def on_index_changed(self, ind):
            if not self.undoActive:
                cmd = MyUndoCommand(self, self.ind0, ind)
                self.undostack.push( cmd )
            self.undoActive = False
            self.ind0 = ind
    

    Note that I've changed the keyPressEvent handler in order to ensure that unhandled key events get processed, which is important for keyboard navigation and item selection.