//
you're reading...
Articles, Technology

MenuExtenderComponent for .NET – Part 4

MenuExtenderComponent — Building an Even Better Mouse Trap

Part 4 of 4

Table of Contents

A Quick Implementation

Let’s walk through the creation of a custom MenuDrawer. This one will mimic most aspects of the Normal drawer but will differ slightly. I’m not going to recreate the Office XP style here; I’ll just say that the trick to that is through the use of transparency when drawing the highlights. If you want to see what I did, look in MenuExtender\Drawers\OfficeXPDrawer.cs.

We’ll start with an implementation which compiles, but doesn’t do much of anything

public class MyMenuDrawer : MenuExtender.MenuDrawer
{
  public MyMenuDrawer() {}

  public override Size Measure()
  {
    return new Size( 16, 16 );
  }

  public override void DrawMenu()
  {
    // ...
  }
}

First let’s plot out what the menu will look like. We will need several different mock-ups to represent each of the different possibilities.

The following mock-ups are needed to handle each state. In this implementation some of the mock-ups will be duplicated, that is okay because this is just a list of all the states I could come up with:

  • Top level normal
  • Top level hover
  • Top level selected (after its been clicked)
  • Top level disabled
  • Sub-menu normal
  • Sub-menu disabled
  • Sub-menu hover
  • Context-menu default item
  • Context-menu default item disabled

In addition to those are the various types of “icon”s you have to deal with.

  • Image normal
  • Image disabled
  • Image hover
  • Image checked (Image applied and the Checked property is true)
  • Checkmark normal
  • Checkmark disabled
  • Checkmark hover
  • Radiomark normal
  • Radiomark disabled
  • Radiomark hover

When you are creating your own styles it is important that you base your colors on those found in the SystemColors class. This ensures that the menu looks good on all systems, unless the user has poor aesthetics.

Measuring the Menu

Each regular menu item will typically have several parts to it:

  • A left margin to give a little breathing room
  • A place for an icon, checkmark, or radiomark to be drawn
  • A space between any icon and the text
  • A place for the text
  • A space between the text and any shortcut which may need to be displayed
  • A place for the shortcut text
  • A space for the submenu arrow which also acts as the right margin between any text and the right boundary

A top level menu item will typically only have three parts to it:

  • Left margin
  • A place for the text
  • Right margin

A mock-up of the three states in a top-level menu item.

Here are a few of the mock-ups I did to design the style we will soon create. Each mock-up is shown at 4 times the normal size with the margins and spacers drawn in funky colors, my apologies to the color-blind reader.

This is the mock-up for a top-level MenuItem. There are 3 menu states shown here, from top-to-bottom. Enabled, Disabled, and Selected/Highlighted. The background for the Enabled and Disabled states is SystemColors.Control; the background for the Selected state is SystemColors.Highlight. The text color for each is SystemColors.MenuText, SystemColors.GrayText (or drawn using ControlPaint.DrawStringDisabled), and SystemColors.HighlightText respectively.

There is a 2-pixel margin on the left and right side of the text and a 1-pixel margin on the top and bottom sides of the text. These are the minimum sizes that will be used. When measuring the MenuItem, the code will return a value no smaller than the size Windows defines for menus, SystemInformation.MenuHeight.

A mock-up of the three states in a normal menu item.

This one has a lot more going on in it, with 3 more spacers introduced and 2 additional parts to the MenuItem to draw.

The space reserved for an icon, checkmark, or radiomark will always be 16 pixels wide, centered between the top and bottom margin and starting right after the left margin.

Immediately following that will be a 4-pixel space to give the text some breathing room. The text will follow taking as much space as it needs, and centered between the top and bottom margins. If there is a shortcut, then there is at least a 13-pixel space between the text and the shortcut; with the shortcut being right aligned in its space, again centered between the top and bottom margins. Regardless if there is a shortcut there is a 13-pixel space between any text and the right margin. This gives a place for the little arrow to display indicating that the menu item has children.

Each piece is centered between the top and bottom margins and the menu will be at least as tall as SystemInformation.MenuHeight.

In the normal, enabled, state the menu will have a background of SystemColors.Menu with any text, checkmarks, or radiomarks drawn in SystemColors.MenuText color. Images will be drawn normally.

In the disabled state the menu will have the same background as the normal state, but the text color will be SystemColors.GrayText or drawn using ControlPaint.DrawStringDisabled and the icon will be drawn in a grayscale.

In the highlighted or selected state, the menu will use SystemColors.Highlight as the background color, with text, checkmarks, and radiomarks in SystemColors.HighlightText color. Images will be drawn normally.

With these parts in mind, let’s start defining some of the constants we need for each of the spacers. At the top of the MyMenuDrawer class add these definitions

public class MyMenuDrawer : MenuExtender.MenuDrawer
{
  private const int LeftMargin = 2;
  private const int IconToTextMargin = 4;
  private const int TextToShortcutMargin = 13;
  private const int AnyTextToRightMargin = 13;
  private const int RightMargin = 2;  
  private const int IconHeight = 16;
  private const int IconWidth = 16;
  private const int TopMargin = 1;
  private const int BottomMargin = 1;

  // ...
}

When you are measuring these items you just need to add up all of the widths, and take the maximum height needed for any of the elements. It is important to note that the size returned here won’t necessarily be the same size you get when you are told to draw the MenuItem.

Using the mock-ups as a template we can begin to construct our MenuDrawer

public override Size Measure()
{
  Size size = new Size( 0 , 0 );

  Font menuFont = SystemInformation.MenuFont;
  
  // DefaultItem's get drawn with a bold font
  if( !IsTopLevel && MenuItem.DefaultItem )
  {
    menuFont = new Font( menuFont, FontStyle.Bold );
  }

  // I have no idea what resources StringFormat would be using
  //   but it implements IDisposable so we better use it
  // GetStringFormat() takes into account whether we need to show
  //   or hide the Accelerator so I use it rather than doing the test
  using( StringFormat stringFormat = GetStringFormat() )
  {
    // Get the size of our text
    SizeF textSize;

    textSize = Graphics.MeasureString( MenuItem.Text, menuFont,
      10000, stringFormat );  // The 10000 is the max width allowed

    // Now that we know the size of the text set that as our size right now
    size = textSize.ToSize();

    // There are three parts that all MenuItems have:
    //  text (size.Width), and the two margins so account for those now
    size.Width += LeftMargin + RightMargin;

    // If this is a regular menu item then we need to add 
    // in the additional space
    if( !IsTopLevel )
    {
      size.Width += IconWidth + IconToTextMargin + AnyTextToRightMargin;

      // If there is a shortcut add that in as well
      if( MenuItem.ShowShortcut && MenuItem.Shortcut != Shortcut.None )
      {
        string shortcut = GetShortcutText();

        textSize = Graphics.MeasureString( shortcut, menuFont,
          10000, stringFormat );

        size.Width += textSize.ToSize().Width + TextToShortcutMargin;
      }

      // Ensure it is at least as big as our icon size plus the 
      // top and bottom margin
      int minHeight = IconHeight + TopMargin + BottomMargin;

      if( size.Height 

Drawing the Menu

If you recall from the previous section, there were several different values that we defined to serve as spacers between various parts of the MenuItem. Now that we are getting ready to draw the MenuItem one of those values is no longer needed. In particular we will not use the TextToShortcutMargin constant. We need to take into consideration that the Bounds given to us for drawing may not match the size we returned while measuring it. According to the mock-ups each non-top-level MenuItem is really made up of three columns. Two that are left aligned - the icon and the text - and one that is right aligned - the shortcut text. In order to right align the shortcut text we start our text at the right margin and move it back so that the right edge of the text is aligned with the boundary we specified earlier. By doing so we can safely ignore the TextToShortcutMargin constant. Since the drawing is done in parts we can break the DrawMenu method up so it becomes easier to work with. We'll start by completely implementing the DrawMenu method and creating stubs for the other three methods we'll use from it.
public override void DrawMenu()
{
  // Start by drawing our background color
  // the highlight is simply a different background color
  if( IsHighlighted )
    Graphics.FillRectangle( SystemBrushes.Highlight, Bounds );
  else if( IsTopLevel )
    Graphics.FillRectangle( SystemBrushes.Control, Bounds );
  else
    Graphics.FillRectangle( SystemBrushes.Menu, Bounds );

  if( IsSeperator )
  {
    DrawSeperator();
  }
  else
  {
    DrawIcon();

    DrawText();
  }
}

It is important that you use Graphics.FillRectangle to draw the background rather than Graphics.Clear. If double buffering is turned off, Graphics.Clear can clear out not only the menu but the entire Form – non-client area and all.

And add the following stub methods, which we’ll fill in shortly

private void DrawSeperator()
{
}

private void DrawIcon()
{
}

private void DrawText()
{
}

Now that we have the stubs there, let’s start filling them in. The easiest to do will be the DrawSeparator method.

private void DrawSeperator()
{
  ControlPaint.DrawBorder3D( Graphics, Bounds.X + LeftMargin, 
    Bounds.Y + TopMargin, Bounds.Width - LeftMargin - RightMargin,
    Bounds.Height - TopMargin - BottomMargin, Border3DStyle.Etched,
    Border3DSide.Top );
}

That’s it! Here and elsewhere I will make use of the ControlPaint class to make drawing certain things easier. Here it draws what should be a 2-pixel tall 3D line across the width of the menu (minus the 2 margins).

The other two methods aren’t quite as easy, but the only hard part is in the positioning of the items that make up the menu.

private void DrawIcon()
{
  // Let us work from left to right now
  // This drawer will only use the Image extended property
  //  to cut down on code bloat...to use ImageLists see how the 
  //  built-in drawers do it
  if( MenuItem.Checked )
  {
    MenuGlyph glyph = MenuGlyph.Checkmark;

    // Use a bullet instead of a checkmark?
    if( MenuItem.RadioCheck )
      glyph = MenuGlyph.Bullet;

    // Draw the glyph
    if( IsHighlighted )
      MenuDrawer.DrawMenuGlyphHighlighted( Graphics, 
        Bounds.X + LeftMargin, Bounds.Y + TopMargin, 
        IconWidth, IconHeight, glyph );
    else
      MenuDrawer.DrawMenuGlyph( Graphics, Bounds.X + LeftMargin, 
        Bounds.Y + TopMargin, IconWidth, IconHeight, glyph );
  }
  else if( MenuInfo.Image != null )
  {
    // Width and height no larger than IconWidth and IconHeight, resp.
    int w = (MenuInfo.Image.Width > IconWidth) ? IconWidth : 
      MenuInfo.Image.Width;
    int h = (MenuInfo.Image.Height > IconHeight) ? IconHeight : 
      MenuInfo.Image.Height;

    // Upper left corner
    int x = Bounds.X + LeftMargin;
    int y = Bounds.Y + TopMargin;

    // Center the icon within the area for it
    if( w != IconWidth )
      x += (IconWidth - w) / 2;
    if( h != IconHeight )
      y += (IconHeight - h) / 2;

    Graphics.DrawImage( MenuInfo.Image, x, y, w, h );
  }
}

To make the code small enough to duplicate here this drawer only supports the use of the Image property and not the ImageLists. There isn’t anything really magic going on in the ImageList code, so look at the NormalDrawer.cs file to see how they are used to draw the different states.

In this method a couple of things are done; if the MenuItem should have a check next to it, it checks – pardon me – if it should be a checkmark or a bullet before drawing the appropriate MenuGlyph using the helper methods MenuDrawer.DrawMenuGlyph and MenuDrawer.DrawMenuGlyphHighlighted.

Finally if it shouldn’t have a check or bullet; it determines if an image is set. If an image is to be drawn it calculates the size of the image. If it is larger than the size we’ve allotted for it then it will get shrunk to the maximum size allowed. It will not expand images to a larger size though. If the image is smaller than the allowed size then the image will be centered within the box allowed for it. Images should be sized to 16×16.

Now it is time to draw the text in the MenuItem. We’ll start with the very basics, so basic that it doesn’t draw anything.

private void DrawText()
{
  using( StringFormat stringFormat = GetStringFormat() )
  {
    Font menuFont = SystemInformation.MenuFont;

    // Use a bold font for the default items
    if( !IsTopLevel && MenuItem.DefaultItem )
    {
      menuFont = new Font( menuFont, FontStyle.Bold );
    }

    // Insert the first routine here

    // Insert the second routine here

    // Dispose of the bold font if it was created
    if( !IsTopLevel && MenuItem.DefaultItem )
    {
      menuFont.Dispose();
    }
  }
}

All we do here is get the StringFormat to use for drawing the MenuItem, set it up in a using statement then check to see if a the font should be in bold, in which case it must also be disposed of at the end of drawing the text.

Now that the frame is set up there are only two things to do: draw the text and draw the shortcut text.

// Measure the text
SizeF textSize = Graphics.MeasureString( MenuItem.Text, menuFont, 10000, 
  stringFormat );

// Now to draw the text
int textX = 0;

if( !IsTopLevel )
{
  // Add in space for the icon and its spacer
  textX += Bounds.X + LeftMargin + IconWidth + IconToTextMargin;
}
else
{
  // The text should be centered within its bounds
  //    taking in account the left and right margins
  textX = (Bounds.Width - (int) textSize.Width - LeftMargin - RightMargin) / 2;
}

// Don't add top margin, it will be added by the centering code
// since it will at least be 2 * TopMargin larger than needed
int textY = Bounds.Y + TopMargin;

// Center the text within the margins
textY += (Bounds.Height - (int) textSize.Height - TopMargin - BottomMargin) / 2;

All of this sets up where the text should be drawn. For TopLevel items the text should be drawn centered horizontally. For all other items it should be drawn to the right of the icon margin. All MenuItems should be centered vertically. Both types of centering are done by taking into account the width/height and the margins.

Now that the point to start drawing the text has been determined, its time to actually draw it.

// Finally draw the text
if( MenuItem.Enabled )
{
  Brush menuBrush;

  if( IsHighlighted )
  {
    menuBrush = new SolidBrush( SystemColors.HighlightText );
  }
  else
  {
    menuBrush = new SolidBrush( SystemColors.MenuText );
  }

  using( menuBrush )
  {
    Graphics.DrawString( MenuItem.Text, menuFont, menuBrush, 
      textX, textY, stringFormat );
  }
}
else
{
  RectangleF textBounds = new RectangleF( textX, textY, 
    textSize.Width, textSize.Height );

  ControlPaint.DrawStringDisabled( Graphics, MenuItem.Text, 
    menuFont, SystemColors.Control, textBounds, stringFormat );
}

The disabled case is easier to deal with to start with; it simply creates a RectangleF to represent the boundaries of the text. This is easily done since we already measured the text and we have the location to start drawing, from there it’s simply plugging the values into the constructor. Finally it draws the text using ControlPaint.DrawStringDisabled.

In the Enabled case the only work done is to create a new SolidBrush to use for drawing depending on whether the MenuItem is currently highlighted. If it is it should use SystemColors.HighlightText otherwise it should use SystemColors.MenuText for the color. Again, it’s just a matter of plugging values into Graphics.DrawString.

Both instances use the StringFormat created at the top of the method.

Now its time to tackle the code to draw the Shortcut text, if one exists. The code for it will look very familiar; in fact it’s just getting the size of the shortcut text and repositioning the text based on where it should be drawn. In this case the text should be drawn, right-aligned to the RightMargin. Once we have the size of the Shortcut text it is positioned by taking the overall width, subtracting the two margins, then finally subtracting the width of the text.

// If there is a shortcut...
if( !IsTopLevel && 
  MenuItem.ShowShortcut && MenuItem.Shortcut != Shortcut.None )
{
  string text = GetShortcutText();

  textSize = Graphics.MeasureString( text, menuFont, 
               10000, stringFormat );

  // The shortcut is right aligned so start on the
  //  right edge and work our way back
  textX = Bounds.X + Bounds.Width;
  textX -= (RightMargin + AnyTextToRightMargin + (int) textSize.Width);

  // Center the text vertically within the margins
  textY = Bounds.Y + TopMargin;
  textY += (Bounds.Height - (int) textSize.Height 
            - TopMargin - BottomMargin) / 2;

  // Finally draw the text
  if( MenuItem.Enabled )
  {
    Brush menuBrush;

    if( IsHighlighted )
    {
      menuBrush = new SolidBrush( SystemColors.HighlightText );
    }
    else
    {
      menuBrush = new SolidBrush( SystemColors.MenuText );
    }

    using( menuBrush )
    {
      Graphics.DrawString( text, menuFont, menuBrush, 
        textX, textY, stringFormat );
    }
  }
  else
  {
    RectangleF textBounds = new RectangleF( textX, textY, 
      textSize.Width, textSize.Height );


    ControlPaint.DrawStringDisabled( Graphics, text, 
      menuFont, SystemColors.Control, textBounds, stringFormat );
  }
}

Second verse, same as the first…well pretty much anyway. As I mentioned it’s the same code, drawing different text in a slightly different position.

Congratulations, you’ve just implemented your own drawer! The only thing to do now is to handle the GetDrawer event to return a new instance of MyMenuDrawer and to set the MenuExtenderComponent.Style to MenuStyle.Custom.

Tying it all Together

Now that you have a brief, or not so brief, overview of all of the components it’s time to bring them all together.

The MenuExtenderComponent is considered the heart of the system, but it can’t do its job without the other two. It relies on the MdiChildManager to be told when to update and the MenuDrawers to do the visible work. By making use of the IExtenderProvider interface it is possible to hook all of the MenuItems in an application, instantly allowing the change from a bland menu into the style made famous by Office XP.

There are still a couple of things to talk about; earlier you saw where the MenuExtenderComponent performed its set up of the MenuItems that were registered to it. There was one method call that I never talked about though; EnableMDISupport.

public void EnableMDISupport()
{
  if( Host == null )
    throw new InvalidOperationException("...");

  if( this.childManager == null )
  {
    this.childManager = new MdiChildManager( Host );

    // HACK: Work around problem with .NET v1.1 with MDI merged menus, 
    //       assume unfixed in .NET v2.0
    if( Environment.Version.Major >= 1 && Environment.Version.Minor >= 1 )
    {
      this.childManager.MdiChildClosed += 
         new MdiChildActionEventHandler(OnMdiChildClosed);
    }
  }
}

As I noted several times before, in order for MDI support to work the Host property MUST be set. After that I merely ensure that an MdiChildManager hasn’t already been created before creating one and then adding in some code I use as a hack or workaround that I talk about below.

Limitations

As much as I’d like to say there are no limitations to the component, there are two limitations that I have found. I was able to come up with a work around for one, but the other seems completely broken and nothing I can fix.

NotifyIcon

The component does not work with the NotifyIcon control which places an icon in the system notification area, commonly called the system tray. For whatever reason it just doesn’t route the owner draw messages back to the menu so those events never even fire.

Merged Menus under .NET v1.1 (and above?)

When using .NET version 1.1, the merged menu functionality breaks after two children with merged menus are opened up. This doesn’t happen under 1.0 and it doesn’t happen until two children are open at the same time. As long as a child is open it also doesn’t happen.

Something changed somewhere in the framework, but I’m unsure what happened.

I have added a work-around to this problem, by utilizing a Timer under .NET v1.1 and above. The downside of course is that this might not always work and without at least a beta of .NET 2.0 to demo it, I have no idea if it will continue to work.

When MDI support is enabled, I do a check to see if what version of the framework we are running under. If we run under v1.1 or above, then I do a few extra things:

public void EnableMDISupport()
{
  if( Host == null )
    throw new InvalidOperationException("...");

  this.childManager = new MdiChildManager( Host );

  // HACK: Work around problem with .NET v1.1 with MDI merged menus, 
  //       assume unfixed in .NET v2.0
  if( Environment.Version.Major >= 1 && Environment.Version.Minor >= 1 )
  {
    this.childManager.MdiChildClosed += new 
      MdiChildActionEventHandler(OnMdiChildClosed);
  }
}

Now that we handle the MDI child closed event I can try to fix the problem when the last MDI child is closed.

private void OnMdiChildClosed(object sender, MdiChildActionEventArgs e)
{
  if( this.childManager.Children.Length == 0 )
  {
    Timer timer = new Timer( );
    timer.Interval = 100;
    timer.Tick += new EventHandler(HACKToggleOwnerDraw);
    timer.Start();
  }
}

If the last child has been closed then we’ll create a new Timer object, set the interval to 0.1 seconds, add our event handler and start the timer. In this case I’m not so much concerned with how long the interval is, just to delay it enough that the MDI menu merge has a chance to screw up so I can fix it. If I didn’t use a Timer it would still cause some problems.

private void HACKToggleOwnerDraw(object sender, EventArgs e)
{
  foreach(DictionaryEntry de in this.menuItems)
  {
    MenuItem mi = (MenuItem) de.Key;
      
    // Toggle owner-draw to update
    mi.OwnerDraw = false;
    mi.OwnerDraw = true;
  }

  Timer timer = (Timer) sender;
  timer.Stop();
  timer.Dispose();
}

As you can see, just as I toggled the OwnerDraw property to force menu items to re-measure when the Style changes, it also works to fix the bug. Then, since the Timer is no longer used, it is stopped and Dispose is called on it.

Future Versions

Currently the MdiListManager will add as many MenuItems as there are MDI children open. I would like to limit this to either a user-defined number of items or 10. It also doesn’t provide any menu accelerator keys to quickly choose the child you want to activate. This should be remedied at the same time.

I may add the Office 2003 style the first (unreleased) version of the component had. I took it out because it just wasn’t quite right when I can’t draw the soft left-right gradient across the entire length of the menu bar.

Any other suggestions should be left on the message board below.

License

The code and binary component follows the FreeBSD license:

MenuExtenderComponent Copyright © 2004 James T. Johnson. All rights reserved.

Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:

  1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
  2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution

THIS SOFTWARE IS PROVIDED BY JAMES T. JOHNSON “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FREEBSD PROJECT OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

This article is Copyright © 2004 James T. Johnson and requires my written permission to be reproduced elsewhere.

Article History

  • March 18, 2004
    • Initial Version
Advertisements

About James

I am a Senior Developer/Consultant for InfoPlanIT, LLC. I previously spent over 7 years as a Product Manager for what eventually became ComponentOne, a division of GrapeCity. While there, I helped to create ActiveReports 7, GrapeCity ActiveAnalysis, and Data Dynamics Reports.

Discussion

No comments yet.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

Archive

%d bloggers like this: