MDI Case Study Purchasing - Part X - Smart Saving
Introduction
In Part X we will deal with Smart Saving. If the user tries to close a document with unsaved changes, we will alert the user, giving them an opportunity to save. We will also deal with this for when the main application is closed. Additionally, we will learn how limit our application instance count to 1, so that if the user runs our application while an instance is already open, we will cancel the new instance, and switch the user to the already running instance.
Smart Document Saving
First, let's deal with simple form closure. If the user tries to close a form that has changed since the last save, or if the form has not yet been saved, we will alert the user and give them an opportunity to either save, not save and let the form close anyway, or cancel the form closure. To do this, we need to override the OnFormClosing
method inside our PurchaseOrderForm
class. In this method, we will check the state of our Saved
boolean, and if it's false, we will show a MessageBox
to the user, with Yes, No, and Cancel buttons. If the user clicks Yes, we want to save, No means let the form close unsaved, and Cancel means let's cancel closing the form. Here is what our override should look like
protected override void OnFormClosing(FormClosingEventArgs e)
{
if (!Saved)
{
DialogResult answer = MessageBox.Show(this.Text +
" has been changed. Do you wish to save\nbefore closing?",
"Unsaved Changes", MessageBoxButtons.YesNoCancel,
MessageBoxIcon.Exclamation);
switch (answer)
{
case System.Windows.Forms.DialogResult.Yes:
Save();
if (!Saved) e.Cancel = true;
break;
case System.Windows.Forms.DialogResult.Cancel:
e.Cancel = true;
break;
}
}
base.OnFormClosing(e);
}
In the Yes case, after calling Save()
we want to recheck the Saved
value, and if it's still false, we want to cancel closure. This accounts for the user cancelling the SaveFile
dialog. Now we need to modify our Save()
method so that it will prompt the user with a SaveFile
dialog in case the document has not yet been saved.
public void Save()
{
if (_fileName != null)
{
SaveAs(_fileName);
}
else
{
SaveFileDialog saveFileDialog = new SaveFileDialog();
saveFileDialog.InitialDirectory = Environment.GetFolderPath(Environment.SpecialFolder.Personal);
saveFileDialog.Filter = "Purchase Orders (*.pof)|*.pof";
saveFileDialog.DefaultExt = ".pof";
saveFileDialog.AddExtension = true;
if (saveFileDialog.ShowDialog(this) == DialogResult.OK)
{
String FileName = saveFileDialog.FileName;
this.SaveAs(FileName);
}
}
}
Next, let's account for instances when the user closes the application from the task bar icon. We need to go to MDIForm
and make a couple of changes. We need to add a public boolean, we'll call it UnsavedDocuments
, that will report whether there are any open child forms that are not saved. So in MDIForm
let's add that boolean
public bool UnsavedDocuments
{
get
{
foreach(PurchaseOrderForm f in this.MdiChildren)
{
if (!f.Saved) return true;
}
return false;
}
}
And now we need to add a public method to forceably attempt to close each open form, so that it's FormClosing
event will fire
public void CloseAllDocuments()
{
foreach(Form f in this.MdiChildren) f.Close();
}
Now let's move to SplashForm
. Here we need to override the OnFormClosing
method, where we will call CloseAllDocuments()
and then check the value of UnsavedDocuments
, and if it's still true, we will cancel the close. We also want to present the MDIForm
back to the user
protected override void OnFormClosing(FormClosingEventArgs e)
{
if(_mainForm != null && _mainForm.UnsavedDocuments)
{
if (_mainForm.WindowState == WindowStates.Minimized) _mainForm.WindowState = WindowStates.Normal;
_mainForm.Show();
_mainForm.CloseAllDocuments();
if (_mainForm.UnsavedDocuments) e.Cancel = true;
}
base.OnFormClosing(e);
}
Now if the user tries to close the application with the task bar icon, and unsaved documents are open, the user will now be alerted. You'll notice now though, if you click to close MDIForm
with unsaved documents, even though we are just hiding, we still get the FormClosing
event from all unsaved forms. We can fix that, by checking the reason for closure in the OnFormClosing
method. If the CloseReason
is MdiFormClosing
, we can elect to Cancel the close event. Change the OnFormClosing
method in PurchaseOrderForm
to
protected override void OnFormClosing(FormClosingEventArgs e)
{
if (e.CloseReason == CloseReason.MdiFormClosing)
{
e.Cancel = true;
}
else if (!Saved)
{
DialogResult answer = MessageBox.Show(this.Text +
" has been changed. Do you wish to save\nbefore closing?",
"Unsaved Changes", MessageBoxButtons.YesNoCancel,
MessageBoxIcon.Exclamation);
switch (answer)
{
case System.Windows.Forms.DialogResult.Yes:
Save();
if (!Saved) e.Cancel = true;
break;
case System.Windows.Forms.DialogResult.Cancel:
e.Cancel = true;
break;
}
}
base.OnFormClosing(e);
}
Now, since closing the MDIForm
itself always just hides the form, we won't be pestered with unsaved alerts in that instance. So that does it for Smart Saving
Limiting Application Instance Count To 1
To achieve this, we will go to our Program
class, and look at all running processes to see if any are identical to our process, and if so we will switch to it. To switch to the currently open instance, we will again take advantage of the WndProc
method to capture a customer message that we will broadcast out before we shut down the newly opening instance. To send the message we will use the User32.SendMessage
method. The User32
class is already included with the source, and handles our User32.dll imports. First we need to set up a couple of variables in the Program class. HWND_BROADCAST
will be the IntPtr
for the Broadcast handle, so that our message gets to all running processes, inluding the originally opened instance of our application.
private const int HWND_BROADCAST = 0xFFFF;
Next let's define our custom message by registering it with the Windows messaging system. To do this, we will call the User32.RegisterWindowsMessage
method
public static readonly int WM_MDIACTIVATEAPP = User32.RegisterWindowMessage("WM_MDIACTIVATEAPP");
Add a using statement for System.Diagnostics
, and then change your Main()
method to
static void Main()
{
Process thisProcess = Process.GetCurrentProcess();
foreach (Process p in Process.GetProcessesByName(thisProcess.ProcessName))
{
if (p.Id != thisProcess.Id)
{
User32.SendMessage((IntPtr)HWND_BROADCAST, WM_MDIACTIVATEAPP, IntPtr.Zero, IntPtr.Zero);
return;
}
}
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new SplashForm());
}
Now if you try it out, you'll see, the application will not allow a second instance to open. However, rather than just ignore a second opening, we want our application to respond by presenting the already open instance back to the user. This time we want to override the WndProc
method in SplashForm
protected override void WndProc(ref Message m)
{
if (m.Msg == Program.WM_MDIACTIVATEAPP)
{
if (_mainForm != null)
{
if (_mainForm.WindowState == FormWindowState.Minimized)
{
_mainForm.WindowState = FormWindowState.Maximized;
}
_mainForm.Show();
_mainForm.BringToFront();
}
}
base.WndProc(ref m);
}
Now we are responding to the custom Windows message we broadcasted from the newly opening instance, and can present the UI back to the user.
Mutex
The method outlined above does have a few drawbacks. The biggest is that it has a possiblilty of getting caught in a race condition, especially if your application is a multi-user application. Another way to handle this, is to use the System.Threading.Mutex
object.
using System.Threading;
......
public static Mutex AppMutex { get; set; }
static void Main()
{
String mutexName = String.Format("Local\\{0}Mutex_{1}_{2}", Application.ProductName,
Environment.UserDomainName, Environment.UserName);
bool secondInstance = false;
AppMutex = new Mutex(true, mutexName, out secondInstance);
if (!secondInstance)
{
User32.SendMessage((IntPtr)HWND_BROADCAST, WM_MDIACTIVATEAPP, IntPtr.Zero, IntPtr.Zero);
return;
}
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new SplashForm());
}
The mutexName variable, we started it with the prefix "Local\", and put the current user domain and user name into it. This allows multiple instances per machine, but only one per user. If you wanted to limit the instances to one per machine, you could instead define your mutexName as
String mutexName = String.Format("Global\\{0}Mutex", Application.ProductName);
That does it for Part X. I hope this guide has been helpful to some of it's readers. This concludes the basics. In upcoming installments we will incorporate docking panels, PDF rendering, Emailing, and a few other tecniques.
Points of Interest
- Smart Saving
- Instance Limiting
- Windows Messaging
- Mutex
History
Keep a running update of any changes or improvements you've made here.