Contents
Sometime ago, I was musing about current user interface design trends. I had been doing a lot of development at that time and VSS was one frequently used app, I started thinking about the VSS message box, you know the one which has six buttons like "Replace", "Merge", "Leave" etc. and the "Apply to all items" checkbox. If we need to implement something like that today, we would have to roll our own message box for each such message box, well, that's just a waste of time. So I started thinking about a reusable message box that supported stuff like custom buttons, "Don't ask me again" feature, etc. I did find some articles about custom message boxes but most of them were implemented using hooks. Well, I didn't like that too much and would never use such solutions in a production environment. So I decided to create a message box from scratch and provide the functionality that I needed. I also wanted to expose the functionality in an easy to use manner. This article describes some of the hurdles and interesting things I discovered while implementing this custom message box. The source code accompanying this article implements a custom message box that:
- Implements all the features of the normal message box.
- Supports custom buttons with tooltips.
- Supports custom icons.
- Allows the user to save his/her response.
- Supports custom fonts.
- Supports disabling the alert sound that is played while displaying the message box.
- Supports timeouts.
- Supports Localization.
The MessageBoxEx
component can be used as is in your applications to show message boxes where the standard message box won't do. Also, it can be used as a starting point for creating your own custom message box.
Let's take a look at what all is required if you need to implement a message box that duplicates the functionality provided by default.
The message box dynamically resizes itself to best fit its content. The factors that determine the size of the message box are message text, caption text and number of buttons. Also, I discovered that it imposes some limits on its size, both horizontally and vertically. So no matter how long the text of the message box is, the message box will never extend beyond your screen area, in fact, it does not even come close to covering the entire screen area. The message box also displays itself in the center of the screen.
So we need to first determine the maximum size for the message box. This can be done by using the SystemInformation
class.
_maxWidth = (int)(SystemInformation.WorkingArea.Width * 0.60);
_maxHeight = (int)(SystemInformation.WorkingArea.Height * 0.90);
So the message box has a max width of 60% of the screen width and max height of 90% of the screen height.
For fitting the size of the message box to its contents, we can make use of the Graphics.MeasureString()
method.
private Size MeasureString(string str, int maxWidth, Font font)
{
Graphics g = this.CreateGraphics();
SizeF strRectSizeF = g.MeasureString(str, font, maxWidth);
g.Dispose();
return new Size((int)Math.Ceiling(strRectSizeF.Width),
(int)Math.Ceiling(strRectSizeF.Height));
}
The above code is used to determine the size of the various elements in the message box. Once we have the size required by each of the elements, we determine the optimal size for the form and then layout all the elements in the form. The code for determining the optimal size is in the method MessageBoxExForm.SetOptimumSize()
, and the code for laying out the various elements is in MessageBoxExForm.LayoutControls()
.
One interesting thing is that the font of the caption is determined by the system. Thus we cannot use the Form
's Font
property to measure the size of the caption string. To get the font of the caption, we can use the Win32 API SystemParametersInfo
.
private Font GetCaptionFont()
{
NONCLIENTMETRICS ncm = new NONCLIENTMETRICS();
ncm.cbSize = Marshal.SizeOf(typeof(NONCLIENTMETRICS));
try
{
bool result = SystemParametersInfo(SPI_GETNONCLIENTMETRICS,
ncm.cbSize, ref ncm, 0);
if(result)
{
return Font.FromLogFont(ncm.lfCaptionFont);
}
else
{
int lastError = Marshal.GetLastWin32Error();
return null;
}
}
catch(Exception )
{
}
return null;
}
private const int SPI_GETNONCLIENTMETRICS = 41;
private const int LF_FACESIZE = 32;
[StructLayout(LayoutKind.Sequential, CharSet=CharSet.Auto)]
private struct LOGFONT
{
public int lfHeight;
public int lfWidth;
public int lfEscapement;
public int lfOrientation;
public int lfWeight;
public byte lfItalic;
public byte lfUnderline;
public byte lfStrikeOut;
public byte lfCharSet;
public byte lfOutPrecision;
public byte lfClipPrecision;
public byte lfQuality;
public byte lfPitchAndFamily;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
public string lfFaceSize;
}
[StructLayout(LayoutKind.Sequential, CharSet=CharSet.Auto)]
private struct NONCLIENTMETRICS
{
public int cbSize;
public int iBorderWidth;
public int iScrollWidth;
public int iScrollHeight;
public int iCaptionWidth;
public int iCaptionHeight;
public LOGFONT lfCaptionFont;
public int iSmCaptionWidth;
public int iSmCaptionHeight;
public LOGFONT lfSmCaptionFont;
public int iMenuWidth;
public int iMenuHeight;
public LOGFONT lfMenuFont;
public LOGFONT lfStatusFont;
public LOGFONT lfMessageFont;
}
[DllImport("user32.dll", SetLastError=true, CharSet=CharSet.Auto)]
private static extern bool SystemParametersInfo(int uiAction,
int uiParam, ref NONCLIENTMETRICS ncMetrics, int fWinIni);
One interesting thing that happened while I was working on getting the caption font was with the definition of the LOGFONT
structure. You see in MSDN documentation, the first five fields of the LOGFONT
structure were declared as type long
. I blindly copied the definition from the MSDN documentation and my call to SystemParametersInfo
always returned false
. After banging my head for four hours trying to figure out what the problem was, I came across a code snippet that used SystemParametersInfo
. I downloaded that snippet and it worked perfectly on my machine. On further inspection, I noticed that the size of the structure I was passing to SystemParametersInfo
was different from what the code snippet had. And then the lights came on, long
should have been mapped to int
...aaaargh.
Another interesting thing that I had never really noticed about the message box was that if you don't have a Cancel button in your message box, the Close button on the top right is disabled. You can check this by showing a message box with "Yes", "No" buttons only. Not only is the Close button disabled but the system menu also does not show a Close option. So, that called for some more P/Invoke magic to disable the Close button if more than one button was present and there were no Cancel buttons. Of course, since the buttons themselves are custom, each button has a IsCancelButton
property that you can set if you want to make the button a Cancel button.
[DllImport("user32.dll", CharSet=CharSet.Auto)]
private static extern IntPtr GetSystemMenu(IntPtr hWnd, bool bRevert);
[DllImport("user32.dll", CharSet=CharSet.Auto)]
private static extern bool EnableMenuItem(IntPtr hMenu, uint uIDEnableItem,
uint uEnable);
private const int SC_CLOSE = 0xF060;
private const int MF_BYCOMMAND = 0x0;
private const int MF_GRAYED = 0x1;
private const int MF_ENABLED = 0x0;
private void DisableCloseButton(Form form)
{
try
{
EnableMenuItem(GetSystemMenu(form.Handle, false),
SC_CLOSE, MF_BYCOMMAND | MF_GRAYED);
}
catch(Exception )
{
}
}
The above code disables the Close button, and also disables the Alt+F4 and Close option from the system menu.
Icons
Initially, I had obtained all the standard message box icons from various system files, using ResHacker. After I had posted the article, Carl pointed out the SystemIcons
enumeration which provides all the standard system icons. So now, instead of embedding the standard icons into the message box, I am using the SystemIcons
enumeration to draw the standard icons. Which means that on Windows XP, instead of , the real system icon is shown. An interesting problem that came up was that initially I was using a PictureBox
control to display the icon. Now since it can only take Image
objects, I tried converting the icon returned by SystemIcons
to a Bitmap
via the Icon.ToBitmap()
method. This worked alright for Win2K icons, but on XP where the icons had an alpha channel, the icons came out looking terrible. Next, I tried manually creating a bitmap and then painting the icon onto the bitmap using Graphics.DrawIcon()
, that too gave the same results. So finally, I had to draw the icon directly on the surface of the message box and drop the picture box.
Another interesting thing that I noticed was that out of the eight enumeration values in MessageBoxIcon
, only four had unique values. Thus, Asterisk
= Information, Error
= Hand, Exclamation
= Warning and Hand
= Stop. The difference I believe is in the support for Compact Framework.
When I was almost finished with my implementation, I realized that my message box made no sound when it displayed. I knew that the sounds were configurable via the Control Panel so I could not embed the sounds in the library. Fortunately, there is an API called MessageBeep
which is exactly what I required. It takes only one parameter which is an integer representing the icon that is being displayed for the message box.
Below is the code that plays the alerts whenever a message box is popped:
[DllImport("user32.dll", CharSet=CharSet.Auto)]
private static extern bool MessageBeep(uint type);
if(_playAlert)
{
if(_standardIcon != MessageBoxIcon.None)
{
MessageBeep((uint)_standardIcon);
}
else
{
MessageBeep(0 );
}
}
The interesting part in the design was how to implement the "Don't ask me again" a.k.a. SaveUserResponse feature. I didn't want that the client code be littered with if
statements checking if a saved response was available. So I decided that the client code should always call MessageBoxEx.Show()
and if the user had saved a response then that response should be returned by the call rather than the dialog actually popping up. The next problem to handle was message box identity, how do I identify that the same message box is being invoked so that I can lookup if a user has saved a response to that message box? One solution would have been to create a hash of the message text, caption text, buttons etc. to identify the message box. The problem here was that in cases where we didn't want to use the response saved by the user, this approach would fail. Another big disadvantage was that there was no way to undo the saved response; once a user made a choice, he had to stick to it for the entire process lifetime.
The approach I have used is to have a MessageBoxExManager
that manages all message boxes. Basically, all message boxes are created with a name. The name can be used to retrieve the message box at a later stage and invoke it. The name can also be used to reset the saved response of the user. One other functionality which I have exposed via the MessageBoxExManager
is the ability to persist saved responses. Although there is no implementation for this right now, it can be implemented very easily, only the hashtable containing the saved responses need to be serialized.
This means that a message box once created can be reused. If it is not required anymore, then it can be disposed using the MessageBoxExManager.DeleteMessageBox()
method; or if you want to create and show a one time message box, then you can pass null
in the call to MessageBoxExManager.CreateMessageBox()
. If a message box is created with a null
name, then it is automatically disposed after the first call to MessageBoxEx.Show()
.
Below is the public interface for the MessageBoxExManager
, along with explanations:
public class MessageBoxExManager
{
public static MessageBoxEx CreateMessageBox(string name);
public static MessageBoxEx GetMessageBox(string name);
public static void DeleteMessageBox(string name);
public static void WriteSavedResponses(Stream stream);
public static void ReadSavedResponses(Stream stream)
public static void ResetSavedResponse(string messageBoxName);
public static void ResetAllSavedResponses();
}
Another design decision was regarding how to expose the MessageBoxEx
class itself. Although the MessageBoxEx
is a Form
, I did not want to expose it as a Form
for two reasons: one was to abstract away the implementation details and the second was to reduce intellisense clutter while working with the class. Thus, MessageBoxEx
is a proxy to the real Form
which is implemented in MessageBoxExForm
.
Below is the public interface for MessageBoxEx
:
public class MessageBoxEx
{
public string Caption
public string Text
public Icon CustomIcon
public MessageBoxExIcon Icon
public Font Font
public bool AllowSaveResponse
public string SaveResponseText
public bool UseSavedResponse
public bool PlayAlsertSound
public int Timeout
public TimeoutResult TimeoutResult
public string Show()
public string Show(IWin32Window owner)
public void AddButton(MessageBoxExButton button)
public void AddButton(string text, string val)
public void AddButton(MessageBoxExButtons button)
public void AddButtons(MessageBoxButtons buttons)
}
Also for convenience, the standard message box buttons are available as an enumeration which can be used in AddButton()
.
public enum MessageBoxExButtons
{
Ok = 0,
Cancel = 1,
Yes = 2,
No = 4,
Abort = 8,
Retry = 16,
Ignore = 32,
}
Also, the results of these standard buttons are available as constants.
public struct MessageBoxExResult
{
public const string Ok = "Ok";
public const string Cancel = "Cancel";
public const string Yes = "Yes";
public const string No = "No";
public const string Abort = "Abort";
public const string Retry = "Retry";
public const string Ignore = "Ignore";
public const string Timeout = "Timeout";
}
Using the code is pretty straightforward. Just add the MessageBoxExLib project to your application, and you're ready to go. Below is some code that shows how to create and display a standard message box with the option to save the user's response.
MessageBoxEx msgBox = MessageBoxExManager.CreateMessageBox("Test");
msgBox.Caption = "Question";
msgBox.Text = "Do you want to save the data?";
msgBox.AddButtons(MessageBoxButtons.YesNo);
msgBox.Icon = MessageBoxIcon.Question;
msgBox.SaveResponseText = "Don't ask me again";
msgBox.Font = new Font("Tahoma",11);
string result = msgBox.Show();
Here is the resulting message box:
Here is some code that demonstrates how you can use your own custom buttons with tooltips in your message box:
MessageBoxEx msgBox = MessageBoxExManager.CreateMessageBox("Test2");
msgBox.Caption = "Question";
msgBox.Text = "Do you want to save the data?";
MessageBoxExButton btnYes = new MessageBoxExButton();
btnYes.Text = "Yes";
btnYes.Value = "Yes";
btnYes.HelpText = "Save the data";
MessageBoxExButton btnNo = new MessageBoxExButton();
btnNo.Text = "No";
btnNo.Value = "No";
btnNo.HelpText = "Do not save the data";
msgBox.AddButton(btnYes);
msgBox.AddButton(btnNo);
msgBox.Icon = MessageBoxExIcon.Question;
msgBox.SaveResponseText = "Don't ask me again";
msgBox.AllowSaveResponse = true;
msgBox.Font = new Font("Tahoma",8);
string result = msgBox.Show();
Here is the resulting message box:
While showing the message box, a timeout value can be specified; if the user does not select a response within the specified time frame, then the message box will be automatically dismissed. The result that is returned when the message box times out can be specified using the enumeration shown below:
public enum TimeoutResult
{
Default,
Cancel,
Timeout
}
Here is a code snippet that shows how you can use the timeout feature:
MessageBoxEx msgBox = MessageBoxExManager.CreateMessageBox(null);
msgBox.Caption = "Question";
msgBox.Text = "Do you want to save the data?";
msgBox.AddButtons(MessageBoxButtons.YesNo);
msgBox.Icon = MessageBoxExIcon.Question;
msgBox.Timeout = 30000;
msgBox.TimeoutResult = TimeoutResult.Timeout;
string result = msgBox.Show();
if(result == MessageBoxExResult.Timeout)
{
}
After my initial posting of this article, Carl and Frank pointed out that the message box could also be useful in localized applications. Now, initially I had thought that I would be able to access the localized strings for standard buttons like "OK", "Cancel" etc. from the OS itself, it seems that there is no such documented way, I even talked to Michael Kaplan and he confirmed that there is no way to get those strings. Now instead of thinking of some hack to get the strings from the OS, I decided to use a simple solution, I moved the strings into a .resx file and used that to show the text for standard buttons based on the CurrentUICulture
property of the current thread. I've included resources for French and German using BabelFish. Here is an example of a message box that was created after setting the CurrentUICulture
to "fr".
MessageBoxEx msgBox = MessageBoxExManager.CreateMessageBox(null);
msgBox.Caption = "Question";
msgBox.Text = "Voulez-vous sauver les donn�es ?";
msgBox.AddButtons(MessageBoxButtons.YesNoCancel);
msgBox.Icon = MessageBoxExIcon.Question;
msgBox.Show();
The resulting message box is shown below:
- If a message box is created without a name, i.e.,
null
is passed to CreateMessageBox()
, then you cannot use the SaveResponse feature, the message box will be automatically disposed after the first call to Show()
.
- If you want to provide a dialog where a user can reset his saved definitions, then you can use
MessageBoxExManager.ResetSavedResponse()
method.
- You can always use this code as a starting point for your own message box. You might want to take a look at some of the points I have mentioned in the To Do section to get some ideas as to where you can take this.
- Add support for RTF or HTML text in message box text.
- Add support for gradient backgrounds and background images.
- Add support for showing detailed help.
- Add support to choose default button, i.e., the button that is selected by default. Currently, this is the first button that is added.
- Add support for persisting saved user responses.
- Make a
Buttons
custom collection that allows insertion of buttons at specified indexes.
- Workaround for tooltip bug. See below.
- The tooltips don't display the second time a message box is shown. This happens because of a known issue in the .NET framework. See this thread for more info.
- 23-April-2005
- Fixed a bug where in some cases the scrollbars for the text box were coming up unnecessarily. I adjusted the width and height of the scrollbars as well while calculating the optimum size for the textbox. The size of the scrollbars can be determined using
SystemInformation.VerticalScrollbarWidth
and SystemInformation.HorizontalScrollbarHeight
properties.
- 26-Mar-2005
- Fixed a bug where if the text of the message box was too long, the buttons were not visible. I've replaced the label control that I was using to show the message text with a textbox, thus now, even if the text is really long, instead of the text getting truncated, scroll bars come up, allowing the user to see the entire message. This might be useful in case some really long stack trace is being viewed.
- Replaced embedded icons with icons from the
SystemIcons
enumeration.
- Added basic support for localization. Texts of standard message box buttons like "OK", "Cancel" etc. are now read from resource files based on the current culture.
- 07-Mar-2005
- Added timeout support.
- Set the
FlatStyle
of buttons and checkbox to "System" so that the controls draw themselves based on the active theme. If the application that uses the message box has enabled visual styles or is using the manifest to use Common Control v6.0, then the buttons and checkbox will draw themselves in a themed manner.
- Fixed a bug where buttons were getting created twice. This was happening because
Load
event gets fired every time ShowDialog()
is called.
- Fixed a bug where the size of the message box was not correct when fixed-width fonts were used. This was happening because the
AutoScale
property of the message box form was set to true
.
- 21-Feb-2005