Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

Context Sensitive History. Part 2 of 2

0.00/5 (No votes)
13 Mar 2010 17  
A Desktop and Silverlight user action management system, with undo, redo, and repeat; allowing actions 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.

Calcium screen shot


Contents

Introduction

In our first article we explored a Task Management system. We saw how tasks, which are application work units, are able to be performed, undone, and repeated. We also looked at how composite tasks, comprising any number of sub-tasks, can be performed sequentially or in parallel, and at how tasks can be chained without compromising the undo/repeat capability.

In this article we will see how the Task Model has been integrated into a WPF application. More specifically, into the Calcium application framework. We will see how undo, redo, and repeat can be performed on items in a rudimentary diagram designer, and we will also look at how multiple tasks can be performed, and undone as a group using composite tasks. We will also examine how Task Service operations can be wired to the UI, using enhanced versions of Prism's DelegateCommands and CompositeCommands.

If you haven't already done so, I strongly recommend reading the first article before this.

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.

Feature Recap

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

A Sample Designer

To demonstrate the Task Model, I have created the beginnings of a rudimentary diagram designer module for Calcium. The Designer uses the MVVM pattern heavily in that interaction with designer items, such as creating new items or moving items, is done via a ViewModelBase class that so happens to implement INotifyPropertyChanged. This makes our UI logic readily testable.

The structure of the Diagram Designer Module is shown below.

Figure: Diagram Designer Module conceptual layout

 

The DiagramDesignerView

The DiagramDesignerView presents designer items (little Calcium cubes) via an observable collection. We use an ItemsControl whose ItemsTemplate consists of a Canvas. This offers us some advantages over choosing to work directly with a Canvas. By using an ItemsControl combined with a Canvas we gain the best of two worlds. We have the capability to databind to an ObservableCollection of items, and we have the flexibility to place items using X Y coordinates in a Canvas.

<ItemsControl ItemsSource="{Binding Path=DesignerItems}">
    <ItemsControl.ItemsPanel>
        <ItemsPanelTemplate>
            <Canvas x:Name="Canvas_Root" 
                    ClipToBounds="True" 
                    SnapsToDevicePixels="True" Margin="2" 
                    Background="{DynamicResource ControlBackgroundBrush}" 
                    Thumb.DragStarted="Canvas_Root_DragStarted" 
                    Thumb.DragCompleted="Canvas_Root_DragCompleted" 
                    PreviewMouseDown="Canvas_PreviewMouseDown" />
        </ItemsPanelTemplate>
    </ItemsControl.ItemsPanel>
    <ItemsControl.ItemTemplate>
        <DataTemplate>
            <Module:DesignerHost >
                <ContentControl Content="{Binding}" 
                                ContentTemplateSelector="{StaticResource DesignerItemTemplateSelector}" />
            </Module:DesignerHost>
        </DataTemplate>
    </ItemsControl.ItemTemplate>
    <ItemsControl.ItemContainerStyle>
        <Style>
            <Setter Property="Canvas.Top" Value="{Binding Path=Top}" />
            <Setter Property="Canvas.Left" Value="{Binding Path=Left}" />
        </Style>
    </ItemsControl.ItemContainerStyle>
</ItemsControl>

We see that the location of a DesignerHost is actually determined by the Top and Left properties of the DesignerItemViewModel. It is the viewmodel that determines the location, which is useful because it makes testing, development, and persistence of the UI state simpler, when we don't have to worry about the life cycle of the UI controls.

The ItemTemplate produces DesignerHosts, and the DesignerHost plays, well, host to DesignerItems. DesignerItems are generated using a DesignerItemTemplateSelector. This serves to procure a template based on the viewmodel or indeed anything we like. Creating a designer is not the easiest of tasks, and in the past I have used sukrams Designer articles as a useful resource. I believe though that relying more on an MVVM approach can enhance things, and the new Calcium Diagram Designer is a nice base for future development.

 

Commands to interact with the Task Service

ICommands that enable interaction with the ITaskService are exposed via a TaskCommandProxies class. We use this class in binding to MenuItems and ToolBar buttons.

Figure: Commands are consumed via the TaskCommandProxies class.

Each CommandLogic class contains a UICommand instance. UICommands inherit from Prism's DelegateCommand, and provide us with two extra important features:

1. Reevaluation of CanExecute handlers by subscription to the System.Windows.Input.CommandManager's RequerySuggested event and the Calcium ActiveViewChangedInShellEvent.

2. A Text property for binding to menu items etc.

Prism's DelegateCommands are designed to be UI agnostic. As a consequence, reevaluation of CanExecute doesn't happen automatically like it does with WPF's RoutedCommands. So, we subscribe to the two events in order for the reevaluation to occur during interaction with the user interface.

In the Calcium DesktopShellView class there is some logic around the active view and active workspace (or document) view. The active view is deemed to be either the view with focus or the active workspace document (with a fallback mechanism to the other TabControl regions). A top level container in the window subscribes to UIElement.GotFocus RoutedEvent. When a control gets focus we reevaluate who the active view is; whether it be the Text Editor, Diagram Designer etc. When the active view is deemed to have changed, we publish the ActiveViewChangedInShell event. Also, when an ActiveAwareUIElementAdapter detects that a view has GotFocus, LostFocus, Loaded, Unloaded, it publishes a CompositePresentationEvent, which is handled in the IShellView. These measures allows for views to update their content etc. And in the case of our Task model, the Task command logic is reevaluated, which causes menu items to be disabled/enabled, and MenuItem.Header properties to be updated. So, we see that there is quite a bit of orchestration that takes place.

Figure: Command handlers that interact with the ITaskService are place in CommandLogic classes.

 

The task commands are data bound to menu items in the StandardMenu control.

<MenuItem Header="Edit" cal:RegionManager.RegionName="{x:Static Client:MenuNames.Edit}">
    <MenuItem Command="ApplicationCommands.Undo" />
    <MenuItem Command="ApplicationCommands.Redo" />
    <MenuItem Command="{Binding Source={x:Static TaskModel:TaskCommandProxies.RepeatTaskCommandLogic}, Path=Command}" 
	      Header="{Binding RelativeSource={RelativeSource Self}, Path=Command.Text}" />
    <MenuItem Command="{Binding Source={x:Static TaskModel:TaskCommandProxies.UndoGlobalTaskCommandLogic}, Path=Command}" 
              Header="{Binding RelativeSource={RelativeSource Self}, Path=Command.Text}" />
    <MenuItem Command="{Binding Source={x:Static TaskModel:TaskCommandProxies.RedoGlobalTaskCommandLogic}, Path=Command}" 
              Header="{Binding RelativeSource={RelativeSource Self}, Path=Command.Text}" />
    ...
</MenuItem>

Note the use of the Command.Text in the Command classes. Here we see that the text changes during evaluation of commands. For example, you will notice when adding a new item to the Diagram Designer that the menu item displaying "Repeat", changes to "Repeat Add Designer Item."

For regular undo and redo we rely on ordinary RoutedCommands: ApplicationCommands.Undo and ApplicationCommands.Redo. Calcium has an ICommandService that allows us to register handlers with the top level window (known as the shell in Prism parlance), as the following excerpt demonstrates:

commandService.AddCommandBinding(ApplicationCommands.Undo,
delegate
{
    var activeView = GetActiveView();
    if (activeView == null)
    {
        return false;
    }
    var taskService = ServiceLocatorSingleton.Instance.GetInstance<ITaskService>();
    var id = activeView.ViewModel != null ? (object)activeView.ViewModel.Id : activeView;
    var taskResult = taskService.Undo(id);
    return true; /* We do not allow the event to bubble. */
},
delegate
{
    var activeView = GetActiveView();
    if (activeView == null)
    {
        return false;
    }
    var taskService = ServiceLocatorSingleton.Instance.GetInstance<ITaskService>();
    var id = activeView.ViewModel != null ? (object)activeView.ViewModel.Id : activeView;
    var result = taskService.CanUndo(id);
    return result;
}, 
new KeyGesture(Key.Z, ModifierKeys.Control));

In fact, the wiring of the Undo and Redo command handlers for the TaskService is itself done in a Task called TaskServiceCommandRegistrationTask, which inherits from TaskBase and expects the ICommandService as a task argument during execution.

C#:
class TaskServiceCommandRegistrationTask : TaskBase<ICommandService>
{
	public TaskServiceCommandRegistrationTask()
	{
		Execute += OnExecute;
	}

	void OnExecute(object sender, TaskEventArgs<ICommandService> e)
	{
		ArgumentValidator.AssertNotNull(e, "e");
		var commandService = e.Argument;

		#region Undo with Context
		commandService.AddCommandBinding(ApplicationCommands.Undo,
			delegate
			{
				var activeView = GetActiveView();
				if (activeView == null)
				{
					return false;
				}
				var taskService = ServiceLocatorSingleton.Instance.GetInstance<ITaskService>();
				var id = activeView.ViewModel != null ? (object)activeView.ViewModel.Id : activeView;
				var taskResult = taskService.Undo(id);
				return true; /* We do not allow the event to bubble. */
			},
			delegate
			{
				var activeView = GetActiveView();
				if (activeView == null)
				{
					return false;
				}
				var taskService = ServiceLocatorSingleton.Instance.GetInstance<ITaskService>();
				var id = activeView.ViewModel != null ? (object)activeView.ViewModel.Id : activeView;
				var result = taskService.CanUndo(id);
				return result;
			},
			new KeyGesture(Key.Z, ModifierKeys.Control));
		#endregion

		#region Redo with Context
		commandService.AddCommandBinding(ApplicationCommands.Redo,
			delegate
			{
				var activeView = GetActiveView();
				if (activeView == null)
				{
					return false;
				}
				var taskService = ServiceLocatorSingleton.Instance.GetInstance<ITaskService>();
				var id = activeView.ViewModel != null ? (object)activeView.ViewModel.Id : activeView;
				var taskResult = taskService.Redo(id);
				return true; /* We do not allow the event to bubble. */
			},
			delegate
			{
				var activeView = GetActiveView();
				if (activeView == null)
				{
					return false;
				}
				var taskService = ServiceLocatorSingleton.Instance.GetInstance<ITaskService>();
				var id = activeView.ViewModel != null ? (object)activeView.ViewModel.Id : activeView;
				return taskService.CanRedo(id);
			},
			new KeyGesture(Key.Y, ModifierKeys.Control));
		#endregion
	}

	IView GetActiveView()
	{
		var viewService = ServiceLocatorSingleton.Instance.GetInstance<IViewService>();
		var activeView = viewService.ActiveView;
		return activeView;
	}

	public override string DescriptionForUser
	{
		get
		{
			/* TODO: Make localizable resource. */
			return "Register default Task Service commands";
		}
	}
}
VB.NET:
Friend Class TaskServiceCommandRegistrationTask
    Inherits TaskBase(Of ICommandService)

    Public Sub New()
        AddHandler MyBase.Execute, New EventHandler( _ 
			Of TaskEventArgs(Of ICommandService))(AddressOf Me.OnExecute)
    End Sub

    Private Function GetActiveView() As IView
        Return ServiceLocatorSingleton.Instance.GetInstance(Of IViewService).ActiveView
    End Function

    Private Sub OnExecute(ByVal sender As Object, ByVal e As TaskEventArgs(Of ICommandService))
        ArgumentValidator.AssertNotNull(Of TaskEventArgs(Of ICommandService))(e, "e")
        Dim commandService As ICommandService = e.Argument
        commandService.AddCommandBinding(ApplicationCommands.Undo, Function 
            Dim activeView As IView = Me.GetActiveView
            If (activeView Is Nothing) Then
                Return False
            End If
            Dim taskService As ITaskService = ServiceLocatorSingleton.Instance.GetInstance(Of ITaskService)
            Dim id As Object = IIf((Not activeView.ViewModel Is Nothing), _ 
				DirectCast(activeView.ViewModel.Id, Object), DirectCast(activeView, Object))
            Dim taskResult As TaskResult = taskService.Undo(id)
            Return True
        End Function, Function 
            Dim activeView As IView = Me.GetActiveView
            If (activeView Is Nothing) Then
                Return False
            End If
            Dim taskService As ITaskService = ServiceLocatorSingleton.Instance.GetInstance(Of ITaskService)
            Dim id As Object = IIf((Not activeView.ViewModel Is Nothing), _ 
				DirectCast(activeView.ViewModel.Id, Object), DirectCast(activeView, Object))
            Return taskService.CanUndo(id)
        End Function, New KeyGesture(Key.Z, ModifierKeys.Control))
        commandService.AddCommandBinding(ApplicationCommands.Redo, Function 
            Dim activeView As IView = Me.GetActiveView
            If (activeView Is Nothing) Then
                Return False
            End If
            Dim taskService As ITaskService = ServiceLocatorSingleton.Instance.GetInstance(Of ITaskService)
            Dim id As Object = IIf((Not activeView.ViewModel Is Nothing), _ 
				DirectCast(activeView.ViewModel.Id, Object), DirectCast(activeView, Object))
            Dim taskResult As TaskResult = taskService.Redo(id)
            Return True
        End Function, Function 
            Dim activeView As IView = Me.GetActiveView
            If (activeView Is Nothing) Then
                Return False
            End If
            Dim taskService As ITaskService = ServiceLocatorSingleton.Instance.GetInstance(Of ITaskService)
            Dim id As Object = IIf((Not activeView.ViewModel Is Nothing), _ 
				DirectCast(activeView.ViewModel.Id, Object), DirectCast(activeView, Object))
            Return taskService.CanRedo(id)
        End Function, New KeyGesture(Key.Y, ModifierKeys.Control))
    End Sub

    Public Overrides ReadOnly Property DescriptionForUser As String
        Get
            Return "Register default Task Service commands"
        End Get
    End Property

End Class

That's how we harness the regular Undo and Redo RoutedUICommands. If an Undo or Redo command bubbles all the way up to the main window, then it will trigger the command handlers for interacting with the ITaskService.

The Diagram Designer ToolBar

The DiagramDesignerToolBar is a ToolBar wrapped in a UserControl to enable design time support.

Readers already familiar with the Calcium SDK and the IViewService may like to skip ahead a couple of paragraphs. For those that aren't familiar, or who would like a recap, read on. Like other interface controls within the Calcium environment, the IViewService has been leveraged in order to tie its visible state with the content type DiagramDesignerView. When the DiagramDesignerView is the active workspace item, the ToolBar is shown. It's as simple as that. This is done by registering the ToolBar with the IViewService, as the following excerpt shows:

viewService.AssociateVisibility(typeof(DiagramDesignerView), new UIElementAdapter(toolBar), Visibility.Collapsed);

When the DiagramDesignerModule is initialized, the control is instantiated and the ToolBar detached, which happens to be a WPF requirement because the ToolBar can't be assigned to another parent container until this is done. This has been accomplished by creating a property in the user control which detaches the ToolBar like so:

public ToolBar ToolBar
{
    get
    {
        LayoutRoot.Children.Remove(toolBar_Main);
        return toolBar_Main;
    }
}
VB.NET:
Public ReadOnly Property ToolBar As ToolBar
    Get
        Me.LayoutRoot.Children.Remove(Me.toolBar_Main)
        Return Me.toolBar_Main
    End Get
End Property

The ToolBar itself is rudimentary, and simply has two buttons to demonstrate adding an item to the Diagram Designer View, and aligning all items in the view to the left.

<Button x:Name="Button_AddItem"
        Command="{x:Static Commands:DiagramDesignerCommands.AddDesignerItemCommand}"
        ToolTip="{Binding RelativeSource={RelativeSource Self}, Path=Command.Text}"
        Width="18" Height="18">
    <Canvas>...</Canvas>
</Button>

<Button x:Name="Button_AlignLeft"
        Command="{x:Static Commands:DiagramDesignerCommands.AlignLeftCommand}" 
        ToolTip="{Binding RelativeSource={RelativeSource Self}, Path=Command.Text}"
        Width="18" Height="18">
    <Canvas>...</Canvas>
</Button>

As with the Undo Redo MenuItems presented earlier, we use a proxy called DesignerDiagramCommands, to bind the relevant commands to the ToolBar buttons. The difference, however, is that the proxy exposes Prism CompositeCommands. (Read more on CompositeCommands and Prisms Command Model) The DiagramDesignerViewModel registers a couple DelegateCommands via the proxy. These commands make use of the viewmodel's ability to know if it is active (has user focus) or not. I discuss more about how this is implemented in Calcium here.

The following excerpt from the DiagramDesignerViewModel shows how the DelegateCommands are wired up.

C#:
var commandsProxy = new DiagramDesignerCommandsProxy();

addDesignerItemCommand = new UICommand<object>(AddDesignerItem, arg => Active);
commandsProxy.AddDesignerItemCommand.RegisterCommand(addDesignerItemCommand);

alignLeftCommand = new UICommand<object>(OnAlignLeft, arg => Active && designerItems.Count > 0);
commandsProxy.AlignLeftCommand.RegisterCommand(alignLeftCommand);
VB.NET:
Dim commandsProxy As New DiagramDesignerCommandsProxy
Me.addDesignerItemCommand = New UICommand(Of Object)(New Action(Of Object)(AddressOf Me.AddDesignerItem), _ 
    Function (ByVal arg As Object) 
        Return MyBase.Active
    End Function)
commandsProxy.AddDesignerItemCommand.RegisterCommand(Me.addDesignerItemCommand)
    
Me.alignLeftCommand = New UICommand(Of Object)(New Action(Of Object)(AddressOf Me.OnAlignLeft), _ 
    Function (ByVal arg As Object) 
            Return (MyBase.Active AndAlso (Me.designerItems.Count > 0))
    End Function)
    
commandsProxy.AlignLeftCommand.RegisterCommand(Me.alignLeftCommand)
AddHandler MyBase.ActiveChanged, New EventHandler(AddressOf Me.OnActiveChanged)

Moving a Designer Item

Moving a designer item is a little odd. The first time the task is executed it actually doesn't do anything, because it is the user that moves the designer item with the mouse. When the move is undone, and then redone however, the task performs the move. We perform the task like so:

C#:
var task = new MoveDesignerHostTask();
var args = new MoveDesignerHostArgs(viewModel, startPoint, endPoint);
taskService.PerformTask(task, args, Id);
VB.NET:
Dim task As New MoveDesignerHostTask
Dim taskService As ITaskService = ServiceLocatorSingleton.Instance.GetInstance(Of ITaskService)
Dim args As New MoveDesignerHostArgs(viewModel, startPoint, endPoint)
taskService.PerformTask(Of MoveDesignerHostArgs)( _ 
	DirectCast(task, UndoableTaskBase(Of MoveDesignerHostArgs)), args, MyBase.Id)

We create the task, assemble some data that the task will need for executing and possibly undoing the task, then we feed it to the ITaskService.

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)

    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

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

    Private point As Point?
End Class

Conclusion

In this article we have seen how the Task Model is able to be integrated into a WPF application; specifically the Calcium application framework. We have seen how undo, redo, and repeat can be performed on items in a rudimentary diagram designer, and we looked at how multiple tasks, such as moving designer items, can be performed, and undone as a group using composite tasks. We also examined how Task Service operations can be wired to the UI, using enhanced versions of Prism's DelegateCommands and CompositeCommands. This brings us to the end of this two part series, and I hope that you found it 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

March 2010

First published.

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here