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;
},
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;
},
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;
},
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
{
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";
}
}
}
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.