Click here to Skip to main content
Click here to Skip to main content

Context Sensitive History. Part 1 of 2

By , 24 Feb 2010
Rate this:
Please Sign up or sign in to vote.
Screen shot of main application window

Contents

Introduction

At some point in the development of most applications, the capability to allow a user to undo and action becomes a requirement. Neither WPF nor Silverlight 4, provide any real infrastructure for accomplishing this, but instead require developers to build this functionality into each component. There are some real advantages to providing an application wide mechanism for managing, what the author calls tasks; which are units of work performed within the application. Advantages that include allowing tasks to be monitored, and grouped according to a context (such as a UI control), executed sequentially or in parallel, and even to be rolled back on failure.

The main features of the task management system provided in this article are:

  • Tasks can be undone, redone, and repeated.
  • Task execution may be cancelled.
  • Composite tasks allow sequential and parallel execution of tasks with automatic rollback on failure of an individual task.
  • Tasks can be associated with a context, such as a UserControl, so that the undo, redo, and repeat actions can be, for example, enabled according to UI focus.
  • Tasks can be global, having no context association.
  • The task system can be wired to ICommands.
  • Tasks can be chained, in that one task can use the Task Service to perform another.
  • Return to a point in history by specifying an undo point.
  • Coherency in the system is preserved by disallowing the execution of tasks outside of the Task Service.
  • Task Model compatible with both the Silverlight and Desktop CLRs

While the examples presented in this article are predominately based around Calcium, the Task Service and tasks are completely separate from Calcium, and can in fact be consumed by any Desktop CLR or Silverlight CLR application. There will be two articles in this series. Part one, this article, details the Task Model and how to use it. Part two will show how the Task Model has been integrated into Calcium, and will demonstrate a simple diagram designer module.

Background

Back in 2007 I wrote about a Command Management system that I created for a game. The code I present in this article follows some of the same principles I explored in that article, but this time around I have expanded the scope and depth to a far greater extent.

The WPF (and now Silverlight 4) commanding infrastructure does not provide support for an undo redo mechanism directly via the CommandManager. Controls such as the TextBox provide internal support by means of RoutedCommands. So, a unified system can be difficult to obtains with the existing infrastructure. The system that I have devised leverages existing Undo/Redo capabilities of such controls; adding handlers for unhandled Routed Commands; while providing new features such as execution cancellation, and allowing for a task to be repeated.

The Task Model

As stated in the introduction, tasks are what the author defines as a unit of work performed within an application. Tasks themselves are instances of an ITask, which encapsulate the data and logic required to carry out an objective, such as moving an item in a UI.

Two Approaches

There are two approaches to task management. The first approach is to dictate that every action causing a change in state is encapsulated in a task. This approach can be laborious, as a new task instance must be created for each action. There is also a risk with this approach that external changes, performed outside of a task may change the state of a component, thereby rendering the undo task capability ineffective.

The second approach is to provide for state awareness. This means to take a snapshot of a component's state when a change in state is occurring. The implementation of an undo action is to simply to restore the previous state of a UI component. This approach may be mandatory for interfaces where many actions are simply not undoable, for example a paint program where use of filters, such as blur, are not able to be undone. This approach is compelling because it has a onetime cost of creating the capability to serialize and deserialize the component state. The downside is that storage efficiency needs to be considered, because each new task will save the entire component state, and not merely the change. One can imagine employing, for example, a diffgram like approach to contend with the extra storage requirements, but that is outside the scope of this article.

Both approaches are attainable with the Task Model infrastructure presented here. We will, however, be focussing on the first.

Capture Logic within a Task

By encapsulating the logic and state for a particular action within a task, we are able to better manage tasks. Most notably we can queue tasks, undo the work performed by a task (or set of tasks), and repeat that work if supported by the task. We can also provide a notification and cancellation system for tasks, so that subscribers to a task service are able to intervene when a particular task is being performed. We also have support for a context system. That is, we can provide the Task Service with an identifier, which is used to partition a set of tasks, so that all actions around those tasks can be managed separately from other tasks. For example, the context could be a view within the UI, so that when the view loses focus, undoing of tasks for that view is disabled. We also allow for global tasks, which have no association with any particular UI context.

Task Service

All task execution activities are performed by the Task Service. In fact, only the Task Service is able execute a task. If it were otherwise, it could allow the state of the UI, for example, to be placed out of sync with the Task Service Undo or Repeat stacks, so that a subsequent undo or repeat of a task would produce unexpected results. This is important when the order of tasks is important, which is usually the case.

The following flow chart diagram shows how the Task Service goes about performing a task.

Figure: Flowchart of Task Execution

The Task Service itself contains several stacks, which are associated with execution contexts. An execution context may be a control, or a control's view model for example. In the demonstration Diagram Designer module, we use a guid identifier for the view model. By using an execution context we are able to associate a set of Tasks with a particular UI element, thereby allowing a different set of undo/repeat tasks to be displayed in the Edit menu, depending on what control has focus.

The default implementation of the ITaskService is the TaskService. As an aside, I further extend this TaskService in the Calcium implementation to provide for application wide notifications using event aggregation.

Figure: ITaskService and default implementation TaskService.

Tasks and Undoable Tasks

A task represents a unit of work performed by the ITaskService implementation. Some tasks are undoable, while others are not. The ITask interface represents the base functionality for all tasks. The base Task class (TaskBase) inherits from IInternalTask. IInternalTask provides capabilities directly associated with the TaskService implementation, and allows the TaskService to repeat the execution of a task. As stated previously, user code is prevented from executing tasks without the TaskService. This has been accomplished with explicit implementation of internal interfaces. Other reasons for prohibiting the execution of a task directly; bypassing the ITaskService, include preventing subscribers of the TaskService events from missing out on notifications and the opportunity to cancel tasks. Also, the ITaskService offers a single point to provide auditing and logging etc.

Figure: Task class and interface hierarchy.

TaskBase and UndoableTaskBase are the starting point for creating new tasks which encapsulate the behavior of a task. That is, by inheriting from either of these two classes we can encapsulate the task logic and state for an action within the subclass itself. Alternatively, if this is too heavy, and you don't wish to go to the trouble of creating a new Task class, use the Task<T> and UndoableTask<T> classes. Both of these two classes accept Actions, which are performed when the task itself is performed etc. See Using the Light Weight Task and UndoableTask Classes

Inheriting from TaskBase and UndoableTaskBase

Both TaskBase and UndoableTaskBase provide events that can be subscribed to in order to perform some custom activity. TaskBase provides an Execute event, while UndoableTaskBase provides both an Execute event (inherited from TaskBase) and an Undo event. I find it a good practice to favor events over virtual methods because when a virtual method approach is used, it may cause confusion over whether the base implementation should be called, likewise it can create a dependency on the base class implementation, and in this case we also are able to prevent the circumvention of the TaskService, because a custom task can't call the Execute method on the task.

An example of a custom UndoableTaskBase implementation is the MoveDesignerHostTask from the Diagram Designer module. It is presented here in its entirety.

C#:
class MoveDesignerHostTask : UndoableTaskBase<MoveDesignerHostArgs>
{
    Point? point;

    public MoveDesignerHostTask()
    {
        Execute += OnExecute;
        Undo += OnUndo;
    }

    void OnUndo(object sender, TaskEventArgs<MoveDesignerHostArgs> e)
    {
        if (!point.HasValue)
        {
            throw new InvalidOperationException("Previous Point is not set.");
        }

        var viewModel = e.Argument.DesignerItemViewModel;
        viewModel.Left = point.Value.X;
        viewModel.Top = point.Value.Y;
    }

    void OnExecute(object sender, TaskEventArgs<MoveDesignerHostArgs> e)
    {
        var newPoint = e.Argument.NewPoint;
        point = e.Argument.OldPoint;
        var viewModel = e.Argument.DesignerItemViewModel;
        viewModel.Left = newPoint.X;
        viewModel.Top = newPoint.Y;
    }

    public override string DescriptionForUser
    {
        get
        {
            return "Move Item"; /* TODO: Make localizable resource. */
        }
    }
}
VB.NET:
Friend Class MoveDesignerHostTask
    Inherits UndoableTaskBase(Of MoveDesignerHostArgs)
    ' Methods
    Public Sub New()
        AddHandler MyBase.Execute, New EventHandler(Of TaskEventArgs(Of MoveDesignerHostArgs))(AddressOf Me.OnExecute)
        AddHandler MyBase.Undo, New EventHandler(Of TaskEventArgs(Of MoveDesignerHostArgs))(AddressOf Me.OnUndo)
    End Sub

    Private Sub OnExecute(ByVal sender As Object, ByVal e As TaskEventArgs(Of MoveDesignerHostArgs))
        Dim newPoint As Point = e.Argument.NewPoint
        Me.point = New Point?(e.Argument.OldPoint)
        Dim viewModel As DesignerItemViewModel = e.Argument.DesignerItemViewModel
        viewModel.Left = newPoint.X
        viewModel.Top = newPoint.Y
    End Sub

    Private Sub OnUndo(ByVal sender As Object, ByVal e As TaskEventArgs(Of MoveDesignerHostArgs))
        If Not Me.point.HasValue Then
            Throw New InvalidOperationException("Previous Point is not set.")
        End If
        Dim viewModel As DesignerItemViewModel = e.Argument.DesignerItemViewModel
        viewModel.Left = Me.point.Value.X
        viewModel.Top = Me.point.Value.Y
    End Sub


    ' Properties
    Public Overrides ReadOnly Property DescriptionForUser As String
        Get
            Return "Move Item"
        End Get
    End Property


    ' Fields
    Private point As Point?
End Class

We can see that this task's purpose is to merely relocate a DesignerItemViewModel by setting its Left and Top properties.

Using the Light Weight Task and UndoableTask Classes

While it is often prudent to place Task logic in a class, because this improves reusability, and helps to decouple application logic (ala the Strategy Pattern), sometimes we may want to do things inline using a delegate. For this purpose we can use the Task and UndoableTask, which both accept an Action parameter. The following excerpt from the DiagramDesignerViewModel demonstrates the UndoableTask, and how it is used to add new designer items.

C#:
DesignerItemViewModel designerItemViewModel;
var removedItems = new Stack<DesignerItemViewModel>();
UndoableTask<object> task = new UndoableTask<object>(
    delegate 
    {
        if (removedItems.Count > 0)
        {
            designerItemViewModel = removedItems.Pop();
        }
        else
        {
            if (lastAddedAt.Y > 200)
            {
                offset += offsetIncrement;
                lastAddedAt = new Point(offset, 0);
            }
            lastAddedAt = new Point(lastAddedAt.X + offsetIncrement, 
				lastAddedAt.Y + offsetIncrement);
            designerItemViewModel = new DesignerItemViewModel
                                    {
                                        Left = lastAddedAt.X, Top = lastAddedAt.Y
                                    };
        }

        designerItems.Add(designerItemViewModel);
    },
    delegate 
    {
        if (lastAddedAt.X > offset && lastAddedAt.Y > offset)
        {
            lastAddedAt = new Point(lastAddedAt.X - offset, lastAddedAt.Y - offset);
        }
        int removalIndex = designerItems.Count - 1;
        var viewModel = designerItems[removalIndex];
        designerItems.RemoveAt(removalIndex);
        removedItems.Push(viewModel);
        /* The align left command may not be executable now. */
        alignLeftCommand.RaiseCanExecuteChanged();

    }, "Add Designer Item"); 

task.Repeatable = true;
var taskService = ServiceLocatorSingleton.Instance.GetInstance<ITaskService>();
taskService.PerformTask(task, null, Id);
VB.NET:
Dim designerItemViewModel As DesignerItemViewModel
        Dim removedItems As New Stack(Of DesignerItemViewModel)
        Dim task As New UndoableTask(Of Object)(Function 
            If (removedItems.Count > 0) Then
                designerItemViewModel = removedItems.Pop
            Else
                If (Me.lastAddedAt.Y > 200) Then
                    Me.offset = (Me.offset + Me.offsetIncrement)
                    Me.lastAddedAt = New Point(Me.offset, 0)
                End If
                Me.lastAddedAt = New Point((Me.lastAddedAt.X + Me.offsetIncrement), _
					(Me.lastAddedAt.Y + Me.offsetIncrement))
                designerItemViewModel = New DesignerItemViewModel { _
                    .Left = Me.lastAddedAt.X, _
                    .Top = Me.lastAddedAt.Y _
                }
            End If
            Me.designerItems.Add(designerItemViewModel)
        End Function, Function 
            If ((Me.lastAddedAt.X > Me.offset) AndAlso (Me.lastAddedAt.Y > Me.offset)) Then
                Me.lastAddedAt = New Point((Me.lastAddedAt.X - Me.offset), (Me.lastAddedAt.Y - Me.offset))
            End If
            Dim removalIndex As Integer = (Me.designerItems.Count - 1)
            Dim viewModel As DesignerItemViewModel = Me.designerItems.Item(removalIndex)
            Me.designerItems.RemoveAt(removalIndex)
            removedItems.Push(viewModel)
            Me.alignLeftCommand.RaiseCanExecuteChanged
        End Function, "Add Designer Item")
        task.Repeatable = True
        ServiceLocatorSingleton.Instance.GetInstance(Of ITaskService).PerformTask(Of Object)(_
			DirectCast(task, UndoableTaskBase(Of Object)), Nothing, MyBase.Id)

Notice that in order to allow this task to be repeated, we must set its Repeatable property to true. Doing so indicates to the TaskService that it should be placed in a repeatable Stack associated with the DiagramDesignerViewModel (via its Id property).

Composite Tasks

Sometimes we may want to allow a set of tasks to be associated with a single user action. In these circumstances it may be tempting to circumvent the task system by placing various logically distinct activities into a single task, which risks violating the Single Responsibility Principle. In order to prevent this I have created the notion of a Composite Task. Composite tasks allow you to place any number of tasks within them, and then when the Composite Task is performed, undone, or repeated; all tasks will be performed etc. Using a Composite Task also allows us to choose to perform the sub tasks sequentially or in parallel, and to also automatically undo tasks when an individual task raises an exception.

Just as we have Task and UndoableTask classes to represent a single activity, we have CompositeTask and CompositeUndoableTask classes.

Executing a Composite Undoable Task

To perform the execution of a set of tasks, we instantiate a CompositeUndoableTask; passing it an IDictionary of tasks and associated task arguments, as shown in the following excerpt, demonstrating the alignment capability of the diagram designer:

C#:
var tasksAndArgs = designerItems.ToDictionary(
        x => (UndoableTaskBase<MoveDesignerHostArgs>)new MoveDesignerHostTask(), 
        x => new MoveDesignerHostArgs(x, new Point(20, x.Top)));

var undoableTask = new CompositeUndoableTask<MoveDesignerHostArgs>(tasksAndArgs, "Align Left");
var taskService = ServiceLocatorSingleton.Instance.GetInstance<ITaskService>();
taskService.PerformTask(undoableTask, null, Id);
VB.NET:
Dim undoableTask As New CompositeUndoableTask(Of MoveDesignerHostArgs)( _
    Me.designerItems.ToDictionary(Of DesignerItemViewModel, _ 
    UndoableTaskBase(Of MoveDesignerHostArgs), _ 
    MoveDesignerHostArgs)(Function (ByVal x As DesignerItemViewModel) 
            Return New MoveDesignerHostTask
        End Function, Function (ByVal x As DesignerItemViewModel) 
            Return New MoveDesignerHostArgs(x, New Point(20, x.Top))
        End Function), "Align Left")
        ServiceLocatorSingleton.Instance.GetInstance(Of ITaskService) _
		    .PerformTask(Of MoveDesignerHostArgs)( _
		        DirectCast(undoableTask, UndoableTaskBase(Of MoveDesignerHostArgs)), Nothing, MyBase.Id)

To the user this will appear as a single action, and if an undo is performed, it will likewise undo all tasks in sequence.

Parallel Execution of Composite Tasks

By default Composite Tasks execute sequentially, that is, child tasks are performed on the same thread, one after another, as this excerpt from the CompositeUndoableTask shows:

C#:
static void ExecuteSequentially(Dictionary<UndoableTaskBase<T>, T> taskDictionary)
{
    var performedTasks = new List<UndoableTaskBase<T>>();
    foreach (KeyValuePair<UndoableTaskBase<T>, T> pair in taskDictionary)
    {
        var task = (IInternalTask)pair.Key;
        try
        {
            task.PerformTask(pair.Value);
            performedTasks.Add(pair.Key);
        }
        catch (Exception)
        {
            SafelyUndoTasks(performedTasks.Cast<IUndoableTask>());
            throw;
        }
    }
}
VB.NET:
Private Shared Sub ExecuteSequentially( _
        ByVal taskDictionary As Dictionary(Of UndoableTaskBase(Of T), T))
        
    Dim performedTasks As New List(Of UndoableTaskBase(Of T))
    Dim pair As KeyValuePair(Of UndoableTaskBase(Of T), T)
    For Each pair In taskDictionary
        Dim task As IInternalTask = pair.Key
        Try 
            task.PerformTask(pair.Value)
            performedTasks.Add(pair.Key)
        Catch exception1 As Exception
            CompositeUndoableTask(Of T).SafelyUndoTasks(performedTasks.Cast(Of IUndoableTask)())
            Throw
        End Try
    Next
End Sub

Sometimes however, we may have a set of tasks that are independent, and which may benefit from execution on different threads.

static void ExecuteInParallel(Dictionary<UndoableTaskBase<T>, T> taskDictionary)
{
    /* When we move to .NET 4 we may use System.Threading.Parallel for the Desktop CLR. */
    var performedTasks = new List<UndoableTaskBase<T>>();
    object performedTasksLock = new object();
    var exceptions = new List<Exception>();
    object exceptionsLock = new object();
    var events = taskDictionary.ToDictionary(x => x, x => new AutoResetEvent(false));

    foreach (KeyValuePair<UndoableTaskBase<T>, T> pair in taskDictionary)
    {
        var autoResetEvent = events[pair];
        var task = (IInternalTask)pair.Key;
        var undoableTask = pair.Key;
        var arg = pair.Value;

        ThreadPool.QueueUserWorkItem(
            delegate
            {
                try
                {
                    task.PerformTask(arg);
                    lock (performedTasksLock)
                    {
                        performedTasks.Add(undoableTask);
                    }
                }
                catch (Exception ex)
                {
                    /* TODO: improve this to capture undone task errors. */
                    lock (exceptionsLock)
                    {
                        exceptions.Add(ex);
                    }
                }
                autoResetEvent.Set();
            });

    }

    foreach (var autoResetEvent in events.Values)
    {
        autoResetEvent.WaitOne();
    }

    if (exceptions.Count > 0)
    {
        SafelyUndoTasks(performedTasks.Cast<IUndoableTask>());
        throw new CompositeException("Unable to undo tasks", exceptions);
    }
}

In which case we merely need to change the Parallel property of the CompositeTask before execution. The following unit test excerpt demonstrates this:

C#:
void CompositeTasksShouldBePerformedInParallel(object contextKey)
{
    var tasks = new Dictionary<TaskBase<string>, string>();
    for (int i = 0; i < 100; i++)
    {
        tasks.Add(new MockTask(), i.ToString());
    }
    var compositeTask = new CompositeTask<string>(tasks, "1") { Parallel = true };
    var target = new TaskService();
    target.PerformTask(compositeTask, null, contextKey);
    foreach (KeyValuePair<TaskBase<string>, string> keyValuePair in tasks)
    {
        var mockTask = (MockTask)keyValuePair.Key;
        Assert.AreEqual(1, mockTask.ExecutionCount);
    }
}
VB.NET:
Private Sub CompositeTasksShouldBePerformedInParallel(ByVal contextKey As Object)
    Dim tasks As New Dictionary(Of TaskBase(Of String), String)
    Dim i As Integer
    For i = 0 To 100 - 1
        tasks.Add(New MockTask, i.ToString)
    Next i
    Dim compositeTask As New CompositeTask(Of String)(tasks, "1")
    compositeTask.Parallel = True
    New TaskService().PerformTask(Of String)(compositeTask, Nothing, contextKey)
    Dim keyValuePair As KeyValuePair(Of TaskBase(Of String), String)
    For Each keyValuePair In tasks
        Dim mockTask As MockTask = DirectCast(keyValuePair.Key, MockTask)
        Assert.AreEqual(Of Integer)(1, mockTask.ExecutionCount)
    Next
End Sub

Chaining Tasks

Tasks themselves can leverage the ITaskService in order to execute sub-tasks. This is useful when we have conditional execution of tasks depending on the result of some internal task activity. The chaining capability explains a curious aspect in the implementation of the various PerformTask overloads in the TaskService class.

C#:
public TaskResult PerformTask<T>(TaskBase<T> task, T argument, object contextKey)
{
    ArgumentValidator.AssertNotNull(task, "task");

    if (contextKey == null)
    {
        return PerformTask(task, argument);
    }

    var eventArgs = new CancellableTaskServiceEventArgs(task);
    OnExecuting(eventArgs);

    if (eventArgs.Cancel)
    {
        return TaskResult.Cancelled;
    }        
    
    int dictionaryKey = contextKey.GetHashCode();

    /* Clear the undoable tasks for this context. */
    undoableDictionary.Remove(dictionaryKey);
    redoableDictionary.Remove(dictionaryKey);

    ReadWriteSafeStack<IInternalTask> tasks;
    if (!repeatableDictionary.TryGetValue(dictionaryKey, out tasks))
    {
        tasks = new ReadWriteSafeStack<IInternalTask>();
        repeatableDictionary[dictionaryKey] = tasks;
    }
    tasks.Push(task);

    var result = task.PerformTask(argument);

    OnExecuted(new TaskServiceEventArgs(task));
    return result;
}
VB.NET:
Public Function PerformTask(Of T)(ByVal task As TaskBase(Of T), _
		ByVal argument As T, ByVal contextKey As Object) As TaskResult
    Dim tasks As ReadWriteSafeStack(Of IInternalTask)
    ArgumentValidator.AssertNotNull(Of TaskBase(Of T))(task, "task")
    If (contextKey Is Nothing) Then
        Return Me.PerformTask(Of T)(task, argument)
    End If
    Dim eventArgs As New CancellableTaskServiceEventArgs(task)
    Me.OnExecuting(eventArgs)
    If eventArgs.Cancel Then
        Return TaskResult.Cancelled
    End If
    Dim dictionaryKey As Integer = contextKey.GetHashCode
    Me.undoableDictionary.Remove(dictionaryKey)
    Me.redoableDictionary.Remove(dictionaryKey)
    If Not Me.repeatableDictionary.TryGetValue(dictionaryKey, tasks) Then
        tasks = New ReadWriteSafeStack(Of IInternalTask)
        Me.repeatableDictionary.Item(dictionaryKey) = tasks
    End If
    tasks.Push(task)
    Dim result As TaskResult = task.PerformTask(argument)
    Me.OnExecuted(New TaskServiceEventArgs(task))
    Return result
End Function

We see that the task itself is performed after the task has been pushed onto the tasks Stack. Thereby allowing any chained tasks to be undone or repeated in the correct order.

Unit Tests

The TaskServiceTest class contains the unit tests for the entire Task Model. A behavior driven approach is invaluable when developing a component like this, as there are quite a few scenarios to test. This class is worth looking at for understanding some of the behavior that hasn't been covered in the demo application.

Figure: Task model unit test results

Conclusion

In this article we have seen how tasks, which are application work units, are able to be performed, undone, and repeated by a task management system. We saw how composite tasks, comprising any number of sub-tasks, can be performed sequentially or in parallel, and how tasks can be chained without compromising the undo/repeat capability. We then touched on the practical application of the Task Model to a simple diagramming tool. In the next part of the series, we will look at how the Task Model has been integrated into a WPF application, and explore the example diagram designer in greater detail. I hope you will join me then.

I hope you find this project useful. If so, then I'd appreciate it if you would rate it and/or leave feedback below. This will help me to make my next article better.

History

February 2010

  • First published.

License

This article, along with any associated source code and files, is licensed under The GNU Lesser General Public License (LGPLv3)

About the Author

Daniel Vaughan
President Outcoder
Switzerland Switzerland
Daniel Vaughan is a Microsoft MVP and cofounder of Outcoder, a Swiss software and consulting company dedicated to creating best-of-breed user experiences and leading-edge back-end solutions, using the Microsoft stack of technologies--in particular WPF, WinRT, and Windows Phone.
 
Daniel is the author of Windows Phone 7.5 Unleashed and Windows Phone 8 Unleashed, both published by SAMS.
 
Daniel is also the creator of a number of open-source projects, including Calcium SDK, and Clog.
 
Would you like Daniel to bring value to your organisation? Please contact

Daniel's Blog | MVP profile | Follow on Twitter
 
Windows Phone Experts
Follow on   Twitter   Google+   LinkedIn

Comments and Discussions

 
GeneralStunning! Pinmembermattiassundstrom24-Feb-10 22:08 
I can only say WOW! This article can't get anything else than a 5!
GeneralRe: Stunning! PinmvpDaniel Vaughan24-Feb-10 23:21 

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

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

| Advertise | Privacy | Mobile
Web02 | 2.8.140415.2 | Last Updated 24 Feb 2010
Article Copyright 2010 by Daniel Vaughan
Everything else Copyright © CodeProject, 1999-2014
Terms of Use
Layout: fixed | fluid