FileSelect - A quick Implementation of the File Menu
What It's For
FileSelect
is a WinForms user control for a default implementation of the "File" menu. All you need to do is to implement opening, saving and closing a document and sending change notifications, and you will get:
- Correct behavior of Save, Save As and Close - whether this document is new or opened, and whether it was modified.
This includes asking if the file should be saved, asking for a file name, etc. - Correct handling of file modified / not modified
- Disabling menu items when they can't be used
- A Recent Files list - in place or as a popup menu
- An automatically updating form title, including the current file name and an "is modified" marker
- Customizable OpenFile / SaveFile dialogs included
Most of the functionality can be activated selectively. I've tried to keep you in control with the individual features.
The download contains a sample (FileSelectDemo
) that implements a basic text editor - or rather, the file handling part - where you can explore the available functionality. It shows all commands available, including two styles of recent files. Of course, in your application you can add only the items you need.
How to Add It to your Project
Adding FileSelect to your project: Open the Toolbox panel, right click and select "Customize...". In the "Customize Toolbox" dialog, on the ".NET Framework components" tab, select "Browse", and select FileSelect.dll. Deselect the "RecentFileList" and "Strings" controls (only add the FileSelect
component itself), and click OK.
Adding FileSelect to your main form: From the toolbox add a FileSelect
component, and a MenuStrip
to your main application form (or wherever you need them). Add the desired commands to the menu.
Tip: Right click the MenuStrip
component, and select "Add default items".
Select the "New" menu items, and change the "FileSelectCommand on fileSelect1" property in category "General" to "New". Do the same for all other commands you want (usually New, Open, Save, Save As and Close).
Similarly, you can wire up toolbar buttons.
Adding a Recent Files list: Insert a new placeholder menu item (it will never be visible), and assign the Recent FileSelect
command to it. At runtime, it will be replaced by a list of recent files.
Additionally, if the placeholder is followed by a separator, and the list of recent files is empty, that separator is hidden. This allows the common style of putting the recent files list between two separators, without having two consecutive separators when there are no files. Similarly, if the placeholder is the only item in a popup menu, and the recent files list is empty, the parent item opening the popup menu gets disabled.
Implement the Commands: Select the FileSelect
control, and go to the "Events" tab of the property panel. Add handler for the events NewDocument
, OpenDocument
, SaveDocument
, CloseDocument
. New and Open can usually be implemented in the same handler. The following examples use a simple TextDocument
to be displayed in a TextBox
(textbox1
). TextDocument
is included with FileSelect
, for other file formats you need to use your own document class and its serialization here.
Handle New and open document, and:
private void fileSelect1_NewOrOpenDocument(object sender, EventArgs e)
{
FileSelectEventArgs fse = (FileSelectEventArgs)e;
DocumentInfo docInfo = fse.DocumentInfo;
TextDocument textDoc;
if (fse.Command == EFSCommand.New)
textDoc = TextDocument.New();
else
textDoc = TextDocument.Load(fse.Path);
docInfo.InitDocument(textDoc);
textBox1.Text = textDoc.Data;
textBox1.Tag = docInfo;
textBox1.ReadOnly = false;
}
private void textBox1_TextChanged(object sender, EventArgs e)
{
DocumentInfo docInfo = textBox1.Tag as DocumentInfo;
if (docInfo != null)
docInfo.IsDirty = true;
}
The Dirty Flag forwards any changes in the text box to the document info, so the user interface can be updated accordingly. Onward to the save handler:
private void fileSelect1_SaveDocument(object sender, EventArgs e)
{
FileSelectEventArgs fse = (FileSelectEventArgs)e;
DocumentInfo docInfo = fse.DocumentInfo;
TextDocument doc = (TextDocument)docInfo.Document;
doc.Data = textBox1.Text;
doc.Save(fse.Path);
fse.SaveComplete = true;
}
This event is used for Save, Save As and Save Copy As. The file name to save to is passed in fse.Path
, and may be different from the documents file path.
Note: You need to set SaveComplete
to true when saving the document was successful. If you don't, FileSelect
assumes the save failed (e.g. because your spanking new 1TB disk is already full again...).
Finally, when the document is closed, the user interface must be updated. Also, when the main form is closed, we need to handle modified documents:
private void fileSelect1_CloseDocument(object sender, EventArgs e)
{
textBox1.Text = String.Empty;
textBox1.ReadOnly = true;
textBox1.Tag = null;
}
private void Form1_FormClosing(object sender, FormClosingEventArgs e)
{
if (!fileSelect1.HandleQuit(e.CloseReason))
e.Cancel = true;
}
Additional Features and Settings
Control Properties
The following settings can be changed in the FileSelect
control properties. The values in parentheses are the default values.
UpdateContainerTitle | (true) If this flag is true , the title of ContainerControl is adjusted to display the current document and the modified state. Alternatively or additionally, you can handle the ParentTitleChanged event if you need custom handling. |
AllowSaveUnmodified | (false) Enable the "Save" command even if the document is not modified. This is uncommon, but may be desired for some applications. |
AskCloseUnmodified | (false) When closing a document that is not modified, the user is asked to confirm and can cancel closing. This is uncommon, but may be desired for some applications. This option does not affect the message that is shown when a modified document should be closed. |
CloseCreatesNew | (false) Instead of having no document open, a new document is created. This is the same behavior as in Notepad, where you never have to create a new document explicitly. Even when this option is true, you should still handle the CloseDocument correctly by clearing document UI, since creating the new document might fail. If this option is true , creating a new document should not require user interaction (such as selecting a document type or size). |
ContainerControl | The control that contains the FileSelect instance. Normally, you do not need to change it. It is used for the following services:
- If
UpdateContainerTitle is set, the title of ContainerControl is adjusted to display the current document and the modified state. - It is used for Invoke for UI updates when the dirty state changes, so you can set the dirty state in a separate thread.
- The controls
VisibleChanged event is used for automatically creating an empty document when CloseCreatesNew is set.
|
DialogOpen , DialogSave : | The file dialogs used to ask the user for a file name when opening or saving a document. You can customize their settings here. Note that some settings may be overwritten by FileSelect . |
RecentFiles | Settings for the recent files list. |
.ListCount | (4) Lets you set the number of recent items displayed (ListCount ). |
.PersistCount | (10) Number of recent files remembered. This can be larger than ListCount - why? When the user notices the file you just wanted to open just dropped out of the recent files list, he might go to increase the number of files displayed there. The user now does not need to browse for the file, but has it in the recent list instantly. (You'd have to offer such a setting, though). |
.AddShortcuts | (true) Adds numbered shortcut keys (1, 2, ...) to recent file lists. |
.DisplayLength | (40) - If not -1, paths are shortened to the selected number of characters for recent file lists. |
CustomUIStrings | Contains the string s used for end user display. You can customize and localize them here. |
DocumentInfo
FileSelect
holds a DocumentInfo
for each document. It is passed to the handler events, and returned from various functions. It contains the following properties:
FilePath | Path to the file the document was loaded from or saved to. May be null / empty, in when the user never specified a file name and will be asked for one when the document is saved. |
CustomTitle | A custom title set programmatically. It will be used e.g. for display in the container control title. |
Title | The current title of the document. Returns CustomTitle if one was set, otherwise the title is taken from the file, or a default name. |
DirtyFlag | Interface that handles document changes. When using the default implementation, you have to set the IsDirty property to true when the document changes. |
IsDirty | A shortcut for DirtyFlag.IsDirty . |
Programming API
HandleXxxxxDocument | Programmatically trigger the respective commands. |
OnXxxxx | Can be overridden in a derived class, instead of implementing. |
UIAskXxxxxx | User interactions - usually message boxes, can be overridden by a derived class. |
SetCurrentDocument | Provide a document that you have created or opened yourself as current. The function will return the document info, through which you can set attributes such as a custom document title and a file path. You have to update the user interface (the view of the document) manually. Note that the user could cancel the operation (e.g. cancelling out of saving the current document). In this case, the function returns null . |
RecentFiles.AllFiles | Contains the list of recent files, separated by line breaks. You can persist this property in user settings, so the recent files list is remembered. |
IDirtyFlag - Signalling Document Changes
The dirty flag affects which commands are enabled, and when message boxes are displayed, so for correct UI behavior, it needs to be implemented correctly.
Default Implementation: If you don't provide a custom implementation, you need to call DocumentInfo.IsDirty = true
(which is shorthand for DocumentInfo.DirtyFlag.IsDirty
) anytime the document changes.
Custom Implementation: If your document class already provides a dirty state or other versioning mechanism, you can provide a custom event.
The public
interface of IDirtyFlag
is this:
bool IsDirty { get; set; }
event EventHandler DirtyChanged;
FileSelect
uses this interface to query the dirty state, modify the dirty state, e.g. after saving the document, or when NewDocumentIsDirty
is enabled. It also binds to the change event to trigger UI updates. You can provide your custom implementation in two places:
- Implement
IDirtyFlag
on your document class that you pass to e.g. FileSelectEventArgs.InitDocument
. - Implement it on a standalone object, and pass it together with the document, e.g. to
FileSelectEventArgs.InitDocument
.
Important: Handling dirty state changes can be expensive, so the event should only fire when the state actually changes, not every time a value is assigned.
Architecture
Only an overview of the entities involved and their roles. If you have specific questions, please ask!
FileSelect
contains the core implementation and provides the interaction with Visual Studio (designer properties, events, etc.)DocumentInfo
is an object associated with each document you open, containing document properties such as the current file name.EFSCommand
is an enumeration containing the available menu commands.FileSelectEventArgs
is the event class that is sent with FileSelect
events. RecentFileList
implements the MRU cache for file names.
RecentFiles
inherits from RecentFileList
and adds some designer properties used by FileSelect
..ICommandItem
is the interface required for an adapter class wrapping menu or toolbar items, CommandItemBase
provides some defaults for implementing it.CI_ToolStripItem
is the ICommandItem
implementation used for WinForms
tool strips (covering tool bars and menus).
Future Plans
Please take note El Corazon's (previous) signature of mice and ceilings.
This is only a first release, with some rough edges and quite some features to be desired.
Multiple Document Support would be a major enhancement, but since this is rather uncommon and I have no immediate application for that, I also have no plans for adding that anytime soon. However, I've made some considerations so this should be possible. Multiple single documents (each in its own top level window) can be supported by giving each top level form its own instance of FileSelect
, though this leaves a few things to be desired.
Support for multiple document types. When opening or saving a document all you have to distinguish between different formats currently is the extension of the file, and the FilterIndex
property of the file dialogs. This may be enough, but could be handled better. I've originally designed a document manager class that handles creating opening and saving the documents and represents different document types, but I've cut them from this release to reduce complexity. This also conflicts with the way I am using events where an interface or delegate would be more appropriate (but less convenient).
Automatically add application settings. I'd like to make that an option (e.g. a property "Add user settings automatically", and a prefix for the property names), but I haven't found a way to do that. Now, you need to add the properties to the user settings manually.
Motivation
I want to share some thoughts that are not directly related to using that control. Why did I write that? Surely, this is a spare time project and way too much time went into it than could ever be justified for commercial development.
First, it is a common pattern, and I get annoyed when I see something not only I have to do over and over, but also everyone else. When learning Windows Forms, I was missing some simplicity here. I did not really (read: "totally not at all") miss the Document / View - Architecture of MFC, as it was too inflexible and stubborn for my taste - you could use either all of it, or none, or you were in for a rough ride. That's one reason this component only handles the "UI side" of providing a document-centric application.
Second, I believe in what Jan Minkovsky describes as "fractal nature of UI design [^]": on his blog he describes the odyssey of remembering the window position. Seemingly simple, Every time he believed it was solved, a new complaint sprung up. What amazed me most is that I went through almost the same ordeal, though I solved some aspects differently. The way I'd describe it is this:
Every problem - small or large - fills the space it has available.
Whenever you have fixed the ugly glaring problem, another smaller issue will fill the gap and annoy the user as much as before. (There are two factors working against it: the user getting over it, and less users being affected by the new problem - but they set in surprisingly late).
Couple that with the quip that the best user interface is no user interface - in the sense that users should not notice the user interface at all, it should be transparent to them. We achieve that through various means - like real life metaphors, consistent user interface and metaphors across applications. However, that way we are creating the vacuum that any tiny annoyance can fill.
This is where simple to use and simple to implement diverge. Simple to use describes a state where the application does what the user expects, and our expectations are often amazingly complex. How come? I don't know.
History
- Version 1.2 May 3, 2009
- Breaking change: Default value for
AskCloseUnmodified
changed to false
- Breaking change:
UpdateParentTitleControl
replaced by ContainerControl
.and UpdateContainerTitle
flag - Added Options
AllowSaveUnmodified
, CloseCreatesNew
- Added
FileSelect.SetCurrentDocument
- Checking document object for
IDirtyFlag
implementation - Handling of "Open" command when current document is dirty similar to Notepad (document close after file was selected)
- Several minor fixes to handling, code cleanups
- Added Toolstrip and property grid with
FileSelect
settings
- Version 1.1 April 5, 2009
- Breaking changes: More consistent naming, some stricter requirements / validations, some signatures have been modified expecting more / less arguments.
- Added shortening file paths for "Recent" Menu
There are two options controlling the default behavior, and you can specify custom behavior through a delegate RecentFilesList
can now be modified programmatically- Separate command IDs for Recent as place holder and
_RecentFile
for the items created by it. - Added documentation
- Added Sandcastle Builder project + CHM documentation
- Fixed a few bugs
- Note: Some literal strings still need to be moved to the
UIStrings
class
- Version 1.0: March 29, 2009