[Actions]

Introduction

Sunday, April 27, 2003

Here I'll talk about how to implement some sort of (or alternative to) the "command" pattern in VB.NET. It's similiar to Delphi's Action lists, but it's going to be "code-only" (of course you can make the IDE work with the classes here, but that's another story).

So why use action lists? Well, a modern UI gives the user several ways of doing things, for example, click a toolbar button, select a command from the menu, or use a context menu, and so on. So it's about saving work: define your actions/commands once, and have consistent names and tooltips across the application. This approach also allows for easy customization by the user. If certain actions should be disabled, you just have to set a property once, and the UI will update automatically. The same goes for captions and tooltips.

On the download page, get the UICore project, which contains everything discussed in this and the next topics.

General design

We'll create a custom collection class called "NActions", which derives from the class NameObjectCollectionBase in the System.Collections namespace (see "Collections" for more info on this approach). This allows us to access actions either by key or by index.

Then there is "CAction", a class that encapsulates about everything related to commands at the user level: captions, tooltips, invokeability (Enabled property), delegates for executing the command and updating UI cues, and potentially icon information (which is discussed in the topic Menues as far as menues are concerned; sure you'll know how to apply that to a toolbar).

That's the most generic part. Then follow several classes that cooperate with CAction, all of which represent such parts of the user interface as can trigger commands (controls mostly), for example menu items, toolbar buttons, and so on. Consequently, these classes derive from respective control classes. I'll talk about the trade-offs between this method and interfaces later. These classes are responsible for reacting to events and mapping them to actions (invoking delegates). They'll also listen to action objects for status changes. Anyway, I'll show you three classes of that sort (CActionMenu, CActionToolbarButton, and CActionButton), but you can create other classes that do the same for other controls, or create controls that deal with CAction objects as well.

When you create an application, you first fill an action list with appropriate actions. These actions are assigned handlers for executing the command, and for updating UI cues. Then you create, for example, menu items and assign actions to them. The same goes for toolbar buttons. That's it.

Implementation

NActions collection

NActions is fairly straightforward. Just let me mention that the base class doesn't ensure unique keys, so you'll want to do that in the add method. I'll just use the actions' captions as keys, which implies you cannot have several commands with the same caption. Mostly, that's OK; otherwise you need to change the Add methods to take something else as a key, or not use keys alltogether. Also have a look at the secondary constructor:

Public Class NActions
	Inherits System.Collections.NameObjectCollectionBase

	Sub New
		MyBase.New
	End Sub
	Sub New(ParamArray ByVal actions() As CAction)
		MyBase.New
		Dim i As Integer
		For i = 0 To UBound(actions)
			MyClass.Add(actions(i)
		Next i
	End Sub

	Public Overloads Sub Add(ByVal action As CAction)
		If MyBase.BaseGet(action.Caption) Is Nothing Then 
				MyBase.BaseAdd(action.Caption, action)
			Else 
				Throw New ArgumentException 
		End If 
	End Sub
	Public Overloads Function Add(ByVal sText As String, ByVal sHint As String) As CAction
	' ...
	Public Overloads Function Add(ByVal sText As String, ByVal execHandler As DExecHandler) As CAction
	' ...
	Public Overloads Function Add(ByVal sText As String, ByVal sHint As String, ByVal execHandler As DExecHandler, ByVal hintHandler As DHintHandler) As CAction
	' ...

	' Don't forget the Item method (overloaded - both key and index)

End Class

The second through fourth Add methods map to CAction's constructors. Note that when a new CAction object is created, the Add methods return a reference to it (when an existing one is added, there's no need). Of course, there's more to CAction:

CAction class

The Delegates

CAction defines three delegate types (see "Delegates" for more info), and one reference of each type per object. The m_ExecHandler(a DExecHandler) and m_HintHandler (a DHintHandler) delegates will reference procedures provided by the application, whereas m_StatusHandler (a DStatusHandler, actually a multicast delegate) will point to methods provided by the above-mentioned cooperating control classes (like CActionMenu).

The purpose of the delegate referenced by m_ExecHandler is to execute the command; all CAction objects can talk to the same handler, but the point about using delegates is to map each action to a different target in the programm. Using delegates, there is no need to create a custom class for each action (which would implement an "ICommand" interface, or even derive from a "CommandBase" class). The m_ExecHandler is usually assigned when a CAction object is created. You can also switch handlers at runtime.

The m_HintHandler delegate updates the user interface for cues about the action. Mostly, this will update the statusbar's text. It is not necessary to provide a hint handler; cooperating control classes can also display the hint as a tooltip (as shown later). It's up to those classes (like CActionMenu) to decide whether to call the DHintHandler delegate at all. Again, this delegate is usually assigned at creation.

The m_StatusHandler delegate should be assigned when an instance of a cooperating class (like CActionMenu) is assigned a reference to a CAction object. The point is to update the user interface (disable a menu item, change a button's caption, etc.) when the programm changes a property of an CAction object (like disabling a command).

Here are the delegates:

Public Delegate Function DExecHandler(ByVal action As CAction) As Boolean
Public Delegate Sub DHintHandler(ByVal action As CAction)
Public Delegate Sub DStatusHandler(ByVal action As CAction)
Private m_ExecHandler As DExecHandler
Private m_HintHandler As DHintHandler
Private m_StatusHandler As DStatusHandler

Both m_ExecHandler and m_HintHandler can be changed by property procedures. As the m_StatusHandler delegate can actually invoke several procedures (as one action can be invoked from different point in the UI), it is a multicast delegate. Therefore, two methods are provided to assign and remove handlers. These methods will be called by cooperating classes like CActionMenu:

Public Sub AddStatusHandler(ByVal statusHandler As DStatusHandler)
	m_StatusHandler = DirectCast(System.Delegate.Combine(m_StatusHandler, statusHandler), DStatusHandler)
End Sub
Public Sub RemoveStatusHandler(ByVal statusHandler As DStatusHandler)
	m_StatusHandler = DirectCast(System.Delegate.Remove(m_StatusHandler, statusHandler), DStatusHandler)
End Sub

CAction properties

CAction encapsulates the following fields, all of which are accessible by property procedures:

Private m_Text As String
Private m_Hint As String
Private m_Enabled As Boolean

When one of these properties changes, the m_StatusHandler multicast delegate is invoked:

Public Property Enabled As Boolean
	Get
		Return m_Enabled
	End Get	
	Set
		m_Enabled = value
		If Not m_StatusHandler Is Nothing Then m_StatusHandler.Invoke(Me)
	End Set	
End Property

This will notify, for example, CActionMenu objects that have registered a DStatusHandler delegate with this action. Therefore, when, somewhere in the programm, an action is disabled, all UI elements will react and take steps defined in classes like CActionMenu.

CAction methods

Classes like CActionMenu react to user input; they will call CAction's methods as needed. The assigned CAction object will then invoke either m_ExecHandler or m_HintHandler:

Public Function Execute As Boolean
	If Not m_ExecHandler Is Nothing Then Return m_ExecHandler.Invoke(Me)
End Function
Public Sub ShowHint
	If Not m_HintHandler Is Nothing Then m_HintHandler.Invoke(Me)
End Sub

Of course, the ShowHint method will be called on certain event (like rollover events in menu items).

CAction constructors

CAction has several constructors; I'll just show the most complicated one (the signature for the others is shown above with the NActions collection):

Public Overloads Sub New(ByVal sText As String, ByVal sHint As String, ByVal execHandler As DExecHandler, ByVal hintHandler As DHintHandler)
	MyBase.New
	m_Enabled = True
	m_Text = sText 
	m_Hint = sHint 
	m_ExecHandler = execHandler 
	m_HintHandler = hintHandler
End Sub

Customizing CAction

CAction objects invoke functions in your program whose signatures match that of the DExecHandler delegate. However, the methods and functions actually called might not comply with that, unless you design everything to be consistent with this pattern here. But this is hardly always possible, nor is it desirable.

In any case, to address the signature problem, there are several options:

Cooperating classes

Now that we've got the CAction class, we need some classes that talk with it. The idea is to derive from existing controls and handle the events in the subclass, then call CAction methods as needed. CActionMenu derives from MenuItem; when you instantiate it, you pass an instance of CAction (the action is exposed via a property, too; not shown):


Public Class CActionMenu
	Inherits System.WinForms.MenuItem

	Private m_Action As CAction 

	Public Overloads Sub New
		MyBase.New
	End Sub
	Public Overloads Sub New(ByVal action As CAction)
		MyBase.New
		MyBase.Text = action.Caption
		MyBase.Enabled = action.Enabled
		m_Action = action
		m_Action.AddStatusHandler(AddressOf Me.HandleStatus)
	End Sub

	Protected Overrides Sub OnClick(ByVal e As System.EventArgs)
		MyBase.OnClick(e)
		m_Action.Execute
	End Sub
	Protected Overrides Sub OnSelect(ByVal e As System.EventArgs)
		MyBase.OnSelect(e)
		m_Action.ShowHint
	End Sub
	Private Sub HandleStatus(ByVal action As CAction)
		MyBase.Text = action.Caption
		MyBase.Enabled = action.Enabled
	End Sub

End Class

On creation, a reference to the action is saved. Then, a DStatusHandler delegate is registered with the action, so the object can react to status changes (disabling etc.). Also, you can see how event handlers for menu clicks aren't needed in the programm, as here the protected (event-raiser) subs "OnClick" and "OnSelect" are overridden. So menu items of the CActionMenu class that are connected to CAction objects take care of event handling themselves. The CAction objects invoke delegates in the program.

Other implementations

Here is how you can use ordinary buttons with CAction objects. The class, CActionButton, differs from CActionMenu in that the hint (via DHintHandler) is shown when the user requests help (i.e., presses F1):

Protected Overrides Sub OnHelpRequested(ByVal hevent As System.WinForms.HelpEventArgs)
	MyBase.OnHelpRequested(hevent)
	m_Action.ShowHint
End Sub

A little different still is the CActionToolbarButton class. It uses the Hint text from the action as a tooltip:

Public Overloads Sub New(ByVal action As CAction)
	MyBase.New
	MyBase.Text = action.Caption
	MyBase.ToolTipText = action.Hint
	MyBase.Enabled = action.Enabled
	m_Action = action
	m_Action.AddStatusHandler(AddressOf Me.HandleStatus)
End Sub

Private Sub HandleStatus(ByVal action As CAction)
	MyBase.Text = action.Caption
	MyBase.ToolTipText = action.Hint 
	MyBase.Enabled = action.Enabled
End Sub

A drawback here is that the ToolBarButton class does not allow us to sink events or overwrite protected event-raiser subs (which it doesn't have). Rather, the containing ToolBar class fires all the events. To work arround this issue, the client could write a handler and call the actions via the button references received. But it's better to create our own descendent from ToolBar that calls the actions associated with the buttons:

Class CActionToolbar

	Public Sub New()
		MyBase.New
	End Sub

	Protected Overrides Sub OnButtonClick(ByVal e As ToolBarButtonClickEventArgs)
		DirectCast(e.button, CActionToolbarButton).Action.Execute
	End Sub

End Class

Note that there is no Select-Case here. The EventArgs instance lets us access the button and thus the action, so we can call the Execute method, which will do the right thing and call the appropriate handler.

Using these classes

The following code shows how to use these classes in your program. Start by creating actions, then creating menu items and toolbar butttons that reference these actions. Also create handlers to update the UI for cues (statusbar), and the actual handlers for the commands:

Private m_Actions As NActions
Private m_AcsRecent As NActions
Private m_Toolbar As CActionToolbar
Private m_Status As StatusBar

Private Sub InitializeComponent()
	' create two action lists
	m_Actions = New NActions
	m_AcsRecent = New NActions 
	m_Actions.Add("File", "Contains commands for working with files", Nothing, AddressOf Me.HandleHint)
	m_Actions.Add("Open ...", "Opens a file", AddressOf Me.HandleAction, AddressOf Me.HandleHint)
	m_Actions.Add("Save", "Saves the file", AddressOf Me.HandleAction, AddressOf Me.HandleHint)
	m_Actions.Add("Recent", "List of recently used files", Nothing, AddressOf Me.HandleHint)
	m_Actions.Add("Edit", "Contains editing commands", Nothing, AddressOf Me.HandleHint)
	m_Actions.Add("Undo", "Undoes the last editing command", AddressOf Me.HandleUndo, AddressOf Me.HandleHint)
	m_Actions.Add("Cut", "Cuts the selection into the clipboard", AddressOf Me.HandleCutCopy, AddressOf Me.HandleHint)
	m_Actions.Add("Copy", "Copies the selection into the clipboard", AddressOf Me.HandleCutCopy, AddressOf Me.HandleHint)
	m_Actions.Add("Paste", "Pastes the clipboard's contents into the selection", AddressOf Me.HandleAction, AddressOf Me.HandleHint)
	m_Actions("Paste").Enabled = False
	m_AcsRecent.Add("Tips.txt", "C:\My Documents\Tips.txt", AddressOf Me.HandleAction, AddressOf Me.HandleHint)
	m_AcsRecent.Add("ReadMe.txt", "C:\Windows\ReadMe.txt", AddressOf Me.HandleAction, AddressOf Me.HandleHint)
	m_AcsRecent.Add("AutoExec.bat", "C:\AutoExec.bat", AddressOf Me.HandleAction, AddressOf Me.HandleHint)
	' menu
	Me.Menu = New MainMenu
	With Me.Menu.MenuItems
		.Add(New CActionMenu(m_Actions("File")))
		With .Item(.Count - 1).MenuItems
			.Add(New CActionMenu(m_Actions("Open ...")))	' access action by key ...
			.Add(New CActionMenu(m_Actions("Save")))
			.Add(New CActionMenu(m_Actions("Recent")))
			With .Item(.Count - 1).MenuItems
				.Add(New CActionMenu(m_AcsRecent(0)))	' ... or by index
				.Add(New CActionMenu(m_AcsRecent(1)))
				.Add(New CActionMenu(m_AcsRecent(2)))				
			End With
			.Add(New CActionMenu(New CAction("Exit", "Exits the program", AddressOf Me.HandleExit, AddressOf Me.HandleHint))) 
		End With
		.Add(New CActionMenu(m_Actions("Edit")))
		With .Item(.Count - 1).MenuItems
			.Add(New CActionMenu(m_Actions("Undo")))
			.Add(New CActionMenu(New CAction("-")))
			.Add(New CActionMenu(m_Actions("Cut")))
			.Add(New CActionMenu(m_Actions("Copy")))
			.Add(New CActionMenu(m_Actions("Paste")))			
		End With		
	End With	
	' context menu
	Me.ContextMenu = New ContextMenu
	With Me.ContextMenu.MenuItems
		.Add(New CActionMenu(m_Actions("Undo")))     
		.Add(New CActionMenu(New CAction("-")))
		.Add(New CActionMenu(m_Actions("Cut")))
		.Add(New CActionMenu(m_Actions("Copy")))
		.Add(New CActionMenu(m_Actions("Paste")))  
		.Add(New CActionMenu(New CAction("-"))) 
		.Add(New CActionMenu(m_Actions(1)))
		.Add(New CActionMenu(m_Actions(2)))
	End With    
	' toolbar
	Dim tbn As CActionToolbarButton
	m_Toolbar = New CActionToolbar
	m_Toolbar.Dock = DockStyle.Top
	m_Toolbar.BorderStyle = BorderStyle.None
	m_Toolbar.Appearance = ToolBarAppearance.Flat 
	Me.Controls.Add(m_Toolbar)
	tbn = New CActionToolbarButton(m_Actions(1))
	tbn.Style = ToolBarButtonStyle.DropDownButton
	tbn.DropDownMenu = New ContextMenu 
	With tbn.DropDownMenu.MenuItems
		.Add(New CActionMenu(m_Actions("Open ...")))
		.Item(0).DefaultItem = True 
		.Add(New CActionMenu(m_Actions("Recent")))
		With .Item(.Count - 1).MenuItems
			.Add(New CActionMenu(m_AcsRecent(0)))
			.Add(New CActionMenu(m_AcsRecent(1)))
			.Add(New CActionMenu(m_AcsRecent(2))) 
		End With
	End With
	m_Toolbar.Buttons.Add(tbn)
	tbn = New CActionToolbarButton(m_Actions("Undo"))
	m_Toolbar.Buttons.Add(tbn)
	' buttons
	Dim bn As CActionButton
	bn = New CActionButton(m_Actions(1))
	bn.Visible = True: bn.Left = 10: bn.Top = 40: bn.Width = 60: bn.Height = 20
	Me.Controls.Add(bn)
	bn = New CActionButton(m_Actions(2))
	bn.Visible = True: bn.Left = 70: bn.Top = 40: bn.Width = 60: bn.Height = 20
	Me.Controls.Add(bn)
	bn = New CActionButton(m_Actions("Undo"))
	bn.Visible = True: bn.Left = 130: bn.Top = 40: bn.Width = 60: bn.Height = 20
	Me.Controls.Add(bn)
	' create statusbar
	m_Status = New StatusBar
	m_Status.Dock = DockStyle.Bottom
	Me.Controls.Add(m_Status)
	' form stuff
    	Me.Text = "FCommand"
End Sub


' generic execute handler (for testing)
Private Function HandleAction(ByVal action As CAction) As Boolean
	MsgBox(action.Caption)
	m_Status.Text = Nothing
	Return True
End Function
' handle hints
Private Sub HandleHint(ByVal action As CAction)
	m_Status.Text = action.Hint
End Sub
' clear statusbar
Protected Overrides Sub OnMenuComplete(ByVal e As System.EventArgs)
	m_Status.Text = Nothing
End Sub
' special handlers
Private Function HandleExit(ByVal action As CAction) As Boolean
	Application.Exit
	Return True
End Function
' these will change several controls
Private Function HandleUndo(ByVal action As CAction) As Boolean
	With action
		.Caption = "(Undo)"
		.Hint = "Can't undo right now"
		.Enabled = False 
	End With
	Return True
End Function
Private Function HandleCutCopy(ByVal action As CAction) As Boolean
	With m_Actions("Paste")
		.Enabled = True
	End With
End Function

Summary

This pattern, as implemented by NActions, CAction as most generic classes and CActionMenu, CActionButton, and CActionToolbarButton as intermediate classes lets you easily create a user interface where commands are consistent and are guaranteed to reflect status changes. You can extend these classes to take care of icons, too.

Note that the actions here are objects that describe commands in a prototypical way: one action is something that can be executed many times; it's not something like an event or even a transaction. If you need data pertaining to a single invocation of a command, use a different class (derived from System.EventArgs), and pass an instance of it to the Exec event handler (this isn't compatible with the expamle here, but it's shown in the UICore project in the Gregor.NET series).

For the user interface problems discussed here, it is often suggested to employ the command pattern. I disagree with that. The advantages of the command pattern, like undo, are of little relevance to a typical Windows application. The commands one would undo are better handled by the RichTextBox control. You wouldn't want to undo most UI commands if you think about it (Tools/Settings, View/StatusBar, Help/About, File/Exit, etc.).

What's more important, is the problem of inheritance, bloated namespaces, and twisting the problem to fit the solution instead of vice versa. I'm talking about the idea of implementing virtually everything that has to do with the actual execution of a command in another class specific to it, that is, having one class per command. An application's logic should not be twisted because of a narrow-minded hold-on to the choice of a particular design pattern. What this comes down to, practically, is a berserk after-the-fact justification of having created new classes, by throwing into them all sorts of tasks (like UI state updates, which should never be designed top-down), flagrantly ignoring all the rules of event-driven programming. Yes, that is as though developers still got paid by the number of lines of code they stomp out.

Just like an object can representing anything, there's a design pattern for everything, and the command pattern used for user interface commands is a telling (though not the worst) example of object orientation out of control. If the command objects provide the actual implementation (as actual as implementation can get in a high-level language), it really comes down to OO, functional style: different command objects scattered in many files working on the same objects in the program. If we end up modelling functional units (read: functions) with classes, we might as well just use - functions.

There is, though, the event handler signature problem discussed above. And the good news is that neither a Select-Case statement, nor its OO solution (implicit dynamic dispatch over virtual methods), which I don't think is suitable here, are necessary. Delegates provide the flexibility that allows us to follow the most important rule for writing maintainable code: specify things only once, and place what's related togethter. All that's needed is creating an action object, specifying text, icon, hint, and an event handler, which simply forwards a call.

If you need special action objects, you can still inherit from CAction, because you have thus far avoided forking the class. In other words, CAction can be specialized in a way that cannot yet be foreseen. Inheritance is a powerful, but precious tool. All in all, the new Delegate feature in .NET enables a more streamlined approach that doesn't bloat the project namespace. Because delegates - ultimately - use the same technique as virtual methods (that would be function pointers), they can add flexibility at a much cheaper cost.