Undo/Redo Stacks
The BASeBlock Editor has had an Undo/Redo stack for quite a while. The implementation of this logic is relatively simple, but the implementation- or rather, my recent re-implementation- seems like a good subject for a blog post.
Basically, an Undo/Redo Stack Let’s your Program reverse through changes that have been made, and then, if necessary, go forward through those changes. What makes this problem interesting is that, by and large, the core logic is really quite similar. I will attempt to outline the process of creating a Generic Undo/Redo Object that can be used in many contexts.
First, we’ll start with The abstract base class that we will use to represent Undo Elements:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
public abstract class UndoRedoStackItem { protected DateTime _ItemTime; protected String _Description; public DateTime ItemTime { get { return _ItemTime; }} public String Description { get { return _Description; } } /// <summary> /// Constructs this object, giving it the appropriate Description. /// </summary> /// <param name="Description"/>Description for the constructed instance protected UndoRedoStackItem(String Description) { _Description = Description; _ItemTime = DateTime.Now; } /// <summary> /// Copy constructor. This should be implemented by derived classes. /// </summary> /// <param name="copythis"/>Item to copy. protected UndoRedoStackItem(UndoRedoStackItem copythis) { _ItemTime = copythis.ItemTime; _Description = copythis.Description; _ItemTime = copythis.ItemTime; } /// <summary> /// Creates a Deep Clone of this Object. /// </summary> /// <returns>A new instance of the same type, with appropriately deep-copied instances for all it's instance data.</returns> public abstract UndoRedoStackItem DeepClone(); } |
Since we don’t know what, specifically, is being handled, we use this abstract class and provide only very basic methods. The DateTime can be used on appropriate menus or as a sort key, and the Description can be used for any menu text, or other UI elements as needed by the app consuming the class.
This will be used for a generic class, which I will call UndoRedoManager. This will, unsurprisingly, manage the undo and redo operations, or, more specifically, it will manage the data structures dealing with the Undo and Redo information. In order to actually undo, we will need to delegate the task to the caller, on the assumption that they know more about the StackItem. We will call the Events HandleRedo and HandleUndo, and they will take a simple EventArgs. First, we will define the EventArgs class definition.
1 2 3 4 5 6 7 8 9 10 11 |
class UndoRedoEventArgs<T>:EventArgs where T:UndoRedoStackItem { public T StackElement; public Boolean Cancel = false; public UndoRedoEventArgs(T stackelement) { StackElement = stackelement; } } |
The Event Argument class is relatively simple; we just have a field for the StackElement, and a field for Cancel, which we will deal with when we fire the event. We use a generic class because we need to support the management of any class type that derives from UndoRedoStackItem, where the appropriate application-specific data will be dealt with and stored.
The “UndoRedoManager” class we will create will be designed as generically as possible. And, to be honest, my original implementation will be a naive one, each Stack Element will be designated as storing a complete “state” for whatever the application is doing. A better approach for applications that have a large amount of state data is to instead store the changes, rather than the entire thing, but that sort of extra functionality is best discussed and designed after we have the basic framework in place.
The first step is to designate what we need to keep track of in the ‘Manager’ Class, as well as our events.
1 2 3 4 5 6 7 8 9 |
private int _maxsize = 10; //Stack of Undo Elements private Stack<t> _UndoStack = new Stack</t><t>(); //stack of Redo Elements private Stack</t><t> _RedoStack = new Stack</t><t>(); //events. public event EventHandler<undoredoeventargs <T>> UndoEvent; public event EventHandler</undoredoeventargs><undoredoeventargs <T>> RedoEvent; </undoredoeventargs></t> |
We need a Maximum size, otherwise it’s possible for the stack to fill up with old Undo elements. We keep a stack for Undo as well as Redo elements. The actual public functionality we need to implement is a method to Undo, a Method to Redo, and a Method to “Push” a new element onto the Undo stack (register a change).
We’ll start with the first method that will usually be used: PushUndo. This will accept a parameter that is the type of the generic type parameter. It will need to:
- Make a Deep copy of the item being pushed.
- Push that deep copy onto the Undo Stack
- Trim the stack to the maximum size.
- Clear the Redo Stack
Here is the code to do that. TrimStack() is private method who’s implementation I will cover shortly:
1 2 3 4 5 6 7 8 |
public void PushUndo(T pushitem) { Debug.Print("Undo Pushed..."); T pushthis = pushitem.DeepClone() as T; _UndoStack.Push(pushthis); TrimStack(_UndoStack, _maxsize); _RedoStack.Clear(); } |
Most of this is self-explanatory. TrimStack’s implementation is a bit trickier, since the Stack
1 2 3 4 5 6 7 8 9 10 |
private static void TrimStack<TStackType>(Stack<TStackType> stacktotrim, int maxelements) { if (stacktotrim.Count < maxelements) return; //nothing to do... Stack<TStackType> pushto = new Stack<TStackType>(); while (stacktotrim.Count > 0 && pushto.Count < maxelements) pushto.Push(stacktotrim.Pop()); stacktotrim.Clear(); while (pushto.Count > 0) stacktotrim.Push(pushto.Pop()); } |
TrimStack here is written to take any Stack Type, though, arguably, this isn’t necessary in this current implementation. (No reason, however, to make it more specific, in my opinion)
First, if the stack has fewer elements than the passed maximum, we have no work to do, so we return right off the bat. Making our work quite easy.
If the Stack does have more elements than the maximum, we pop elements until either the stack to trim is empty (for some reason) or the temporary stack has maxelements elements.
The effect here is to grab the top maxelements items. After this, we clear the stack and push the elements we popped.
The task of performing the Undo has the following steps:
- If CanUndo returns false, return only null.
- Otherwise, Peek at the top element from the UndoStack.
- Create a new instance of the EventArgs class with the peeked element.
- Fire the Undo event with the created EventArgs.
- If the Event’s Cancel field has not been set, perform a Pop on the Undo Stack, and Push the item we retrieved earlier to the Redo Stack.
- Return the Item we removed
The idea here is that the Event handler that is tied to the class will do the application-specific activity for the “undo” operation- we don’t know what the data is, nor what it represents, but the program using us will, so we leave them to it. This also enables the less naive implementation concept that I’ll be using later to extend this.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
public T Undo() { if (CanUndo) { //pop element of UndoStack. T popped = _UndoStack.Peek(); //fire Undo... var useargs = new UndoRedoEventArgs<T>(popped); fireUndo(useargs); //if it wasn't cancelled, really pop it off the stack, and push it to the Redo Stack. if(!useargs.Cancel){ _UndoStack.Pop(); _RedoStack.Push(popped); } return popped; } return null; } Redo is, of course, very much the opposite- <ol> <li>If we cannot Redo, return null.</li> <li>peek and store the first element of the Redo Stack.</li> <li>Create a new UndoRedoEventArgs instance with the peeked element.</li> <li>fire the Redo Event with the created instance.</li> <li>if the passed argument's cancel field has not changed, pop the element from the redo stack, and push it onto the Undo stack.</li> </ol> |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
public T Redo() { if (CanRedo) { T popped = _RedoStack.Peek(); //fire redo... var useargs = new UndoRedoEventArgs<T>(popped); fireRedo(useargs); if (!useargs.Cancel) { _RedoStack.Pop(); _UndoStack.Push(popped); } return popped; } return null; } |
And that is pretty much the core implementation. Of course, as it is, we don’t actually have an implementation for the StackElement class. Since that is something we left up to the client application, let’s create one. Here is a small Windows Form that utilizes the aforementioned classes as well as providing an implementation of the Stack Item for a TextBox:
The Designer class:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 |
partial class Form1 { /// <summary> /// Required designer variable. /// </summary> private System.ComponentModel.IContainer components = null; /// <summary> /// Clean up any resources being used. /// </summary> /// <param name="disposing"/>true if managed resources should be disposed; otherwise, false. protected override void Dispose(bool disposing) { if (disposing && (components != null)) { components.Dispose(); } base.Dispose(disposing); } #region Windows Form Designer generated code /// <summary> /// Required method for Designer support - do not modify /// the contents of this method with the code editor. /// </summary> private void InitializeComponent() { this.textBox1 = new System.Windows.Forms.TextBox(); this.cmdUndo = new System.Windows.Forms.Button(); this.cmdRedo = new System.Windows.Forms.Button(); this.SuspendLayout(); // // textBox1 // this.textBox1.Location = new System.Drawing.Point(12, 30); this.textBox1.Multiline = true; this.textBox1.Name = "textBox1"; this.textBox1.Size = new System.Drawing.Size(490, 284); this.textBox1.TabIndex = 0; // // cmdUndo // this.cmdUndo.Location = new System.Drawing.Point(12, 1); this.cmdUndo.Name = "cmdUndo"; this.cmdUndo.Size = new System.Drawing.Size(75, 23); this.cmdUndo.TabIndex = 1; this.cmdUndo.Text = "Undo"; this.cmdUndo.UseVisualStyleBackColor = true; this.cmdUndo.Click += new System.EventHandler(this.button1_Click); // // cmdRedo // this.cmdRedo.Location = new System.Drawing.Point(93, 1); this.cmdRedo.Name = "cmdRedo"; this.cmdRedo.Size = new System.Drawing.Size(75, 23); this.cmdRedo.TabIndex = 2; this.cmdRedo.Text = "Redo"; this.cmdRedo.UseVisualStyleBackColor = true; this.cmdRedo.Click += new System.EventHandler(this.button2_Click); // // Form1 // this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; this.ClientSize = new System.Drawing.Size(506, 318); this.Controls.Add(this.cmdRedo); this.Controls.Add(this.cmdUndo); this.Controls.Add(this.textBox1); this.Name = "Form1"; this.Text = "Form1"; this.Load += new System.EventHandler(this.Form1_Load); this.ResumeLayout(false); this.PerformLayout(); } #endregion private System.Windows.Forms.TextBox textBox1; private System.Windows.Forms.Button cmdUndo; private System.Windows.Forms.Button cmdRedo; } |
Proper code-behind class:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 |
public partial class Form1 : Form { TextBoxUndoManager tboxManager; public Form1() { InitializeComponent(); } private void Form1_Load(object sender, EventArgs e) { tboxManager = new TextBoxUndoManager(textBox1, 50, 500); } private void button1_Click(object sender, EventArgs e) { //undo tboxManager.Undo(); } private void button2_Click(object sender, EventArgs e) { tboxManager.Redo(); //redo } } public class TextBoxUndoManager { private int _DelayTime; private TextBox _ManageBox = null; private UndoRedoManager<TextUndoItem> UndoObject = null; private Thread pressdelaythread = null; private DateTime? StartDelay; public TextBoxUndoManager(TextBox managebox, int nMaxSize,int msdelay) { UndoObject = new UndoRedoManager<TextUndoItem>(nMaxSize); UndoObject.UndoEvent += UndoObject_UndoEvent; UndoObject.RedoEvent += UndoObject_UndoEvent; _DelayTime = msdelay; _ManageBox = managebox; _ManageBox.KeyPress += _ManageBox_KeyPress; } void UndoObject_UndoEvent(object sender, UndoRedoEventArgs<TextUndoItem> e) { _ManageBox.Text = e.StackElement.Text; _ManageBox.SelectionStart = e.StackElement.SelectionStart; _ManageBox.SelectionLength = e.StackElement.SelectionLength; } void _ManageBox_KeyPress(object sender, KeyPressEventArgs e) { StartDelay = DateTime.Now; Debug.Print("StartDelay set to " + StartDelay); if (pressdelaythread == null) { //if it's null, we are waiting for input, so we will push here too. UndoObject.PushUndo(new TextUndoItem(_ManageBox.Text, _ManageBox.SelectionStart, _ManageBox.SelectionLength, "Edit Text")); pressdelaythread = new Thread(DelayThread); pressdelaythread.Start(); } } private void DelayThread() { while(true){ Thread.Sleep(0); if((DateTime.Now-StartDelay).Value.Milliseconds > _DelayTime) { Debug.Print("Timeout expired"); _ManageBox.Invoke((MethodInvoker)(() => { _ManageBox.ClearUndo(); UndoObject.PushUndo(new TextUndoItem(_ManageBox.Text, _ManageBox.SelectionStart, _ManageBox.SelectionLength, "Edit Text")); pressdelaythread = null; })); return; } } } public void Undo() { UndoObject.Undo(); } public void Redo() { UndoObject.Redo(); } } public class TextUndoItem : UndoRedoStackItem { private String _Text; private int _SelectionStart; private int _SelectionLength; public String Text { get { return _Text; } set { _Text = value; } } public int SelectionStart { get { return _SelectionStart; } set { _SelectionStart = value; } } public int SelectionLength { get { return _SelectionLength; } set { _SelectionLength = value; } } public TextUndoItem(String pText,int pStart,int pLength,String Description):base(Description) { _Text = pText; _SelectionStart = pStart; _SelectionLength = pLength; } public TextUndoItem(TextUndoItem clonethis) : base(clonethis) { _Text = (string) clonethis.Text.Clone(); } public override UndoRedoStackItem DeepClone() { return new TextUndoItem(this); } } |
In the above implementation, TextUndoItem is the UndoRedoStackItem implementation I created for textboxes; this stores the selection start and length as well as the full text. The Actual Undo implementation was less trivial than I had hoped; in this case I created a “Managing” class that uses the UndoRedoManager specifically with Textboxes, hooking the appropriate textbox events and implementing a delay after typing stops before Undo’s are pushed onto the stack. This is a very simple implementation of this particular class design; BASeBlock uses a very similar class in it’s Editor, but for the far more complex structures dealt with there, which include several lists of objects.
The extension and addition to this design that I mentioned earlier consists of storing not the entire state, but rather what changed from the previous element. This is unlikely to be a trivial thing to implement.
Have something to say about this post? Comment!