Creating Document-Centric Applications in Windows Forms, Part 1

 

Chris Sells
Microsoft Corporation

September 2, 2003

Summary: Chris Sells outlines an implementation of simple document-handling for an SDI application, including proper save functionality, updated captioning based on the filename and state, and opening documents passed in through the command line. (14 printed pages)

Download the wfdocs1src.msi sample file.

This is what always happens to me—I'm reading something and I doubt what it says, so I have to write a computer program to see for myself. This time, I doubted the stated annualized rate of return for a mutual fund given a set of yearly returns. My reading was that the author claimed that the numbers represented an annualized rate of return of 24.91%, which I had to check having just figured out the difference between average and annualized rate of return (bottom line is that you can spend annualized returns, whereas average returns are worthless). That mission in mind, I built the RatesOfReturn application shown in Figure 1 (populated with the author's numbers).

Figure 1. The RatesOfReturn application in action

Unfortunately, it wasn't until after I'd built the application that I realized that the numbers that the author was reporting were not actual rates, but deltas from the S&P 500 for the same periods, but that just makes me look silly, so let's focus on other things.

Document-Based Applications

The bulk of the application shown in Figure 1 was built using the Visual Studio® .NET Designers, including the data grid, a data view to expose the ListChanged event when the data set changed, a typed data set representing the data structure of my application, and the binding between them. Even the formatting of the columns for percentages and currency was defined using the table styles editor for the data grid. In fact, except for implementing the document-related menu items (for example, File->Open), the only code I had to write was the Form.Load event handler to pre-populate the data set with an initial row and the DataView.ListChanged event handler to implement the average and annual rates of return calculations:

void Form1_Load(object sender, System.EventArgs e) {
  // Add starting principle
  this.periodReturnsSet1.PeriodReturn.AddPeriodReturnRow(
    "start", 0M, 1000M);
}

void dataView1_ListChanged(object sender, ListChangedEventArgs e) {
  // Calculate average and annual returns
  ...
}

I was very much enjoying my declarative development experience until I wanted to save my newly populated data set to the disk for later use. While Windows Forms and Visual Studio .NET provide all kinds of support for me to easily write my data bound application, it didn't provide any real support for the staple of MFC (Microsoft Foundation Classes) programmers everywhere—document-based applications. MFC is so able in its support of document-based applications that often applications that weren't document-based become so in order to fit into the MFC model (although MFC is certainly flexible enough to handle many different kinds of applications).

Windows Forms, on the other hand, while capable at building data-centric applications provide almost no support for document-based applications. Oh, it was easy enough for me to layout the File menu and to show the file dialogs. It was even easy for me to dump the contents of the data set to the disk using the serialization stack in .NET:

using System.IO;
using System.Runtime.Serialization;
using System.Runtime.Serialization.Formatters;
using System.Runtime.Serialization.Formatters.Soap;
...
void fileSaveMenuItem_Click(object sender, EventArgs e) {
  if( this.saveFileDialog1.ShowDialog(this) != DialogResult.OK ) {
    return;
  }

  string fileName = this.saveFileDialog1.FileName;
  using( Stream stream = new FileStream(
           fileName, FileMode.Create, FileAccess.Write) ) {
    // Serialize object to text format
    IFormatter formatter = new SoapFormatter();
    formatter.Serialize(stream, this.periodReturnsSet1);
  }
}

All of this functionality is built into .NET. Some of it is in the System.Windows.Forms namespace, some in System.IO, and some in System.Runtime.Serialization, but it's all there for your ready access.

However, document-based applications require a lot more than just showing a file dialog and dumping an object's contents into a file. To meet a Microsoft Windows® user's basic expectations, even a minimal Single Document Interface (SDI) application needs the following features:

  • Shows the currently loaded document in the form's caption (Untitled.txt, for example).
  • Shows the user that a document has changed from it's on disk state, commonly with an asterisks next to the file name (Untitled.txt*).
  • Prompts the user to save a changed document when they attempt to close it without saving.
  • Lets the user save changes to the current document without providing the file name for each change. For example, the difference between File->Save after the first save and File->Save As.
  • Creates new documents, clearing any currently active document.
  • Opens previously saved files.

At the programmer level, these features can be implementing with the following constructs:

  • Events that fire when the document has changed.
  • A dirty bit to keep track of the document's state relative to the copy on disk for use in populating the caption and prompting the user to save when necessary.
  • A current file name for use in populating the caption and implementing File->Save after an initial save.
  • Handlers for the various File menu-related events (File->New, File->Save, and so on).

While Windows Forms provides no feature that gathers these constructs together, they can be added fairly easily to our SDI sample.

Tracking the Dirty Bit

Noticing when the document has changed is application-specific. For our sample, the document data changes are indicated by the DataView.ListChanged event:

// Tracking the dirty bit
bool dirty = false;

void SetDirty(bool dirty) {
  this.dirty = dirty;
  SetCaption();
}

// Set caption based on application name, file name and dirty bit
void SetCaption() {...}

void dataView1_ListChanged(object sender, ListChangedEventArgs e) {
  ...
  // Update the dirty bit
  SetDirty(true);
}

Notice that when the ListChanged event is fired, we're calling SetDirty instead of setting the dirty field directly. This is good practice as SetDirty also sets the new caption using the updated dirty field. If you forgot and started setting the dirty field directly, you'd also have to remember to call the SetCaption method.

Tracking the Current File Name

Like the dirty bit, the currently active file name changes during the lifetime of a document-based application. Mostly the file name changes during the implementation of the File menu items, but the basic support for a current file name can be implemented as follows:

// Tracking the current file name
string fileName = null;

void SetFileName(string fileName) {
  this.fileName = fileName;
  SetCaption();
}

static bool Empty(string s) { return s == null || s.Length == 0; }

// Set caption based on application name, file name and dirty bit
void SetCaption() {
  this.Text = string.Format(
    "{0} - [{1}{2}]",
    Application.ProductName,
    Empty(this.fileName) ?
      "Untitled.ror" :
      Path.GetFileName(this.fileName),
    this.dirty ? "*" : "");
}

On a side note, you may wonder why I formatted the caption as:

Application – [FileName*]

Instead of the Notepad/Office standard of:

FileName* – Application

I chose the former to be consistent with how Windows Forms Multiple Document Interface (MDI) is implemented, which also uses the former style, in contrast to Notepad and Microsoft Office, which uses the latter style. I'll cover document-centric MDI applications in part 2 of this series.

Finally, notice that the filename starts as null instead of "Untitled.ror". The null is a signal to the Save family of methods that the user has not yet chosen a file name.

File->Save and Friends

Implementing File->Save, Save As, and Save Copy As are the most complicated part of this whole process because they're three slightly different ways to put the contents of your document data onto the disk. The following lists the behavior expected from Windows applications (and the behavior that MFC exhibits):

  • Save has the following behaviors:
    • Shows the save file dialog if there is no current file name.
    • Serializes the document data to the disk.
    • Clears the dirty bit.
    • Sets the current file name if there isn't one already.
    • Updates the form caption with the new state of the dirty bit and potentially new file name.
  • Save As has the following behaviors:
    • Always shows the save file dialog.
    • Serializes the document data to the disk.
    • Clears the dirty bit.
    • Sets the current file name to the new file name.
    • Updates the form caption.
  • Save Copy As has the following behaviors:
    • Always shows the save file dialog.
    • Serializes the document data to the disk.
    • Doesn't clear the dirty bit.
    • Doesn't set the current file name to the new file name.
    • Doesn't update the form caption.

The following code shows one way to implement this set of behaviors:

protected enum SaveType {
  Save,
  SaveAs,
  SaveCopyAs,
}

bool SaveDocument(SaveType type) {
  // Get the file name
  string newFileName = this.fileName;
  if( (type == SaveType.SaveAs) ||
    (type == SaveType.SaveCopyAs) ||
    Empty(newFileName) ) {

    if( !Empty(newFileName) ) {
      saveFileDialog1.InitialDirectory =
        Path.GetDirectoryName(newFileName);
      saveFileDialog1.FileName =
        Path.GetFileName(newFileName);
    }
    else {
      saveFileDialog1.FileName = "Untitled.ror";
    }

    DialogResult res = saveFileDialog1.ShowDialog(this);
    if( res != DialogResult.OK ) return false;
    newFileName = saveFileDialog1.FileName;
  }

  // Write the data
  try {
    using( Stream stream = new FileStream(
              newFileName, FileMode.Create, FileAccess.Write) ) {
      // Serialize object to text format
      IFormatter formatter = new SoapFormatter();
      formatter.Serialize(stream, this.periodReturnsSet1);
    }
  }
  catch( Exception e ) {
    // report error...
    return false;
  }

  if( type != SaveType.SaveCopyAs ) {
    // Clear the dirty bit, set the current file name
    // and the caption is set automatically
    SetDirty(false);
    SetFileName(newFileName);
  }

  // Success
  return true;
}

Notice that the SaveDocument method takes an argument indicating what kind of save we're performing (that is, Save, Save As, or Save Copy As). Save only needs to ask for a new file name if there isn't already one, whereas Save As and Save Copy As both need to ask every time. Save and Save As clear the dirty bit and set the new file name as current, but Save Copy As does neither. All three saves write the document data to the file of choice, however.

With the multi-mode Save method in place, implementing the three menu options is easy:

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);
}

File->New

One thing I didn't mention was why the SaveDocument method returns a Boolean, even though the menu item click handlers don't check it. The result of a Save operation is important when it's part of an operation that needs to unload the current data and replace it with new data, like File->New does.

New has the following behaviors:

  • If the document data is dirty, it prompts the user if they'd like to save their changes, abandon their changes, or cancel the New operation.
  • If the user wants to save, it calls Save(Save), prompting for a file name if necessary. If the Save method fails, the New operation is aborted, keeping the user's changes. This happens if the disk is full or the user pressed Cancel at the file dialog.
  • If the user wants to cancel the New operation, nothing is saved and no new document is created.
  • If the dirty bit is clear or the user doesn't want to save their changes or they have saved their changes, the document needs to be reset to a "New" state, just like it was when it was created.

If the dirty bit is set when the user selects File->New, a message box like Figure 2 is shown to get their choice as to what to do with the changes to the document's data.

Figure 2. Prompting the user what to do with their document changes

To support File->Open, which I'll discuss below, the behavior of New is most easily broken up into two methods, NewDocument and CloseDocument:

public bool NewDocument() {
  // Check to see if we can close
  if( !CloseDocument() ) return false;

  // Clear existing data
  ...

  // Set initial document data and state
  this.periodReturnsSet1.PeriodReturn.AddPeriodReturnRow(
    "start", 0M, 1000M);
  SetDirty(false);
  SetFileName(null);

  return true;
}

bool CloseDocument() {
  // It's all about the dirty bit...
  if( !this.dirty ) return true;

  DialogResult res = MessageBox.Show(
    this,
    "Save changes?",
    Application.ProductName,
    MessageBoxButtons.YesNoCancel);

  switch( res ) {
    case DialogResult.Yes: return SaveDocument(SaveType.Save);
    case DialogResult.No: return true;
    case DialogResult.Cancel: return false;
    default: Debug.Assert(false); return false;
  }
}

Notice that the CloseDocument method checks the dirty bit and reports success if it's already clear, indicating that there's no data changes to save. If there are changes to save, we prompt the user with their three choices, leaning on the success or failure of the SaveDocument method, if used, to determine whether the document can be successfully saved or not.

If the document can be closed, the NewDocument method clears out the old data, sets any initially new data (like our seed row), clears the dirty bit and the current file name, and updates the caption for the user.

With the NewDocument in place, we can use it to implement both the main form's constructor and the File->New menu item:

public RatesOfReturnForm() {
  // Required for Windows Form Designer support
  InitializeComponent();

  // Create the new document
  // NOTE: Can't do this in Load, or opening documents
  // from the command line becomes difficult
  NewDocument();
}

void fileNewMenuItem_Click(object sender, EventArgs e) {
  NewDocument();
}

File->Open

Open has the following behaviors:

  • Checks if the data document can be closed, prompting the user to save if the data is dirty.
  • If the document can be closed, it shows the open file dialog and lets the user pick a file to open.
  • Deserializes the chosen file.
  • Clears the dirty bit.
  • Sets the current file name.
  • Updates the caption.

Open is essentially a combination of the behavior of New, checking to see if the current data can be closed, and saved, interacting with the user to choose a file and updating the caption accordingly. OpenDocument implements this behavior:

public bool OpenDocument(string newFileName) {
  // Check if we can close current file
  if( !CloseDocument() ) return false;

  // Get the file to open
  if( Empty(newFileName) ) {
    DialogResult res = openFileDialog1.ShowDialog(this);
    if( res != DialogResult.OK ) return false;
    newFileName = openFileDialog1.FileName;
  }

  // Read the data
  try {
    using( Stream stream = new FileStream(
              newFileName, FileMode.Open, FileAccess.Read) ) {
      // Deserialize object from text format
      IFormatter formatter = new SoapFormatter();
      PeriodReturnsSet
        ds = (PeriodReturnsSet)formatter.Deserialize(stream);

      // Clear existing data
      ...

      // Merge in new data, keeping data bindings intact
      ...
    }
  }
  catch( Exception e ) {
    // report error...
    return false;
  }

  // Clear dirty bit, set the current the file name
  // and set the caption
  SetDirty(false);
  SetFileName(newFileName);

  // Success
  return true;
}

Implementing File->Open is as simple as calling the OpenDocument method:

void fileOpenMenuItem_Click(object sender, EventArgs e) {
  OpenDocument(null);
}

Notice that the OpenDocument function takes a filename argument an optional parameter, skipping the dialog if the argument is null. Notice also that I made OpenDocument public, unlike the rest of the methods I've shown thus far. Both of these features enable opening a document using an optional file passed in on the command line.

Opening Using the Command Line

All .NET applications, whether they're console applications or Windows applications, provide equal access to the optional command line arguments passed when the application is first started. Those arguments are available through the string array passed to your application's Main method. By default, the wizard-generated code will look like the following, ignoring the command line arguments:

static void Main() {
  Application.Run(new Form1());
}

Handling the command line arguments with OpenDocument in place looks like this:

using System.IO;
...
static void Main(string[] args) {
  // 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);
}

Notice the use of the Path.GetFullPath method from the System.IO namespace to turn what may potentially be a relative file name into a full path name. This is useful to make sure that we're always dealing in full path names, just like the file dialogs do.

Of course, command line processing is most useful when the shell has been informed of your custom file extension so that double-clicking on your application's files in the Explorer cause your application to be launched, passing in the file as an argument. Getting the shell to do this for your Windows Forms application is covered in part 2 of this series.

Closing and File->Exit

Wrapping up our simple document-handling functionality, if the user makes changes to their document and clicks the Close button on the form (the X in the upper right-hand corner) or exits the application, we'd like to make sure their data gets saved.

Closing and Exit have the following behaviors:

  • Checks if the data document can be closed, prompting the user to save if the data is dirty.
  • Closing or existing if the document can be closed, canceling the operation otherwise.

The CloseDocument method we've already built for NewDocument and OpenDocument are enough to implement the form's Closing event (when it's not too late to cancel the close):

void RatesOfReturnForm_Closing(object sender, CancelEventArgs e) {
  if( !CloseDocument() ) e.Cancel = true;
}

File->Exit can be implemented by merely closing the form and letting the Closing event handler decide whether it's okay to close the form or not:

void fileExitMenuItem_Click(object sender, EventArgs e) {
  this.Close();
}

Where Are We

In spite of the fact that I misinterpreted the numbers from the book I was reading, what we ended up with is an implementation of simple document-handling for an SDI application, including proper New, Save, SaveAs, SaveCopyAs, Open, Close, and Exit functionality, as well as keeping the form's caption up to date based on the current document's filename and dirty state, and even opening documents passed in through the command line.

In part two of this series, we'll see what has to be done to extend this model for MDI applications, talk about some deeper level of shell integration like custom file extensions, and adding files to the Documents menu on the Start button. Finally, I'll show the benefits of pushing all of this generic document-handling functionality into a reusable component so that most of the work can be done from the design surface, as Alan Cooper intended.

References

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 for C# Programmers. 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.