Click here to Skip to main content
15,891,513 members
Articles / Programming Languages / C#

Yet another Undo-Redo Framework

Rate me:
Please Sign up or sign in to vote.
4.83/5 (6 votes)
30 Oct 2013CPOL3 min read 20.9K   206   31   4
Undo-Redo for desktop applications

Introduction 

I know that many people already solved the problem of undo & redo well. Just count the articles publishes here on codeproject about this topic! But nonetheless I couldn't resist to build my own small undo-redo framework. 

The framework powers a mid-sized application of mine that is built with WPF. but it should work as well with Windows Forms based applications. I have to disappoint you if you were hoping that this framework would support RDBMS-backed applications. It doesn't. It works well when files are used for persistence and the object model is loaded completely into memory. 

Basic Design 

The basic design of the framework is to integrate it at the layer of the entities where all changes in properties and collections are observed.

 

Recording Changes 

Recording changes is split up into recording simple property changes and recording changes in a collection. 

At first, let's have a look at the ModelBase class:

C#
/// <summary>
/// Base-class for all models / entities.
/// </summary>
public class ModelBase : INotifyPropertyChanged
{
	public event PropertyChangedEventHandler PropertyChanged;

	public virtual ModelBase GetParent()
	{
		return null;
	}

	public ModelBase GetRoot()
	{
		var parent = GetParent();

		if (parent == null)
		{
			return this;
		}

		return parent.GetRoot();
	}
	
	protected virtual void OnPropertyChanged(string propertyName)
	{
		var h = PropertyChanged;
		if (h != null) h(this, new PropertyChangedEventArgs(propertyName));
	}

	protected virtual void OnPropertyChanged(string propertyName, object oldValue, object newValue)
	{
		var h = PropertyChanged;
		if (h != null) h(this, new PropertyChangedEventArgs(propertyName));
		
		var root = GetRoot() as IUndoRedoRootObject;
		
		if (root != null)
		{
			root.GetJournal().AddChangeEntry(this, propertyName, oldValue, newValue);
		}
	}
} 

Every entity of the application must inherit from this class and implement properties like this: 

C#
private string property1;

public string Property1
{
    get { return this.property1; }
    set
    {
        if (this.property1 != value)
        {
            var oldValue = property1;
            this.property1 = value;
            this.OnPropertyChanged("Property1", oldValue, value);
        }
    }
} 

The journal is kept in the root object of the domain model, so every child object must override GetParent() 

C#
public class TestChildObject1 : ModelBase
{
    internal TestRootObject parent;

    public TestChildObject1(TestRootObject parent)
    {
        this.parent = parent;
    }

    public override ModelBase GetParent()
    {
        return parent;
    }
} 

Finally, the root object must implement the interface IUndoRedoRootObject

C#
public class TestRootObject : ModelBase, IUndoRedoRootObject
{       
        private ChangeJournal changeJournal;
	
	public TestRootObject()
	{  
	    this.changeJournal = new ChangeJournal();
	}
	
	public ChangeJournal GetJournal()
	{
	    return changeJournal;
	}
} 

So whenever a property change occurs, the object hierarchy is traversed until the root object is found, then the change is added to the journal.

To record changes in the collections a special collection class must be used - the UndoRedoRecordingCollection

C#
/// <summary>
/// A special sub-class of ObservableCollection that records all changes in the collection
/// </summary>
/// <typeparam name="T"></typeparam>
public class UndoRedoRecordingCollection<T> : ObservableCollection<T>
{
    private ModelBase owner;

    public UndoRedoRecordingCollection(ModelBase owner)
    {
        this.owner = owner;
    }

    protected override void OnCollectionChanged(NotifyCollectionChangedEventArgs e)
    {
        base.OnCollectionChanged(e);

        var root = owner.GetRoot() as IUndoRedoRootObject;

        if (root != null)
        {
            root.GetJournal().AddChangeEntry(this, e);
        }
    }
}  

This class adds all changes to the journal as well. 

Change Sets  

By default every single change will be added to the journal and thus represents a button click to the user. In many situations this is not the desired behavior.  While a single action may contain hundreds of single changes the user should only be able to undo the action as a whole. 

Therefore a ChangeScope can be created: 

using (var changeScope = rootObject.GetJournal().BeginScope())
{
	// ...

The scope itself is just a small syntactic sugar class that utilizes the using syntax for calling EndScope() in the journal. 

public class ChangeScope : IDisposable
{
    private ChangeJournal journal;

    internal ChangeScope(ChangeJournal journal)
    {
        this.journal = journal;
    }

    public void Dispose()
    {
        journal.EndScope();
    }
}  

Change scopes can be nested, so the changes will be added to the journal after the last scope has been left. A ChangeScope should not be confused with a ChangeSet. A ChangeScope is for the definition of the scope, the ChangeSet aggregates all single changes within that scope. 

Journal (Record & Replay of Changes) 

The Change<code>Journal is used both for recoding the changes and for the replay logic and is thus the central class: 

public class ChangeJournal
{
    private bool executingUndoOrRedo;

    private int currentUndoPos = -1;

    private List<ChangeEntry> changes = new List<ChangeEntry>();

    private ChangeSet changeSet;

    public int ChangesCount
    {
        get { return this.changes.Count; }
    }

    public void AddChangeEntry(object entity, string property, object oldValue, object newValue)
    {
        if (this.executingUndoOrRedo) return;

        if (currentUndoPos != -1)
        {
            changes = changes.GetRange(0, currentUndoPos);
            currentUndoPos = -1;
        }

        if (this.changeSet == null)
        {
            this.changes.Add(new PropertyChangedEntry(entity, property, oldValue, newValue));
            this.OnChanged();
        }
        else
        {
            this.changeSet.changes.Add(new PropertyChangedEntry(entity, property, oldValue, newValue));
        }            
    }

    public void AddChangeEntry(IList collection, NotifyCollectionChangedEventArgs rawEventArg)
    {
        if (this.executingUndoOrRedo) return;

        if (currentUndoPos != -1)
        {
            changes = changes.GetRange(0, currentUndoPos);
            currentUndoPos = -1;
        }

        if (this.changeSet == null)
        {
            this.changes.Add(new CollectionChangedEntry(collection, rawEventArg));
            this.OnChanged();
        }
        else
        {
            this.changeSet.changes.Add(new CollectionChangedEntry(collection, rawEventArg));
        }            
    }

    public void Undo(int steps)
    {
        this.executingUndoOrRedo = true;

        try
        {
            int done = 0;

            int pos = currentUndoPos == -1 ? changes.Count : currentUndoPos;

            while (done < steps)
            {
                if (pos - done - 1 < 0)
                {
                    break;
                }

                changes[pos - done - 1].Undo();

                done++;
            }

            this.currentUndoPos = pos - done;
        }
        finally
        {
            this.executingUndoOrRedo = false;
        }
    }

    public void Redo(int steps)
    {
        if (this.currentUndoPos == -1)
        {
            return;
        }

        this.executingUndoOrRedo = true;

        try
        {
            int i = currentUndoPos;

            for (; i < currentUndoPos + steps && i < changes.Count; i++)
            {
                changes[i].Redo();
            }

            this.currentUndoPos = i;
        }
        finally
        {
            this.executingUndoOrRedo = false;
        }
    }

    public void Reset()
    {
        this.changes.Clear();
        this.executingUndoOrRedo = false;
        this.currentUndoPos = -1;
        this.OnChanged();
    }

    private Stack<ChangeScope> changeScopes = new Stack<ChangeScope>();

    public ChangeScope BeginScope()
    {
        if (this.changeSet == null)
        {
            this.changeSet = new ChangeSet();
        }

        var scope = new ChangeScope(this);
        changeScopes.Push(scope);            
        return scope;
    }

    public void EndScope()
    {
        if (changeScopes.Count > 0)
        {
            changeScopes.Pop();
        }

        if (this.changeScopes.Count == 0)
        {
            if (this.changeSet.changes.Count > 0)
            {
                this.changes.Add(this.changeSet);
                this.OnChanged();
            }

            this.changeSet = null;
        }
    }

    public int ExecutableUndoActions()
    {
        if (this.currentUndoPos == -1)
        {
            return this.changes.Count;
        }
        else
        {
            return this.currentUndoPos;
        }
    }

    public int ExecutableRedoActions()
    {
        if (this.currentUndoPos == -1)
        {
            return -1;
        }

        return this.changes.Count - this.currentUndoPos;
    }

    public event EventHandler Changed;

    protected void OnChanged()
    {
        var h = this.Changed;
        if (h != null) h(this, EventArgs.Empty);
    }
}  

Replay in Change Entries 

The journal contains change entries that implement the undo and redo function for each single entry: 

/// <summary>
/// A change entry in the journal that executes the actual undo-redo.
/// </summary>
abstract class ChangeEntry
{
    public abstract void Undo();
    public abstract void Redo();
}    

For property changes this is almost trivial: 

class PropertyChangedEntry : ChangeEntry
{
    private object changedObject;
    private string property;
    private object oldValue;
    private object newValue;

    public PropertyChangedEntry(object obj, string property, object oldValue, object newValue)
    {
        this.changedObject = obj;
        this.property = property;
        this.oldValue = oldValue;
        this.newValue = newValue;
    }

    public override void Undo()
    {
        changedObject.GetType().GetProperty(property).SetValue(changedObject, oldValue, null);
    }

    public override void Redo()
    {
        changedObject.GetType().GetProperty(property).SetValue(changedObject, newValue, null);
    }
}

For collections this is only slightly more complex:

class CollectionChangedEntry : ChangeEntry
{
    private NotifyCollectionChangedEventArgs rawEventArg;
    private IList collection;

    public CollectionChangedEntry(IList collection, NotifyCollectionChangedEventArgs rawEventArg)
    {
        this.collection = collection;
        this.rawEventArg = rawEventArg;
    }

    public override void Undo()
    {
        switch (rawEventArg.Action)
        {
            case NotifyCollectionChangedAction.Add:
                foreach (var addedItem in rawEventArg.NewItems)
                {
                    this.collection.Remove(addedItem);
                }
                break;
            case NotifyCollectionChangedAction.Remove:
                int a = rawEventArg.OldStartingIndex;
                foreach (var movedItem in rawEventArg.OldItems)
                {
                    this.collection.Remove(movedItem);
                    this.collection.Insert(a, movedItem);
                    a++;
                }
                break;
            case NotifyCollectionChangedAction.Move:
                int o = rawEventArg.OldStartingIndex;
                foreach (var movedItem in rawEventArg.NewItems)
                {
                    this.collection.Remove(movedItem);
                    this.collection.Insert(o, movedItem);
                    o++;
                }
                break;
        }
    }

    public override void Redo()
    {
        switch (rawEventArg.Action)
        {
            case NotifyCollectionChangedAction.Add:
                int a = rawEventArg.NewStartingIndex;
                foreach (var movedItem in rawEventArg.NewItems)
                {
                    this.collection.Remove(movedItem);
                    this.collection.Insert(a, movedItem);
                    a++;
                }
                break;
            case NotifyCollectionChangedAction.Remove:
                foreach (var removedItem in rawEventArg.OldItems)
                {
                    this.collection.Remove(removedItem);
                }
                break;

            case NotifyCollectionChangedAction.Move:
                int o = rawEventArg.NewStartingIndex;
                foreach (var movedItem in rawEventArg.OldItems)
                {
                    this.collection.Remove(movedItem);
                    this.collection.Insert(o, movedItem);
                    o++;
                }
                break;
        }
    }
} 

 The ChangeSet's implementation is trivial again:

class ChangeSet : ChangeEntry
{
    internal List<ChangeEntry> changes = new List<ChangeEntry>();

    public override void Undo()
    {
        for (int i = changes.Count - 1; i >= 0; i--)
        {
            changes[i].Undo();
        }
    }

    public override void Redo()
    {
        foreach (var change in changes)
        {
            change.Redo();
        }
    }
} 

Planned Improvements  

This is the first version of the framework and I hope to improve it in the future: 

  • Automatic aggregation of changes (e.g. a 5-second rule), especially text fields
  • Limit the size of the journal to lower memory consumption
  • Allow a text description of the change (so that it can be shown in the UI)
  • Memory consumption must be checked in depth and should be optimized
  • Change of UI state (pages, selections) should be part of the undo-redo (this is especially an integration issue for my application)  
Therefore I started a project on sourceforge where you can download the latest version

Integration Sample  

I have integrated the framework into the application Quality Spy - you can find it at sourceforge, too. 

History  

Initial version - 2013/10/29

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Unknown
This member has not yet provided a Biography. Assume it's interesting and varied, and probably something to do with programming.

Comments and Discussions

 
Generalwhere is the source code? Pin
Southmountain30-Oct-13 6:24
Southmountain30-Oct-13 6:24 
GeneralRe: where is the source code? Pin
ankle30-Oct-13 21:35
ankle30-Oct-13 21:35 
QuestionHi Pin
Aydin Homay29-Oct-13 19:57
Aydin Homay29-Oct-13 19:57 
AnswerRe: Hi Pin
ankle29-Oct-13 20:52
ankle29-Oct-13 20:52 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.