Giving users what they want, when they want
Between them, David and Eric have developed everything from Windows apps for travel agents to electronic publishing software. They can be contacted at scofielddaveqwest.net and ericterrellcomcast.net, respectively.
You can satisfy some of your users all of the time, and all of your users some of the time. But how can you satisfy all of your users all of the time? A given user often has unique needs that cannot be accommodated by a single application developed for a large user community. And it's usually not feasible to create custom versions of your product for individual users. But how can you accommodate your users' unique needs in your application? Well, the answer is by letting them create the custom features that they require. In this article, we show how to develop applications that offer user-created toolbar buttons and menu items that launch custom features written in dynamically compiled C# or Visual Basic.NET. In the process, we present a sample application called "Sample App" (available electronically; see "Resource Center," page 5) that demonstrates all of the techniques covered in this article.
The Sample Application
The sample application is a .NET Windows Forms program written in C#. It requires the .NET 1.1 Framework and Windows 2000/XP or later. Build it in Visual Studio .NET 2003 (by opening SampleApp\SampleApp.sln) or install it by running the setup.exe program in the Setup folder. In any case, launch Sample App and try it out.
Sample App's custom toolbar buttons and menu items are generated from custom code (Figure 1). Press Edit Code to see the custom code (Figure 2). When you make changes to the custom code and press OK, the code is recompiled and the custom toolbar buttons and menu items are regenerated.
Press the Edit Code button and add Example 1 as the first class inside of the NS namespace declaration. Now press the OK button. The custom code is compiled and the toolbar redrawn with a new "Hello World" button. The HelloToolbarButton constructor specifies the button's caption and tool-tip. The GetImage method specifies the button's graphic. DoAction contains the code that is called when the user presses the toolbar button.
Select Options/Settings to configure Sample App. The Assemblies tab specifies the assemblies that your custom code references. For example, since the HelloToolbarButton class uses the Windows Forms MessageBox class, the Assemblies tab must include the system.windows.forms.dll assembly. Use the Misc. tab to specify compilation options (debugging/tracing, optimization, warning level, and so on).
Dynamic Compilation
The sample application's InMemoryCompiler class takes care of all the details of dynamic C# code compilation. It compiles C# code to an in-memory assembly (DLL) and checks for compiler errors; see Listing One.
Because compilation takes time, it's performed only if the source code has changed since the previous compilation. The first step is to instantiate a CSharpCodeProvider object. Next, an ICodeCompiler object is created by calling CreateCompiler(). Then a CompilerParameter object is created by calling CreateCompilerParameters. This CompilerParameter object specifies that the source code will be compiled into an in-memory assembly. It also includes the settings specified in the Options/Settings Assemblies and Misc. tabs.
Calling CompileAssemblyFromSource takes the source code and CompilerParameter object and creates an in-memory assembly, provided there are no errors. If there are no errors, Compile returns a value of true. Otherwise, it returns false and the compilerError parameter holds the first error.
To modify Sample App to compile VB.NET code, make the following changes:
- In InMemoryCompiler.Compile, change the CSharpCodeProvider to a VBCodeProvider.
- In EditCodeForm.UpdateClassesComboBox, change the regular expression pattern to: String pattern = "[\s] +Class[\s]+" + type.Name;.
- Convert the custom code to VB.NET.
Creating Custom GUIs
The custom toolbar buttons and menu items are dynamically generated by inspecting the compiled assembly and looking for classes that implement specific interfaces.
A custom toolbar button is created for each class that implements the ICustomToolBarButton interface. The button has the appearance and behavior specified by that class. Listing Two presents the ICustomToolBarButton interface and CustomToolBarButton class.
The ICustomMenuItem interface declares the properties and methods that are implemented by each custom menu object (Listing Three). This interface provides menu text and shortcuts as well as overrideables for the Select and Click menu methods. One additional interesting feature of the ICustomMenuItem is that it has properties and methods that let the menu item be owner-drawn. Sample App's Textbox Settings menu contains owner-drawn child menu items.
Finding Classes in an Assembly with Reflection
To create custom toolbar buttons and menu items, you need to search the in-memory assembly for classes that implement the ICustomToolBarButton and ICustomMenuItem interfaces. The .NET Reflection API is perfect for this purpose. There are two ways to get type information from an assemblyAssembly.GetTypes and Assembly.GetExportedTypes. Since we're only interested in public classes, we use GetExportedTypes. This method returns an array containing all of the assembly's public types. We then inspect each type to see if it implements the desired interface. This is done by calling Type.GetInterface.
Sample App's UpdateProgrammableToolbarButtons method populates the toolbar with custom buttons (Listing Four). It first removes all existing toolbar buttons. Then it calls inMemoryCompiler.GetTypes, which in turn calls GetExportedTypes to construct an array of all the assembly's public classes. It creates a custom toolbar button for each class that implements the ICustomToolBarButton interface. Then it needs to instantiate an ICustomToolBarButton object and call that object's SaveAPI and GetImage methods, and access the object's Text and Style properties. A handy class called Activator creates an instance of this class by using the CreateInstance method.
Unlike a toolbar, which is just a linear list of toolbar buttons, menus can contain cascading menu items. For example, Sample App's Custom Menu Items menu contains a Textbox Settings cascading menu, which in turn contains several menu items (Red, Blue, and so on). Because menus can contain nested submenus, Sample App uses nested classes to represent custom menu items. For example, the TextBoxSettingsMenuItem class is not a nested class, but it contains nested classes for each menu item nested below the Textbox Settings menu item (RedMenuItem, BlueMenuItem, and the like).
Sample App needs to determine if a class is nested to generate custom menus. Consider a nonnested ParentClass that contains a nested ChildClass. The Type.FullName property of ParentClass is simply "ParentClass." The Type.FullName property of ChildClass is "ParentClass+ChildClass." Consequently, Sample App knows that a class is nested if its Type.FullName property contains at least one "+" character.
Listing Five shows how Sample App creates its custom menu items. The UpdateProgrammableMenus method first clears the custom main menu. Then it creates an ArrayList of ICustomMenuItem objects that are not nested. Finally, the array is passed to the method GetChildMenus, which creates each menu item and recursively creates any subordinate menu items.
Developing Custom Code
When developing custom code, there needs to be a two-way calling capability. The application needs to be able to call custom code, and the custom code needs to be able to call methods and properties in the application. The IAPI interface contains all of the methods and properties in Sample App that custom code can access. When each custom toolbar button and menu item object is created, the object's SaveAPI method is called. SaveAPI saves a reference to the IAPI interface in the object. The object's custom code can call the application's API by using its iapi member. For example, a custom toolbar button could launch Sample App's about box by calling iapi.DisplayAboutBox.
The standard .NET debugging APIs are available to custom code. Select Options/Settings and check "Allow debugging features" in the Misc. tab to enable Debug.Assert. Check "Allow tracing features" to enable Trace.WriteLine calls in custom code. The Sample App's trace feature uses a TraceListener to intercept Trace calls and display them in the View/Trace Window dialog box. See the code for TraceUtil (available electronically) for the details. Since custom code can throw exceptions, Sample App has a handler for the Application.ThreadException event. MainForm.Application_ThreadException inspects its exception argument. If the exception was thrown from custom code, it displays the exception information and a stack trace in a RuntimeErrorForm. Otherwise, if the exception was thrown by the application itself, it rethrows the exception and relies on standard exception processing.
Conclusion
If your users are demanding custom features that aren't feasible to include in your product, use the techniques we've discussed to make your application customizable. Dynamic compilation and Reflection make it easy to implement custom features. What are you waiting for?
DDJ
// Compile the specified source code to an in-memory assembly. public bool Compile(String sourceCode, out CompilerError compilerError) { bool result = true; compilerError = null; // Only compile if source code has changed since previous compilation. if (sourceCode != compiledSourceCode) { CSharpCodeProvider cSharpProvider = new CSharpCodeProvider(); ICodeCompiler codeCompiler = cSharpProvider.CreateCompiler(); // Create a CompilerParameters object that specifies assemblies referenced // by the source code and the compiler options chosen by the user. CompilerParameters compilerParameters = CreateCompilerParameters( SerializeConfiguration.Settings.assemblies); compiledSourceCode = null; try { // Compile the source code. compilerResults = codeCompiler.CompileAssemblyFromSource(compilerParameters, sourceCode); // Check for errors. if (compilerResults.Errors.Count > 0) { compilerError = compilerResults.Errors[0]; result = false; } else { // Keep track of the source code that was compiled. compiledSourceCode = sourceCode; } } catch (Exception) { MessageBox.Show("Cannot compile source code", Application.ProductName, MessageBoxButtons.OK, MessageBoxIcon.Error); result = false; } } return result; }Back to article
Listing Two
public interface ICustomToolBarButton : IAPIUser { ... } public class CustomToolBarButton : ICustomToolBarButton { // Reference to the program's API. protected IAPI iapi; // The code that creates a CustomToolBarButton will call this method // and pass in an object that implements the IAPI interface. public void SaveAPI(IAPI iapi) { this.iapi = iapi; } // Button's caption. private String text; public String Text { get { return text; } set { text = value; } } // Button's Tool-tip text private String toolTipText; public String ToolTipText { get { return toolTipText; } set { toolTipText = value; } } // Button's style public virtual ToolBarButtonStyle Style { get { return ToolBarButtonStyle.PushButton; } } // Button's graphic, no graphic by default. public virtual Image GetImage() { return null; } // Do the action to be performed when the button is pressed. public virtual void DoAction(object sender, System.EventArgs e) { MessageBox.Show(iapi.TheMainForm, text); } }Back to article
Listing Three
public interface ICustomMenuItem : IAPIUser { ... } public class CustomMenuItem : ICustomMenuItem { // Reference to the program's API. protected IAPI iapi; // The code that creates a CustomMenuItem will call this method and // pass in an object that implements the IAPI interface. public void SaveAPI(IAPI iapi) { this.iapi = iapi; } // Menu's text private String text; public String Text { get { return text; } set { text = value; } } // Menu's shortcut, if any. private Shortcut menuShortcut = Shortcut.None; public Shortcut MenuShortcut { get { return menuShortcut; } set { menuShortcut = value; } } // Default click method public virtual void Click(object sender, System.EventArgs e) { MessageBox.Show(iapi.TheMainForm, text); } // Click event handler public virtual EventHandler ClickEventHandler { get { return new EventHandler(Click); } } // Default code to run when user highlights this custom menu item public virtual void Select(object sender, System.EventArgs e) { iapi.TheStatusBar.Text = text.Replace("&", ""); } // Select event handler public virtual EventHandler SelectEventHandler { get { return new EventHandler(Select); } } ... }Back to article
Listing Four
// Re-load toolbar with custom toolbar buttons private void UpdateProgrammableToolbarButtons() { // Remove custom toolbar buttons, they will be re-added below. customToolbar.Buttons.Clear(); if (customToolbar.ImageList != null) { // Need to re-create the toolbar's image list // or button graphics will be incorrect. customToolbar.ImageList.Dispose(); customToolbar.ImageList = null; } CompilerError compilerError; if (!inMemoryCompiler.SyntaxErrorsExist( sourceCode.Code, out compilerError)) { Type[] types = inMemoryCompiler.GetTypes(sourceCode.Code, true); foreach (Type type in types) { // Only look at classes that implement ICustomToolbarItem if (type.GetInterface("ICustomToolBarButton") != null) { ICustomToolBarButton item = (ICustomToolBarButton) Activator.CreateInstance(type); item.SaveAPI(this); ICustomToolBarButton customToolbarButton = item; textBox.Text += "Adding custom toolbar button: " + item.GetType().FullName + Environment.NewLine; ToolBarButton button = new ToolBarButton(customToolbarButton.Text); button.Style = customToolbarButton.Style; Image image = customToolbarButton.GetImage(); if (image != null) { if (customToolbar.ImageList == null) { customToolbar.ImageList = new ImageList(); customToolbar.ImageList.ColorDepth = ColorDepth.Depth4Bit; customToolbar.ImageList.TransparentColor = System.Drawing.Color.Green; } customToolbar.ImageList.Images.Add(image); button.ImageIndex = customToolbar.ImageList.Images.Count - 1; } button.ToolTipText = customToolbarButton.ToolTipText; button.Tag = customToolbarButton; customToolbar.Buttons.Add(button); } } } }Back to article
Listing Five
// Return true if the specified class is nested inside of another class. private bool TypeIsNested(Type type) { return type.FullName.IndexOf('+') != -1; } // Clear and re-create custom menus. private void UpdateProgrammableMenus() { customMainMenu.MenuItems.Clear(); CompilerError compilerError; if (!inMemoryCompiler.SyntaxErrorsExist( sourceCode.Code, out compilerError)) { Type[] types = inMemoryCompiler.GetTypes(sourceCode.Code, true); ArrayList nonNestedMenuItems = new ArrayList(); foreach (Type type in types) { if (type.GetInterface("ICustomMenuItem") != null && !TypeIsNested(type)) { nonNestedMenuItems.Add(type); } } GetChildMenus(customMainMenu, (Type[]) nonNestedMenuItems.ToArray(typeof(Type))); } } // Create a menu item and add subordinate menu items to it. private void GetChildMenus(MenuItem ParentMenu, Type[] nestedTypes) { foreach (Type type in nestedTypes) { // Only look at classes that implement ICustomMenuItem if (type.GetInterface("ICustomMenuItem") != null) { //Got one at the right level ICustomMenuItem item = (ICustomMenuItem) Activator.CreateInstance(type); item.SaveAPI(this); CMI.ICustomMenuItem customMenuItem = item; //Get a new menu item and set the properties MenuItem newMenuItem = new MenuItem(customMenuItem.Text, customMenuItem.ClickEventHandler); newMenuItem.Select += new EventHandler(customMenuItem.SelectEventHandler); newMenuItem.Shortcut = customMenuItem.MenuShortcut; newMenuItem.OwnerDraw = customMenuItem.IsOwnerDrawn; newMenuItem.MeasureItem += new MeasureItemEventHandler( customMenuItem.measureItemEventHandler); newMenuItem.DrawItem += new DrawItemEventHandler(customMenuItem.drawItemEventHandler); //Notification to the user textBox.Text += "Adding menu class: " + type.FullName + Environment.NewLine; //Get the sub-menus for this item GetChildMenus(newMenuItem, type.GetNestedTypes(BindingFlags.Public)); //Add this item to the parent ParentMenu.MenuItems.Add(newMenuItem); } } }Back to article