Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / desktop / Win32

Custom Ribbon Help for Office 2010 VSTO Add-ins

4.59/5 (6 votes)
6 Dec 2012CPOL6 min read 60.3K   1K  
Providing custom context sensitive help for VSTO add-in Ribbon controls.

Opening a chm file when pressing f1 for help

Introduction

If you have ever written an Office add-in that includes a Ribbon, you may have noticed the "Press F1 for help" ScreenTip that pops up when you hover over it. If you're like me, you'll have wondered how to get this to display some custom help and be slightly dissappointed to find that you can't.

This articles describes the settings that affect ScreenTips and a possible method that can be used to display custom help for Ribbon controls in custom VSTO application level add-ins.

Background

VSTO application add-ins allow developers to extend Office applications. Whilst the models for each Office application vary, the core implementations of controls such as the Ribbon are the same.

One key feature of the Ribbon control is the ScreenTip that allows you to enter a description of each controls functionality. For a custom add-in, the bottom section of a ScreenTip shows a cog icon, your friendly add-in name and a "Press F1 for add-in help" message:

Add-in SuperTips sample

You can control whether or not ScreenTips and their SuperTips are displayed via the "File...Options...General" settings.

For any custom add-in, pressing F1 opens up the generic "View, manage, and install add-ins in Office programs" help topic. The aim of this article to show a way in which this behaviour can be altered to show custom help.

Office applications use Microsoft Active Accessibility (MSAA) to expose UI information to the outside world. The use of MSAA is central to the implementation of the code in this article. See the code project article UI Automation Using Microsoft Active Accessibility (MSAA) for an introduction to this topic.

Using the code

The RibbonHelpContext raises the HelpActivated event whenever the user presses the F1 key and the Ribbon is in the context of custom help. This event defines what's in context using the HelpActivatedEventArgs class.

You'll need to define what's providing custom help using an instance of IHelpCollection, which exposes a dictionary of RibbonHelpItem items. This lets the context know which controls it's providing custom help for (an undesired side affect of the code is that you can provide help for any part of the Ribbon).

A RibbonHelpItem describes two properties: a Key and a HelpId. They Key describes the controls path, a colon seperated list of the id's in the hierarchy. The HelpId is a just that, an identifer that you can use to activate the required help behaviour.

In my samples I've implemented IHelpCollection by serializing an Xml file:

XML
<?xml version="1.0" encoding="utf-8"?>
<Help xmlns="http://addin-help">
    
    <!-- Repeat this for each ribbon in your add-in -->
    <AddInHelp key="OfficeHelpSampleRibbon">
        <!-- An entry for each control you want to provide custon help for -->
        <HelpItem key="OfficeHelpSampleRibbon:group1:button1" helpId="1001" />
    </AddInHelp>

</Help>

To use the RibbonHelpContext hook into the add-ins Startup and Shutdown events to initialize and dispose an instance of RibbonHelpContext. It's especially important to call Dispose() in the Shutdown event to ensure that the windows api hooks are correctly removed:

C#
//...
using RibbonHelp.Core;
//...
namespace AddIns.Namespace
{
    public partial class ThisAddIn
    {
        private RibbonHelpContext ribbonHelp;

        private void ThisAddIn_Startup(object sender, System.EventArgs e)
        {
            // get an instance of IHelpCollection from somewhere...
            var hc = HelpCollection.FromXmlString(Resources.Help);
            
            // initialize the local instance
            ribbonHelp = new RibbonHelpContext(hc);

            // decide what you want to do when the help is activated...
            ribbonHelp.HelpActivated += (o, args) => Debug.WriteLine(args.HelpItem.HelpId);
        }

        private void ThisAddIn_Shutdown(object sender, System.EventArgs e)
        {
            // this bit is important to ensure the api hooks are removed correctly
            if (ribbonHelp != null) ribbonHelp.Dispose();
        }
        //...
    }
}

Running the samples

The sample solution contains an application level add-in for Excel, Word, Powerpoint and Outlook 2010. Each sample has a Ribbon, and uses an instance of RibbonHelpContext as described above.

When running any of the samples in debug mode you can use the trace pane to get an overview of the current context. You can show/hide the trace pane by clicking the "Toggle Trace Pane" button in the top left of each Ribbon sample:

Trace pane

Whilst running any of the samples in debug, if the control context label is green then pressing F1 will result in a custom help topic being displayed.

Implementation

The bulk of the work is done by two classes, RibbonShowHideContext and RibbonFocusContext. Each of these classes hook into MSSA events using the SetWinEventHook and use the AccessibleObjectFrom* P/Invoke api calls. Both classes subclass RibbonContextBase, which does the work of hooking up the events.

C#
//..
public abstract class RibbonContextBase : IRibbonContext
{
    protected IntPtr Hook { get; private set; }
    protected IHelpItems HelpCollection { get; private set; }

    private GCHandle hookDelegateHandle;
    private readonly WinEventProc hookDelegate;
    internal IWinApi Win32;

    internal RibbonContextBase(IHelpItems helpCollection, IWinApi win32, 
				WinEvent eventMin, WinEvent eventMax)
    {
        if (helpCollection == null)
            throw new ArgumentNullException("helpCollection");

        if (win32 == null)
            throw new ArgumentNullException("win32");

        this.HelpCollection = helpCollection;
        this.Win32 = win32;

        this.hookDelegate = Callback;
        this.hookDelegateHandle = GCHandle.Alloc(this.hookDelegate);

        this.Hook = this.Win32.SetWinEventHook(
            (uint)eventMin,
            (uint)eventMax,
            IntPtr.Zero, hookDelegate, 0, (uint)AppDomain.GetCurrentThreadId(), 0);
    }

    //..
    internal abstract void Callback(IntPtr hWinEventHook, WinEvent eventType,
                                  IntPtr hwnd, uint idObject, 
                                  uint idChild, uint dwEventThread,
                                  uint dwmsEventTime);
    //..
}

The combination of RibbonShowHideContext and RibbonFocusContext allow the tracking of both mouse and keyboard interaction.

RibbonShowHideContext

RibbonShowHideContext tracks two MSAA events, EVENT_OBJECT_SHOW and EVENT_OBJECT_HIDE. It doesn't track every single MSAA show and hide event, there are simply to many off them and it would be inefficient.

To prevent this, RibbonShowHideContext uses an instance of MouseContext, which is a straight forward implmentation of a windows hook, using the SetWindowsHookEx api call that tracks the WM_MOUSEMOVE message. Whenever the mouse is in the context of the Ribbon, ribbonshowhidecontext will process messages and set any required context. It uses the AccessibleObjectFromPoint P/Invoke api to establish the control in context.

C#
//..
public class RibbonShowHideContext : RibbonContextBase
{
    //..
    internal override void Callback(IntPtr hWinEventHook, WinEvent eventType, IntPtr hwnd, 
        uint idObject, uint idChild, uint dwEventThread, uint dwmsEventTime)
    {
        //-> don't do anything if we're not in the ribbon
        if (!this.mouseInContext) return;

        //..
    	Point cursorPosition;
	if (Win32.GetCursorPos(out cursorPosition) == 0) return;

    	IAccessible accFromPoint; 
    	object childFromPoint;
    	Win32.AccessibleObjectFromPoint(cursorPosition, out accFromPoint, out childFromPoint);
    	//..
    }
    //..
}

RibbonFocusContext

RibbonFocusContext tracks a single MSAA event, EVENT_OBJECT_FOCUS. It processes messages and sets any required context. Unlike the RibbonShowHideContext, it does process every EVENT_OBJECT_FOCUS message. It uses the AccessibleObjectFromEvent P/Invoke api to establish the control in context.

C#
//..
public class RibbonFocusContext : RibbonContextBase
{
    //..
    internal override void Callback(IntPtr hWinEventHook, WinEvent eventType, IntPtr hwnd, 
        uint idObject, uint idChild, uint dwEventThread, uint dwmsEventTime)
    {
    //..
        IAccessible accEventObject; 
    	object child; 
    	if(Win32.AccessibleObjectFromEvent(hwnd, idObject, 
    	          idChild, out accEventObject, out child) != 0x0) 
		return;
    	//..
    }
    //..
}

Both classes are effectively navigating the context controls parent hierarchy/path and then checking if that path is in the provided IHelpCollection.

The path is found by navigation the IAccessible interface of the object discovered from the relevant AccessibleObjectFrom* P/Invoke function.

C#
//..RibbonContextBase
private static readonly string[] RootList = 
  new[] { RibbonConstants.RibbonLower, RibbonConstants.Ribbon, RibbonConstants.RibbonTabs };

private List<string> GetAccessiblePathContext(IAccessible accessibleObj)
{
    var contextName = accessibleObj.accName[0];
    var lastName = this.ContextControl;
    var newContextPathList = new List<string>();
            
    if (!string.IsNullOrEmpty(contextName)) 
    {
        //-> Don't repeat
        if (contextName == this.ContextControl) return this.contextPathList;

        lastName = contextName;
        newContextPathList.Add(contextName);
    }

    IAccessible accParent = accessibleObj.accParent;

    while (accParent != null)
    {
        contextName = accParent.accName[0];

        if (contextName.IsNullOrEmpty())
        {
            accParent = accParent.accParent;
            continue;
        }

        //-> For when the Ribbon is collapsed
        if (contextName == RibbonConstants.RibbonTabs && newContextPathList.Count == 1)
        {
            var stateUint = Convert.ToUInt32(accessibleObj.accState[0]);
            var selectable = AccessibleState.HasState(stateUint, 
					Win.AccessibleStates.STATE_SYSTEM_SELECTABLE);

            if (selectable)
            {
                newContextPathList.Insert(0, "*Tab*");
                break;
            }
        }

        //-> once we hit any Ribbon root container we're not interested in any more hierarchy
        if (contextName.EqualsAny(StringComparison.OrdinalIgnoreCase, RootList))
            break;

        if (contextName != lastName)
        {
            newContextPathList.Add(contextName);
            lastName = contextName;
        }

        accParent = accParent.accParent;
    }

    newContextPathList.Reverse();
    return newContextPathList;
}

Points of Interest

ScreenTip style settings and behaviour

An interesting side effect of using the MSAA events to track context is how the behaviour almost naturally reflects the ScreenTip style options setting. There are three settings, and there is one subtle difference in how these settings affect Ribbon controls that cannot accept keyboard focus. A Ribbon control will only be ready to accept keyboard focus once something has been selected.

ScreenTip options

Apart from the "Don't show ScreenTips" setting, when you press F1 in the context of any Ribbon item you will be directed to the "View, manage, and install add-ins in Office programs" help topic.

When "Don't show ScreenTips" is on, Ribbon controls that have not recieved keyboard focus will direct you to the Office help index.

The behaviour of the custom help provider ends up working in the much the same way. You will be directed to the custom help topic when the Ribbon has recieved keyboard focus, otherwise you wil be directed to the Office help index.

One exception to this rule appears to be the RibbonComboBox. The items in the RibbonComboBox don't raise the EVENT_OBJECT_FOCUS event, so they can only be tracked by the RibbonShowHideContext, which is dependant on the ScreenTip behaviour.

This behaviour makes sense, given that we are using the show and hide events to track context, if there's nothing to show there's nothing for us to track. It also shows Ribbon controls only raise EVENT_OBJECT_FOCUS events once they are ready to accept keyboard focus - as per it's documented behaviour.

It's a bit too brutal

It's probably obvious, but tracking the Ribbon in this way and overriding the F1 key means you can pretty much change the help behaviour of the whole ribbon. Although this may sound like a neat side affect, I actually feel a little uncomfortable about it.

When implmenting keyboard hooks, there are some rules you are recommened to follow, one of them being always pass on the call to the next handler in the chain using CallNextHookEx. In order to stop the default Excel help opening in our custom help context, the call is not passed on when custom help is activated.

This may lead to other processses that rely on the F1 key to not function. In addition, we can never be sure if any other process is stopping our behaviour from working. I've only tested this code on a developer Windows 7 workstation with Office 2010. I'm sure there are some security and environmental considerations that have not been taken into account.

I wish I didn't have to think about it

Ultimately, I want to write add-ins that look and feel professional. I wish there was a better, documented, built in way of doing this. It could just be me, but I think devs want the option to provide and intergate custom help in some way and the "Press F1 for add-in help" ScreenTip is neither friendly or intuitive to both developers and end-users.

History

First version.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)