Creating Document-Centric Applications in Windows Forms, Part 2
Chris Sells
Microsoft Corporation
October 8, 2003
Summary: Chris Sells expands on his first installment of this series by showing you how to integrate an SDI application into a shell while providing support for double-click document opening, a custom document icon, and adding documents to the Start->Document menu in the shell. (14 printed pages)
Download the wfdocs2src.msi sample file.
Recall from the first article in this series that we were after adding document-handling support to the Windows Forms application shown in Figure 1.
Figure 1. The SDI version of the RatesOfReturn application
In part 1, we were able to add the appropriate document handling functionality to the SDI version of this application, including proper New, Save, Save As, Save Copy As, Open, Close, and Exit functionality. We were also able to keep the form's caption up to date based on the current document's filename and dirty state, and even open documents passed in through the command line. Let's pick up where we left off by finishing up the basic functionality of our SDI application by integrating it with the shell.
Integrating with the Shell
The shell I'm interested in integrating with is the Explorer shell that the user uses to keep track of their documents (although the command line shell picks up some integration here, too). The most basic kind of shell integration for a document-based application is making sure that the application's documents are associated with the application so that double clicking on one of the documents brings up that document in the application. The trick is to place the correct entries in the Registry that map a custom file extension—like .ror to a ProgID (programmatic identifier), for example—and then to register one or more commands under the ProgID (mapping Open to launch RatesOfReturn.exe).
To add a custom extension, we need a new key under the HKEY_CLASSES_ROOT Registry hive for that extension that maps to the ProgID. To add a new ProgID, we also need a new key under HKEY_CLASSES_ROOT, along with a subkey for the Open command. The goal, as shown in the Registry Editor (regedit.exe) looks like Figure 2 for the custom extension and Figure 3 for the associated open command.
Figure 2. Mapping a custom file extension to a custom ProgID
Figure 3. Registering an Open command with a custom ProgID
Notice the use of the quoted %L argument in Figure 3 as part of the full path to our custom application's EXE file. When the user double-clicks on a .ror file or right-clicks and chooses Open from the context menu, the command in the Registry is executed, replacing %L with the long name to the file that the user chose. The use of the double quotes surrounding %L is to ensure that even filenames with spaces in them come through as a single argument.
The .NET Framework Class Library (FCL) provides a nice set of classes for Registry manipulations in the Microsoft.Win32 namespace, making registration of our sample's custom extension a snap:
using Microsoft.Win32;
...
static void Main(string[] args) {
// Register custom extension with the shell
using( RegistryKey key =
Registry.ClassesRoot.CreateSubKey(".ror") ) {
// Map custom extension to a ProgID
key.SetValue(null, "rorfile");
}
// Register open command with the shell
string cmdkey = @"rorfile\shell\open\command";
using( RegistryKey key =
Registry.ClassesRoot.CreateSubKey(cmdkey) ) {
// Map ProgID to an Open action for the shell
key.SetValue(null, Application.ExecutablePath + " \"%L\"");
}
// Load main form, taking command line into account
RatesOfReturnForm form = new RatesOfReturnForm();
if( args.Length == 1 ) {
form.OpenDocument(Path.GetFullPath(args[0]));
}
Application.Run(form);
}
Now, whenever our application runs, it registers the .ror extensions with the shell. When the user double-clicks a file, the application is executed with the arguments passed in the string array to the Main method, allowing us to open the file as a document in our application.
Document Icons
In addition, once the Open command is registered under Microsoft Windows® XP, the shell replaces the unregistered extension icon (shown in Figure 4) with an icon composed of a miniature icon from the application itself, as shown in Figure 5.
Figure 4. File without an extension association
Figure 5. Shell-created document icon based on the application's icon
If you'd prefer a custom icon for your document types, you can set the DefaultIcon key under the ProgID in the Registry. The key is the name of the Windows EXE or DLL containing native icons, followed by an icon indicator. If the indicator is negative, it's interpreted as a resource ID (after the minus sign is dropped). If the indicator is positive, it's interpreted as an offset into the list of native icons bundled into the EXE or DLL. For example, the following code sets the DefaultIcon using an icon from the shell32.dll that comes with current versions of Windows:
// Register document icon with the shell
string icokey = @"rorfile\DefaultIcon";
using( RegistryKey key =
Registry.ClassesRoot.CreateSubKey(icokey) ) {
// Map ProgID to a document icon for the shell
key.SetValue(null, @"%SystemRoot%\system32\shell32.dll,-10");
}
Notice the use of the %SystemRoot% variable in the key value. Like %L, this variable is expanded by the shell.
Unfortunately, the use of the DefaultIcon key can't be used to pull managed icon resources out of .NET assemblies because the shell only supports native icon resources. To use a custom document icon, you'll either have to bundle a separate DLL with a native icon resource in it or use a tool to bundle the native icon resources into your application's assembly. The .NET command line compilers support bundling native resources, but Visual Studio® .NET does not (except for one special "Application Icon" that can be set in the project properties for your application).
Note While Visual Studio .NET doesn't support embedding arbitrary native resources into managed assemblies, Visual Studio .NET 2003 does provide support for custom build steps when combined with a 3rd party tool. I've used Peter Chiu's ntcopyres utility for this article, which you can check out at http://www.codeguru.com/cpp_mfc/rsrc-simple.html.
Start->Documents
In addition to seeing their files in the Explorer, users are accustomed to seeing their most recently accessed documents in the Start->Documents menu. If you want those files to be added to that list, you'll have to call the Win32 function SHAddToRecentDocs, exposed from shell32.dll. Unfortunately, there's no .NET wrapper for that function, so you'll have to use a bit of Win32 interop to gain access to it:
using System;
using System.Runtime.InteropServices;
class ShellApi {
public enum ShellAddToRecentDocsFlags {
Pidl = 0x001,
Path = 0x002,
}
[DllImport("shell32.dll", CharSet = CharSet.Ansi)]
public static extern void
SHAddToRecentDocs(ShellAddToRecentDocsFlags flag, string path);
[DllImport("shell32.dll")]
public static extern void
SHAddToRecentDocs(ShellAddToRecentDocsFlags flag, IntPtr pidl);
public static void AddToRecentDocs(string path) {
SHAddToRecentDocs(ShellAddToRecentDocsFlags.Path, path);
}
}
The heart of this code is the static extern function with the DllImport attribute pulling in the SHAddToRecentDocs function from shell32.dll at run time. The enumeration is there to take the place of the SHARD_* constants normally used from the shellapi.h header for C/C++ programmers. The wrapper method, AddToRecentDocs, is merely a helper to make calling this method from the SaveDocument and OpenDocument methods a tad easier:
bool SaveDocument(SaveType type) {
...
// Put the file into the Start->Documents list
ShellApi.AddToRecentDocs(newFileName);
// Success
return true;
}
public bool OpenDocument(string newFileName) {
...
// Put the file into the Start->Documents list
ShellApi.AddToRecentDocs(newFileName);
// Success
return true;
}
Now, whenever a document is saved or opened from our application, it's added to the Start->Documents list. Because we've already mapped the custom .ror extension in the shell to our application, an instance of our application is loaded whenever a document from the Start->Documents list is selected.
Multi-Document Interface
So far, we've concentrated on Single Document Interface (SDI) applications, but Multiple Document Interface (MDI) applications are just as likely to need the document-handling features we've discussed.
There are two pieces to the MDI-style form—the parent and the child. A parent form is so designated by setting the IsMdiContainer property to true, while a child form is designated by setting the MdiParent property before showing the form:
class MdiParentForm : Form {
...
void InitializeComponent() {
...
this.IsMdiContainer = true;
...
}
void fileNewMenuItem_Click(object sender, EventArgs e) {
// Create and show a new child
RatesOfReturnForm child = new RatesOfReturnForm();
child.MdiParent = this;
child.Show();
}
}
An MDI parent form is expected to have a Window menu to show the list of child forms. This expectation can be implemented by adding a Window menu item to the main menu in the MDI parent form, and then setting its MdiList property to true, as shown in Figure 6.
Figure 6. The MDI Window Menu
Notice that the captions for the child forms are the filenames, and that the Window menu shows those captions. This is achieved using the IsMdiChild property of the child form to detect when the form is hosted in an MDI parent:
class RatesOfReturnForm : Form {
...
// Set caption based on application name, file name and dirty bit
void SetCaption() {
if( this.IsMdiChild ) {
// Format for MDI
this.Text = string.Format(
"{0}{1}",
Empty(this.fileName) ?
"Untitled.ror" :
Path.GetFileName(this.fileName),
this.dirty ? "*" : "");
}
else {
// Format for SDI...
}
}
...
}
This formatting of the caption looks nice in the Windows menu and in the child form caption (as shown in Figure 6), and merges well when a child form is maximized, as shown in Figure 7.
Figure 7. Windows Forms combining the captions of the MDI parent and child
Notice that that when maximized in an MDI parent (as shown in Figure 7), the caption matches the SDI caption format as shown in Figure 1.
Menu Merging
So far, Windows Forms has made MDI so easy that it almost takes all the satisfaction out of things until we get to menu merging, which gives us something to sink our teeth into. The basic idea of menu merging is that there is only one main menu shown in the parent form, even if that form has MDI children in it. Instead of letting each MDI child have its own menu, the menu items from the child are merged with the menu items of the parent, simplifying things for the user. The reason everything isn't put into the parent's main menu to start with is that lots of menu items don't make sense without a child—like File->Close—so showing them isn't helpful. Likewise, the set of operations can change between MDI children, so the merged menu should only consist of the items from the parent that always makes sense, like File->Exit and the items from the currently active child.
For example, Figure 8 shows our MDI parent File menu when there are no children.
Figure 8. MDI parent File menu with no MDI children
Figure 9 shows the same File menu when there is a child.
Figure 9. MDI parent File menu with an MDI child
In the Designer, both the parent and the child forms have a main menu, as shown in Figure 10.
Figure 10. The parent and child menus in the Designer
Notice that the child's menu items don't contain all of the items shown in Figure 9 when the child is active at run time. Instead, the child has only the new items that are to be added into the parent menu. For the merging to work, we need to set two properties on the menu items to be merged, both at the top level. For example, we need to set File, and at the lower levels, File->New.
Merging is governed on a per menu item basis using the MergeType and the MergeOrder. The MergeType is one of the following MenuMerge enumeration values:
enum MenuMerge {
MergeItems,
Add,
Remove,
Replace,
}
The MergeOrder dictates how things are ordered at the top level and in the sub-menus. The top-level menu is arranged by merge order right to left, lowest to highest, while sub-menus are arranged in merge order top to bottom. In our example, the parent File menu and the child File menu both are set to a merge order of 0. When a parent and child's menu merge order match and the MenuMerge property is set to MergeItems, this causes the sub-menu items to be merged together, which is exactly the effect we want.
Notice that parent File->Open and File->Exit menu items are separated with all of the child's File menu items. This is achieved by setting the merge order for File->Open and File->Exit to 0 and 2 respectively in the parent, and setting all of the child menu items to a merge order of 1. Don't be confused by the numbers I used, however. The actual numbers don't matter, just their relative order.
Table 1 and Table 2 show the menu merge settings used by the example to create a merged top-level menu and a merged File menu.
Table 1. Parent menu merge settings
Parent MenuItem | MergeType | MergeOrder |
---|---|---|
File | MergeItems | 0 |
File->New | Add | 0 |
File->Open... | Add | 0 |
- (separator) | Add | 2 |
File->Exit | Add | 2 |
Window | Add | 2 |
Help | Add | 2 |
Table 2. Child menu merge settings
Child MenuItem | MergeType | MergeOrder |
---|---|---|
File | MergeItems | 0 |
File->Save | Add | 1 |
File->Save As | Add | 1 |
File->Save Copy As | Add | 1 |
File->Close | Add | 1 |
These settings result in the parent and child menus merging as shown in Figure 9.
Implementing the Parent and Child File Menu Items
Now that we've split the menu items appropriately between the parent and the child, we need to implement them. Since the MDI child form is the same as the SDI form we built up in the previous article, all of its Save menu item implementations are the same as before. The only thing that's different is the need to implement File->Close:
class RatesOfReturnForm : Form {
...
void fileSaveMenuItem_Click(object sender, EventArgs e) {
SaveDocument(SaveType.Save);
}
void fileSaveAsMenuItem_Click(object sender, EventArgs e) {
SaveDocument(SaveType.SaveAs);
}
void fileSaveCopyAsMenuItem_Click(object sender, EventArgs e) {
SaveDocument(SaveType.SaveCopyAs);
}
void fileCloseMenuItem_Click(object sender, EventArgs e) {
// Let Closing event handler cancel this if it's not OK
this.Close();
}
void RatesOfReturnForm_Closing(object sender, CancelEventArgs e) {
if( !CloseDocument() ) e.Cancel = true;
}
}
Notice that the File->Close handler only closes the form, and lets the Closing event handler check to see if the current document can be closed or not.
The new MDI parent's File menu items are implemented using newly created instances of the child form:
class MdiParentForm : Form {
...
void fileNewMenuItem_Click(object sender, EventArgs e) {
// Create and show a new child
RatesOfReturnForm child = new RatesOfReturnForm();
child.MdiParent = this;
child.Show();
}
void fileOpenMenuItem_Click(object sender, EventArgs e) {
OpenDocument(null);
}
// For use by Main in processing a file passed via the command line
public void OpenDocument(string fileName) {
// Let child do the opening
RatesOfReturnForm child = new RatesOfReturnForm();
if( child.OpenDocument(fileName) ) {
child.MdiParent = this;
child.Show();
}
else {
child.Close();
}
}
void fileExitMenuItem_Click(object sender, EventArgs e) {
// Children will decide if this is allowed or not
this.Close();
}
...
}
Notice the public OpenDocument method that creates a new child form and only shows it if it's able to open the document. This method is used by the File->Open menu implementation, as well as the Main method when a file is passed as a command line argument, using the same OpenDocument method that the new MDI child form exposed from before when it was being called as the top-level form used by the Main method.
The File->Exit implementation simply closes itself, relying on the Closing events being sent to each MDI child form to determine if it's acceptable that each document be closed.
Where Are We?
We started this article with an SDI application as an island. We proceeded to integrate it into the shell, including support for double-clicking a document to be opened by our application, a custom document icon, and adding documents to the Start->Document menu in the shell. Further, we widened the document-managed story from SDI to include MDI.
The reliance on the child form to do all of the document management work reduces the MDI parent code to only a few lines. The MDI child code is currently burdened with both document-management and providing application-specific functionality, when it should only be focused with the latter. The document-management functionality is application-independent and should be pushed into a chunk of easily reusable code for all of our document-centric application needs. Such a chunk of code is called a component, and it's the subject of the next article in this series.
Some of this material is excerpted from the forthcoming Addison-Wesley title Windows Forms Programming in C# by Chris Sells (0321116208). Please note the material presented here is an initial draft of what will appear in the published book.
Chris Sells is a Content Strategist for MSDN Online, currently focused on Longhorn, Microsoft's next operating system. He's written several books, including Mastering Visual Studio .NET and Windows Forms Programming in C#. In his free time, Chris hosts various conferences, directs the Genghis source-available project, plays with Rotor and, in general, makes a pest of himself in the blogsphere. More information about Chris, and his various projects, is available at http://www.sellsbrothers.com.