- A Practical Introduction to PyQts Undo/Redo Framework
- Adding Undo/Redo to the Mix
- Using the Undo/Redo Framework
- Summary
Using the Undo/Redo Framework
One way of providing undo/redo would be to record all the relevant states whenever an action is invoked, and keep a stack of states. Doing this manually would involve a lot of work and a lot of code duplication, since you’d have to do it for each dialog box and main window for which you wanted to provide undo/redo facilities. Fortunately, PyQt has a ready-made solution: the Undo/Redo framework. This section briefly reviews the framework and sketches out what it does. Then we’ll examine "before and after" code snippets to see where the methods called by actions must change to accommodate the undo/redo capability.
The framework follows the Command design pattern. Each action leads to a command object (a QUndoCommand) being created. These objects have two interesting methods: redo() and undo(). Once a QUndoCommand has been created, you can call its redo() method to apply the action. At any later point, the command’s undo() method can be called to undo the action. On its own, this is sufficient for only one level of undo/redo, but using it in conjunction with the QUndoStack class enables you to have unlimited undo/redo levels—or up to as many as you specify by using QUndoStack.setUndoLimit(). If every time you create a command you add it to an undo stack, you can leave all the undo/redo handling to the undo stack, greatly simplifying your code.
Let’s look at the code, starting with the changes that must be made to the dialog box’s __init__() method:
self.undoStack = QUndoStack(self)
Add this line to create an undo stack. Although it’s an instance variable, you still must give it a parent (the dialog box), so that PyQt is able to clean it up at the right time when the dialog box is destroyed. In addition, you must create two buttons, Undo and Redo, and connect their clicked() signals to the appropriate undo stack methods, self.undoStack.undo() and self.undoStack.redo().
The remaining changes that must be made are of two kinds:
- Create QUndoCommand subclasses, one for each action you want the user to be able to do (and undo).
- Modify the methods invoked as a result of user interaction, so that the methods use the undo/redo framework rather than perform the actions directly themselves.
Let’s start by looking at probably the easiest method, add(), which is invoked when the user clicks the Add button. Here’s the original code (no undo/redo):
def add(self): row = self.listWidget.currentRow() title = "Add %s" % self.name string, ok = QInputDialog.getText(self, title, "&Add:") if ok and not string.isEmpty(): self.listWidget.insertItem(row, string)
The strings are held in a QListWidget, with self.listWidget being the instance used in the program. The self.name variable holds the list’s name (Movies in this example). If the user enters a string in the input dialog and clicks OK, the program inserts the new string directly into the list widget. Here’s a version that supports undo/redo:
def add(self): row = self.listWidget.currentRow() title = "Add %s" % self.name string, ok = QInputDialog.getText(self, title, "&Add:") if ok and not string.isEmpty(): command = CommandAdd(self.listWidget, row, string, "Add (%s)" % string) self.undoStack.push(command)
The code is almost identical to the earlier version. The difference is that instead of performing the action directly, I create an instance of a custom QUndoCommand subclass (a CommandAdd instance in this case), and pass to it all the relevant details. As soon as the command is created, I add it to the undo stack—but notice that I don’t need to call the command’s redo() method to perform the action. That’s because whenever a command is pushed onto the undo stack, the undo stack automatically calls the command’s redo() method.
Notice also that I’ve provided a textual description of the action. This is a courtesy to users and might appear in a tool tip for a Redo menu option or toolbar button.
Here’s the CommandAdd class:
class CommandAdd(QUndoCommand): def __init__(self, listWidget, row, string, description): super(CommandAdd, self).__init__(description) self.listWidget = listWidget self.row = row self.string = string def redo(self): self.listWidget.insertItem(self.row, self.string) self.listWidget.setCurrentRow(self.row) def undo(self): item = self.listWidget.takeItem(self.row) del item
In the __init__() method, I pass on the description to the base class and record the information needed to perform the action. The action is performed in the redo() method. In this example, it inserts the given string at the given row. As an improvement on previous functionality, it also makes the newly inserted item’s row current. The action is undone in the undo() method. In this example, it removes the string (actually, the QListWidgetItem that contains the string) from the list widget and deletes it.
What happens if many actions take place and the row has changed between the initial redo action and a subsequent undo? If you’re using an undo stack, you don’t have to worry. Suppose a string is added and then lots of other actions occur. To undo adding that particular string, the user has to undo all those subsequent actions; so, at the point of undoing the new string, the row will be correct.
Let’s see how strings are deleted (and undeleted), starting again with the original code:
def delete(self): row = self.listWidget.currentRow() item = self.listWidget.item(row) if item is None: return reply = QMessageBox.question(self, "Remove %s" % self.name, "Remove %s ´%s’?" % ( self.name, unicode(item.text())), QMessageBox.Yes|QMessageBox.No) if reply == QMessageBox.Yes: item = self.listWidget.takeItem(row) del item
The deletion takes place in the last two lines, so only those two lines must be changed. Here are the replacement lines:
command = CommandDelete(self.listWidget, item, row, "Delete (%s)" % item.text()) self.undoStack.push(command)
Just as when adding, I create an instance of a custom QUndoCommand and push it onto the undo stack. As always, the undo stack calls the command’s redo() command to perform the action in the first place.
Here’s the custom CommandDelete class:
class CommandDelete(QUndoCommand): def __init__(self, listWidget, item, row, description): super(CommandDelete, self).__init__(description) self.listWidget = listWidget self.string = item.text() self.row = row def redo(self): item = self.listWidget.takeItem(self.row) del item def undo(self): self.listWidget.insertItem(self.row, self.string)
Here, the redo command takes the string out of the list widget, and the undo command puts it back in.
The other commands, Edit, Up, and Down, follow the same pattern, so I won’t reproduce them here. But the Sort command is different. If you provide undo/redo for sorting and the list contains hundreds or thousands of strings, you might end up consuming a lot of memory. Nonetheless, it’s perfectly possible to do it: Just create a CommandSort class and store the original list in it, following the same pattern as you used for the Add and Delete actions. For this dialog box, I won’t offer undo/redo if the user chooses to sort; therefore, I must warn the user so that he can back out. Here’s the code for the dialog box’s sort() method:
def sort(self): message = ("Sorting will clear the undo/redo stack.\n" "Sort anyway?") if (not self.undoStack.count() or QMessageBox.question(self, "Sort", message, QMessageBox.Yes|QMessageBox.No) == QMessageBox.Yes): self.undoStack.clear() self.listWidget.sortItems()
This method performs the sort if the undo stack is empty or if the user wants to sort even after being told that undo/redo will not be available afterward. Clearly, I could use the same approach with any other non-undoable actions.
To this point, we’ve focused on converting a dialog box that doesn’t provide undo/redo into one that does. This change has required more code (for example, creating the QUndoCommand subclasses and the undo code), but has paid back in terms of improved usability. Having undo/redo in place provides an additional benefit: Because all actions are executed indirectly by calling a QUndoCommand’s redo() method, I could chain two or more actions together simply by keeping track of them. Therefore, I can provide a macro facility. In fact, this functionality is built into the QUndoStack class, so I can just use it.
For actions that simply redo or undo, no changes are necessary to use the macro facility; for actions that clear the undo stack, however, I must make some minor modifications. Figure 4 shows a string list editor that has undo/redo, macro recording (the Record button’s text changes to Stop when recording), and the undo stack.
Figure 4 String list editor dialog box with undo/redo and macros.
To get a visible undo stack, I just need to create and lay out an undo view with the undo stack as its parent in the dialog box’s __init__() method:
self.undoView = QUndoView(self.undoStack)
I’ve omitted the layout code since that’s standard; in this case, I used a splitter. It’s unusual to provide a visible undo stack, particularly in a dialog box like this, but it’s useful for testing when starting to use the undo/redo framework for the first time. (A more realistic use case would be to put the undo view into a dock window in a main window.)
To accommodate macros, I’ve added two new instance variables, again created in the __init__() method:
self.macroNum = 1 self.recording = False
In addition, I’ve connected the Record button to the following method:
def startOrStopMacro(self): if not self.recording: for button in self.undoRedoButtons: button.setEnabled(False) self.recordButton.setText("St&op") self.recordButton.setIcon(QIcon("stop.png")) self.undoStack.beginMacro("Macro #%d" % self.macroNum) self.macroNum += 1 else: for button in self.undoRedoButtons: button.setEnabled(True) self.recordButton.setText("Rec&ord") self.recordButton.setIcon(QIcon("record.png")) self.undoStack.endMacro() self.recording = not self.recording
When a macro is being recorded, the user interacts with the dialog box as usual. Instead of each individual command being pushed onto the undo stack, however, a macro containing all the commands is pushed on when macro recording stops. During recording, the user cannot undo or redo, so I disable/enable the Undo and Redo buttons. If the user makes a mistake, he can simply click Stop to stop recording and Undo to undo the recorded actions. None of the methods that does straightforward undo/redo handling needs to be changed to accommodate macros, but those methods that clear the undo stack must be modified. In this example, I must change the sort() method slightly:
def sort(self): message = "Sorting will clear the undo/redo stack" if self.recording: message += "\nand will clear the macro being recorded" message += ".\nSort anyway?" if (not self.undoStack.count() or QMessageBox.question(self, "Sort", message, QMessageBox.Yes|QMessageBox.No) == QMessageBox.Yes): if self.recording: self.startOrStopMacro() self.undoStack.clear() self.listWidget.sortItems()
There are just two differences from the earlier version:
- I notify the user that sorting not only will clear the undo/redo stack, but also clear the macro being recorded (if the user is recording one).
- If the user sorts while a macro is being recorded, I must call self.startOrStopMacro() to stop the recording. This sets the Record button’s text back to Record, clears the undo stack, and performs the sort.
In dialog boxes, only one undo stack is likely to be needed, but in main window applications two or more might be required. These can be accommodated easily by using a QUndoGroup, to which any number of QUndoStacks can be added.