Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Languages / C#7.0

oops - A Cross-Platform, General Purpose Undo/Redo Framework

5.00/5 (6 votes)
8 Oct 2018CPOL6 min read 8K   6  
Design, implementation, and usage of the oops framework

Introduction

It is common for many user-facing applications to want some kind of undo/redo functionality for a user-driven action. A general purpose undo/redo framework should be cross-platform, configurable, and loose in its structure to allow for unforeseen usage requirements (UI driven? Service driven? Concurrent accessors?, etc).

Background

The difficulty in creating a general purpose undo/redo framework lies in tackling (at least) two core issues:

1. Being able to define a range of actions from a user as undoable
2. Making every single thing the user does undoable
  • Making every character typed undoable, for example, can result in a klunky user experience, but hey it's a start.
  • The ideal framework would allow the code to aggregate changes together
  • oops allows either configuration or somewhere in-between. It's all about the scope of the Accumulator you 'new' up and use.

Overall Goal: Observing changes in all objects in the system in an ordered fashion for N unknown objects

For example, there may be 50 objects that are created and/or modified for any given user action and it would be tedious (at best) or impossible (for any real-world use-case) to funnel all changes into one undo action for the user by explicitly telling each object at the time of the change how to do it, and then how to undo it. oops manages all of this for you by observing any and all property changes in a ViewModel (derived from TrackableViewModel) and any collection changes (TrackableCollection - list, stack, queue - or TrackableDictionary), and accumulating them into one undo action either globally or locally.

Why distinguish between global and local changes? Glad you asked.

Presume you have an application with a main window/form/page of some sort. Anything that goes on in the app will observed and coalesced into undo actions through a menu-driven undo/redo system (like the back and forward arrows we're all used to).

Now, presume you have a dialog that pops up that does some undoable stuff, and also pops a different dialog that also does some undoable stuff. Depending on the project manager's whimsy, all of those actions from both dialogs should be aggregated into one undoable action for the user, or maybe there should be two. Either scenario (and really any scenario I can think of) is available through oops with the option to create singleton/global scopes or local scopes, either of which can go onto the global undo stack.

Components to support Undo/Redo for any ViewModel or Collection change

  • TrackableCollection - a beefed up version of ObservableCollection:

    • Takes care of context switching to the UI thread for you (if it is set up to fire on the UI thread)
    • Seamlessly tracks all operations and makes them undoable - this can be turned on/off at any time
    • Fires CollectionChanged and PropertyChanged events like the UI needs, in the right order, on the right thread, and synchronously with the caller's thread - this can be turned off at any time
    • Handles concurrency just fine (hammer it from multiple threads and it'll all get sorted out)
    • Works as a Stack<> or a Queue<> as well (because sometimes you need those bound to your UI)
    • Highly performant (is that still a word?), as much as the UI will allow
  • TrackableDictionary - super-charged version of Dictionary

    • Fires CollectionChanged and PropertyChanged events like the UI needs, in the right order, on the right thread, and synchronously with the caller's thread
    • Handles concurrency just fine (hammer it from multiple threads and it'll all get sorted out)
    • Takes care of context switching to the UI thread for you (if it is set up to fire on the UI thread)
    • Seamlessly tracks all operations and makes them undoable - this can be turned off at any time
    • Highly performant, as much as the UI will allow if it is bound to the UI
  • ConcurrentList

    • A highly performant List<> that handles concurrency just fine
    • Has lots of helper methods like RemoveAndGetIndex, ReplaceAll, etc. that are inherently thread-safe
    • Also works as a Stack<> or a Queue<>
  • Accumulator

    • Records all actions for undoing them later
    • Can be used a Singleton for application-scoped actions, or as an instance for locally-scoped actions (like in a dialog)
  • AccumulatorManager

    • Manages the Undo and Redo stacks of accumulators
    • Auto-records redo operations for any undo, and vice-versa
  • TrackableViewModel

    • Auto-tracks all changes to its properties in its referenced Accumulator (either the Singleton or a local one)
    • Changes are tracked by using custom Get<>/Set<> methods for any property change
  • TrackableScope

    • Utility class to easily create an Accumulator and push it to the AccumulatorManager on Dispose
    • All changes within its
    using(new TrackableScope("testing"))

    block are aggregated into one Undo action for the User

Using the code

  1. Make a ViewModel that inherits from TrackableViewModel
  2. Give it some properties, like this:
public bool ILikeCheese 
{
   get => Get<bool>();
   set 
   {
      if(Set(value))
        MessageBox.Show("Why did you change your mind?");
   } = true;
}
  1. For any collection you need, use the TrackableCollection (most often)
  2. Create your scope when you want to track changes, like this:
using (new TrackableScope("Undo me now!"))
{
   ... do some stuff ...
}

OR just set Globals.ScopeEachChange to true.

That's it! AccumulatorManager has bindable properties/commands for you to use in your UI for undoing anything you've tracked.

Points of Interest

This framework has been designed, distilled, and refined over the past 4+ years, and I thought it was finally time to publish it to give anyone a leg up looking to add this functionality to their application.

As with any framework that tackles the monumental task of performant concurrent activities, there is the possbility of creating thread-locks, so read ahead to see how to avoid that. :)

How can I create a thread-lock with the oops framework?

This is, unfortunately, quite simple if you are not acutely aware of how TrackableCollection/Dictionary locks for concurrency, due to the nature of most UI's (like WPF) that require the collection change and the CollectionChanged event to happen synchronously, the lock must then be set on the UI thread. I truly, heartily wish it weren't so and I have in fact worked very hard to find a way to not make it so, but WPF ended up killing all such aspirations after much gnashing of teeth. My experience with .NET Core Xaml has not lead me to believe that it is any more flexible than WPF in this area.

Given that the lock for any TrackableCollection/Dictionary happens on the UI thread, it is trivial to create a thread-locking situation. Presume you have this horrible extension method (you get the idea, even though this is truly a horrible method):

public static void AddAndRemoveOneSecondLater<TType>(this TrackableCollection<TType> coll, TType item)
{
    lock(coll.SyncRoot)
    {
       coll.Add(item);
       Thread.Sleep(60000);
       coll.Remove(item);
    }
}

If you have this method, and you're no amateur, you're not going to call it on the UI thread b/c of that Thread.Sleep() call because that would be bad form. Bad form indeed!

So, if called on a background thread of some sort (thread pool, let's say), you're going to lock on a background thread, and then the Add() will also lock on that same object on the UI Thread. Since Add() is synchronous, you have now got yourself a thread-lock b/c the UI thread is waiting on your background thread to release the lock, but your background thread is waiting on the Add() to finish on the UI thread.

History

The oops framework is available on github and for download on nuget.  Enjoy!

License

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