Here Tabby!

I don’t play with VSTO much, but I had an opportunity recently to debug an issue with tab order in Form Regions that resulted in an interesting workaround.

This issue was this: Suppose you’ve created an Outlook 2007 Add-in project in C# in Visual Studio 2008. To this project, you’ve added an Outlook Form Region, accepting all the defaults. And on this form region, you added a few standard controls: maybe a couple text boxes and a couple combo boxes. Up until now, you haven’t actually written any code – the wizards did all the work for you. You fire up this add-in to test it out:

Test Form Region showing four controls

So far so good. Now click on the first combo box and hit Tab. You’d expect the cursor to jump to the second combo box. But it doesn’t. It jumps to the second edit box. Click back in the first combo box and hit Tab. Again, you expect the cursor to jump to the second combo box. Instead it flickers and lands back in the first combo box.

What’s going on here is for certain controls hosted in this fashion, WM_SETFOCUS is not being received when you click on them. Since they don’t get WM_SETFOCUS, they don’t set the internal properties that indicate to WinForms that they are the current control. So when you hit Tab, we look at the current control, which is not the one with the cursor in it, figure out the next control in the tab order, and set focus there.

It’s not just combo boxes affected by this bug. All sorts of common controls show the same problem. Even regular edit boxes, which appeared to work, are not immune. You can right click in an edit box and hit Esc to dismiss the drop down menu. Now the text box has the cursor, but isn’t the current control.

This has been confirmed to be a bug in WinForms (it can be reproduced without VSTO), and should be addressed in a future version. Fortunately, we don’t have to wait to get this to work. One of our Visual Studio devs gives the following workaround:

Since WM_PARENTNOTIFY is sent to the parent window whenever a child window is clicked, we can hook that message in WndProc and set focus manually. You can drop the following in your FormRegion class and the tabs will start working:

         protected override void WndProc(ref Message m)
        {
            switch (m.Msg)
            {
                case NativeMethods.WM_PARENTNOTIFY:
                    if (NativeMethods.Util.LOWORD(m.WParam) == NativeMethods.WM_LBUTTONDOWN ||
                        NativeMethods.Util.LOWORD(m.WParam) == NativeMethods.WM_RBUTTONDOWN)
                    {
                        // Get x and y coordinate
                        int x = NativeMethods.Util.LOWORD(m.LParam);
                        int y = NativeMethods.Util.HIWORD(m.LParam);
                        // Get a child control according to coordinates
                        Control control = GetChildControl(x, y);
                        if (control != null && !control.Focused)
                            control.Focus();
                    }
                    break;
            }
            base.WndProc(ref m);
        }
        private Control GetChildControl(int x, int y)
        {
            foreach (Control control in this.Controls)
            {
                Rectangle rect = new Rectangle(control.Location, control.Size);
                if (rect.Contains(x, y))
                    return control;
            }
            return null;
        }
        internal class NativeMethods
        {
            public const int WM_PARENTNOTIFY = 0x0210;
            public const int WM_LBUTTONDOWN = 0x0201;
            public const int WM_RBUTTONDOWN = 0x0204;
            public static class Util
            {
                public static int HIWORD(int n)
                {
                    return (n >> 16) & 0xffff;
                }
                public static int LOWORD(int n)
                {
                    return n & 0xffff;
                }
                public static int HIWORD(IntPtr n)
                {
                    return HIWORD(unchecked((int)(long)n));
                }
                public static int LOWORD(IntPtr n)
                {
                    return LOWORD(unchecked((int)(long)n));
                }
            }
        }