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:
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:
="1.0"="utf-8"
<Help xmlns="http://addin-help">
<AddInHelp key="OfficeHelpSampleRibbon">
<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:
using RibbonHelp.Core;
namespace AddIns.Namespace
{
public partial class ThisAddIn
{
private RibbonHelpContext ribbonHelp;
private void ThisAddIn_Startup(object sender, System.EventArgs e)
{
var hc = HelpCollection.FromXmlString(Resources.Help);
ribbonHelp = new RibbonHelpContext(hc);
ribbonHelp.HelpActivated += (o, args) => Debug.WriteLine(args.HelpItem.HelpId);
}
private void ThisAddIn_Shutdown(object sender, System.EventArgs e)
{
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:
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.
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.
public class RibbonShowHideContext : RibbonContextBase
{
internal override void Callback(IntPtr hWinEventHook, WinEvent eventType, IntPtr hwnd,
uint idObject, uint idChild, uint dwEventThread, uint dwmsEventTime)
{
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.
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.
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))
{
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;
}
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;
}
}
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.
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.