Contents
Most application developers have been through it. During the later stages of a project,
you find yourself spending time working with an HTML Help author to connect the
screens and dialogs of your application to context sensitive help.
This article introduces a way to instrument your code that enables help authors to
associate help topics with the application's visual contexts at any time - even
post-compilation – and to do so using the application's user interface without
the involvement of the developer. There is no need for the help author to
manually edit any files or have knowledge of any internals.
Like my earlier
articles [^], this one steps you through the code writing
process, displaying code snippets for each change. I'm sure that readers will
have many valuable comments, criticisms, and suggestions to offer. You are
encouraged to do so in the discussion section at the bottom of the article. I
will do my best to incorporate your thoughts in the next revision of the
article - but even if I'm a lazy bum and don't get around to it, you will be
sharing your perceptions and ideas with others in the community.
When you're running an application (as in life), you are always in multiple contexts at
any given moment. For example, if the focus is in a particular TextBox
control, the context can be all of the following: the TextBox
, a TabPage
containing the TextBox
, the Tab
control containing the
TabPage
, the dialog box containing the TabPage
, and the application
itself. It is up to the help author to decide which of these contexts should have an
associated help topic.
The general approach many applications take when launching Context Sensitive Help is to
start with the control with the focus and check to see if it has an associated
help topic. If so, the application launches the topic, and if not, it moves
outward (i.e., up the parent chain) to the containing control and repeats the
process.
We'll start by implementing a system for displaying an associated topic when the F1 key
is pressed, by using a ContextID
to topic mapping that will be
stored in an XML configuration file. After this is working, we'll get to adding
the feature that enables help authors to edit these mappings from inside the
application UI. Click on this link to skip to
the fun part. :)
The first step is to create an Interface (IContextHelp
) that you can add to
the various controls in your project. This interface identifies controls that
are candidates for acting as a context for help, and provides a unique string
identifier (ContextID
) to identify the context.
public interface IContextHelp
{
string ContextHelpID { get; }
}
Since such controls are typically derived from UserControl
, we can simplify
things further by creating a UserControlEx
that implements the
interface. To use this, derive your user controls from UserControlEx
instead of UserControl
. In the default implementation, the ContextID
for the control is the fully qualified class name of the control.
public virtual string ContextHelpID {get{return this.GetType().FullName;}}
Similarly, we create a FormEx
that implements IContextHelp
in
the same way. When you define a form in your application, change it to derive
from FormEx
instead of Form
.
If you want to provide context help for standard Windows or third party controls, you
can do so easily by deriving new controls and adding the interface. For
example:
public class PanelEx : Panel, IContextHelp
{
private string m_sContextID;
public string ContextHelpID
{
get
{
if (string.IsNullOrEmpty(m_sContextID))
return this.Name;
return m_sContextID;
}
set { m_sContextID = value; }
}
}
In this case, we use the control's name as the default ID, and permit it to be
overridden in case there is a name conflict. In practice, that hasn't been
necessary for projects I've worked on.
If you need to provide a context ID for many different standard Windows or third party
controls, you should consider implementing an IExtenderProvider
as
described by
Charles Williams [^] in his excellent
comment[^]
to the original version of this article.
You can implement a single handler to trap and respond to the F1 key press throughout
your application by adding a message filter to your application, and checking
for F1 when the WM_KEYDOWN
message is received.
static void Main()
{
MessageFilter oFilter = new MessageFilter();
System.Windows.Forms.Application.AddMessageFilter(
(IMessageFilter)oFilter);
}
internal class MessageFilter : IMessageFilter
{
#region IMessageFilter Members
bool IMessageFilter.PreFilterMessage(ref Message m)
{
switch (m.Msg)
{
case 0x100 : if ((int)m.WParam == (int)Keys.F1)
{
HelpUtility.ProcessHelpRequest(Control.FromHandle(m.HWnd));
return true;
}
break;
}
return false;
}
#endregion
}
Now that we've taken care of the infrastructure, it's time to show how to handle the F1
key. Later, we'll modify this method to test for the Control key, but for now
it looks like this:
public static class HelpUtility
{
public static void ProcessHelpRequest(Control ctrContext)
{
ShowContextHelp(ctrContext);
}
}
The ShowContextHelp()
method travels up the parent chain, looking for a
control that:
- implements
IContextHelp
,
- has a non-empty
IContextHelp.ContextHelpID
, and
- has a corresponding entry in the mapping XML file.
If one is found, it launches the help viewer to display it. If not, it launches the
default topic.
public static void ShowContextHelp(Control ctrContext)
{
Control ctr = ctrContext;
string sHTMLFileName = null;
while (ctr != null)
{
IContextHelp help = GetIContextHelpControl(ctr);
if (help == null)
break;
if (help.ContextHelpID != null)
{
sHTMLFileName = LookupHTMLHelpPathFromID(help.ContextHelpID);
if (sHTMLFileName != null && ShowHelp(ctrContext, sHTMLFileName))
return;
}
ctr = ((Control)help).Parent;
}
ShowHelp(ctrContext, "");
}
The GetIContextHelpControl()
method traverses up the parent chain, looking
for an IContextHelp
control.
private static IContextHelp GetIContextHelpControl(Control ctl)
{
while (ctl != null)
{
IContextHelp help = ctl as IContextHelp;
if (help != null)
{
return help;
}
ctl = ctl.Parent;
}
return null;
}
The ShowHelp()
method launches HTML help, displaying the topic specified by
sHTMLHelp
.
private static bool ShowHelp(Control ctlContext, string sHTMLHelp)
{
try
{
if (string.IsNullOrEmpty(sHTMLHelp))
Help.ShowHelp(ctlContext, HelpUtility.HelpFilePath);
else
Help.ShowHelp(ctlContext, HelpUtility.HelpFilePath,
HelpNavigator.Topic, sHTMLHelp);
}
catch (ArgumentException)
{
return false;
}
return true;
}
private const string mc_sHELPFILE = "ContextHelpMadeEasy.chm";
private static string HelpFilePath
{
get
{
return Path.Combine(System.Windows.Forms.Application.StartupPath,
mc_sHELPFILE);
}
}
To complete F1 help, we need to implement the LookupHTMLHelpPathFromID()
function
called from ShowContextHelp()
. This method examines the ID to
topic mappings, and returns the topic filename if a mapping exists. The
mappings are read from the XML configuration file on first access, and cached
to avoid reading the file every time a user presses F1. We start by defining a
StringDictionary
to hold the mapping cache. In addition, we define a
number of constants which represent the mapping file name, and various XML
elements and attribute names.
static private StringDictionary ms_sdContextPaths = null;
private const string mc_sMAPPING_FILE_NAME = "HelpContextMapping.Config";
private const string mc_sIDMAP_ELEMENT_NAME = "IDMap";
private const string mc_sCONTEXTID_ELEMENT_NAME = "ContextID";
private const string mc_sID_ATTRIBUTE_NAME = "ID";
private const string mc_sHTMLPATH_ATTRIBUTE_NAME = "HTMLPath";
The MappingFilePath
property returns the path to the mapping file, which is
assumed to be in the same directory as the application executable.
private static string MappingFilePath
{
get { return Path.Combine(Application.StartupPath, mc_sMAPPING_FILE_NAME); }
}
The ContextPaths
property implements the cache by reading the mapping file
only on the first call.
private static StringDictionary ContextPaths
{
get
{
if (ms_sdContextPaths == null)
{
ms_sdContextPaths = ReadMappingFile();
}
return ms_sdContextPaths;
}
}
The ReadMappingFile()
method creates a StringDictionary
and
populates it with information read from the XML help mapping configuration
file.
private static StringDictionary ReadMappingFile()
{
StringDictionary sdMapping = new StringDictionary();
XmlDocument docMapping = new XmlDocument();
if (File.Exists(MappingFilePath) == true)
{
try { docMapping.Load(MappingFilePath); }
catch
{
MessageBox.Show(string.Format("Could not read help mapping file '{0}'.",
MappingFilePath), "Context Help Made Easy", MessageBoxButtons.OK,
MessageBoxIcon.Error);
throw;
}
XmlNodeList nlMappings = docMapping.SelectNodes("//" +
mc_sCONTEXTID_ELEMENT_NAME);
foreach (XmlElement el in nlMappings)
{
string sID = el.GetAttribute(mc_sID_ATTRIBUTE_NAME);
string sPath = el.GetAttribute(mc_sHTMLPATH_ATTRIBUTE_NAME);
if (sID != "" && sPath != "")
sdMapping.Add(sID, sPath);
}
}
return sdMapping;
}
A mapping file looks something like this:
="1.0"
<IDMap>
<ContextID ID="namespace.fsettings" HTMLPath="SettingsTopic.htm" />
<ContextID ID="controlname1" HTMLPath="Control1Topic.htm" />
<ContextID ID="overridenidctl2" HTMLPath="Control2Topic.htm" />
</IDMap>
With the cache implemented, the LookupHTMLHelpPathFromID()
implementation
becomes trivial.
private static string LookupHTMLHelpPathFromID(string sContextID)
{
if (ContextPaths.ContainsKey(sContextID))
return ContextPaths[sContextID];
return null;
}
Finally! We're done with the F1 implementation, and we can move on to the really cool
part - instrumenting your code to allow help authors implement context
sensitive help without your involvement.
We now have almost everything we need to disentangle the work of the help author with
the work of the developer. With the code as described, the author could
manually modify the XML configuration file to add and modify help mappings – if
they knew the ContextID
s for each application contexts which they
want to provide help for.
The idea here is that with the infrastructure already developed, we can turn the
application itself into a context sensitive editor for the configuration file.
We'll modify the code so that when a HTML help author navigates to some screen,
he or she can click Ctrl-F1 and get a dialog that displays all the available ContextID
s
for that screen. The HTML help author can then add or remove a ContextID
to HTML filename associations using the dialog, with the resulting association
written to the help mapping configuration file. Immediately after clicking OK,
they can press F1 and see the context sensitive help appear.
We can modify the ProcessHelpRequest()
method described above by adding a
check for the Control key as follows:
public static void ProcessHelpRequest(Control ctrContext)
{
if (Control.ModifierKeys == Keys.Control)
{
ShowHelpMappingDialog(ctrContext);
return;
}
ShowContextHelp(ctrContext);
}
In practice, you will probably want to put in an additional test to prevent end-users
from getting this dialog if they accidentally hit Ctrl-F1. In my
implementation, the test for the control key is ANDed with a registry check for
a key value that enables this feature. You can, of course, check the
application configuration file or anything else that could uniquely identify a
help author.
Now, if the Control key is pressed when the WM_KEYDOWN
message is handled,
instead of displaying help for the specified control, we call the ShowHelpMappingDialog()
method.
public static void ShowHelpMappingDialog(Control ctrContext)
{
IContextHelp help = GetIContextHelpControl(ctrContext);
List<ContextIDHTMLPathMap> alContextPaths = new List<ContextIDHTMLPathMap>();
while (help != null)
{
string sContextID = help.ContextHelpID;
if (sContextID != null)
{
string sHTMLHelpPath = LookupHTMLHelpPathFromID(sContextID);
alContextPaths.Add(new ContextIDHTMLPathMap(sContextID, sHTMLHelpPath));
}
help = GetIContextHelpControl(((Control)help).Parent);
}
if (FHelpMappingDialog.ShowHelpWriterHelper(alContextPaths) == true)
{
foreach (ContextIDHTMLPathMap pathMap in alContextPaths)
{
if (!string.IsNullOrEmpty(pathMap.ContextID))
{
if (!string.IsNullOrEmpty(pathMap.HTMLPath))
{
ContextPaths[pathMap.ContextID] = pathMap.HTMLPath;
}
else
{
if (ContextPaths.ContainsKey(pathMap.ContextID))
ContextPaths.Remove(pathMap.ContextID);
}
}
}
SaveMappingFile(ContextPaths);
}
}
}
The mapping dialog opened by calling FHelpMappingDialog.ShowHelpWriterHelper()
is discussed below. It basically acts as an editor for the list of ContextIDHTMLPathMap
structures.
The ContextIDHTMLPathMap
structure containing two strings and a
constructor:
public class ContextIDHTMLPathMap
{
public string ContextID;
public string HTMLPath;
public ContextIDHTMLPathMap(string ID, string Path)
{
ContextID = ID;
HTMLPath = Path;
}
}
Since we'd like our changes to be written immediately to the configuration file, we
implement this as a write-through cache. The SaveMappingFile()
method called from ShowHelpMappingDialog()
takes care of this:
private static void SaveMappingFile(StringDictionary sdMappings)
{
XmlDocument docMapping = new XmlDocument();
XmlDeclaration xmlDecl = docMapping.CreateXmlDeclaration("1.0", null, null);
docMapping.InsertBefore(xmlDecl, docMapping.DocumentElement);
XmlElement elIDMap = AddChildElementToNode(docMapping, docMapping,
mc_sIDMAP_ELEMENT_NAME);
foreach (DictionaryEntry de in sdMappings)
{
XmlElement elMapping = AddChildElementToNode(elIDMap, docMapping,
mc_sCONTEXTID_ELEMENT_NAME);
elMapping.SetAttribute(mc_sID_ATTRIBUTE_NAME, de.Key as string);
elMapping.SetAttribute(mc_sHTMLPATH_ATTRIBUTE_NAME, de.Value as string);
}
try
{
docMapping.Save(MappingFilePath);
}
catch
{
MessageBox.Show(string.Format("Could not write help mapping file '{0}'",
MappingFilePath), "Context Help Made Easy", MessageBoxButtons.OK,
MessageBoxIcon.Error);
throw;
}
}
The AddChildElementToNode()
utility function makes the code a bit more
readable:
private static XmlElement AddChildElementToNode(XmlNode node,
XmlDocument doc, string elementName)
{
XmlElement el = doc.CreateElement(elementName);
node.AppendChild(el);
return el;
}
The mapping dialog consists of a ListView
control, and buttons for editing
the topic file name, OK, and Cancel. It is an editor of the list of ContextIDHTMLPathMap
structures that contain the current control's contexts. The dialog populates
the ListView
control, permits editing of the HTML File names in
it, and writes the results back to the List<ContextIDHTMLPathMap>
.
The entry point for the dialog is the static method ShowHelpWriterHelper()
that
instantiates the form and initializes its data.
public static bool
ShowHelpWriterHelper(List<ContextIDHTMLPathMap> contextIDs)
{
FHelpMappingDialog frmHelper = new FHelpMappingDialog();
frmHelper.IDList = contextIDs; if( frmHelper.lvMapping.Items.Count > 0 )
frmHelper.lvMapping.SelectedIndices.Add(0);
frmHelper.ShowDialog(); if (frmHelper.Changed)
{
foreach (ListViewItem lvi in frmHelper.lvMapping.Items)
{
ContextIDHTMLPathMap pathMap = (ContextIDHTMLPathMap)lvi.Tag;
pathMap.HTMLPath = (string)lvi.SubItems[0].Text.Trim();
}
}
return frmHelper.Changed;
}
The ListView
is populated in the IDList
property set
method:
public List<ContextIDHTMLPathMap> IDList
{
set
{
lvMapping.Items.Clear();
foreach (ContextIDHTMLPathMap pathMap in value)
{
AddMappingNode(pathMap);
}
}
}
private void AddMappingNode(ContextIDHTMLPathMap pathMap)
{
ListViewItem lvi = new ListViewItem(pathMap.HTMLPath);
lvi.SubItems.Add(pathMap.ContextID);
lvi.Tag = pathMap;
lvMapping.Items.Add(lvi);
}
Other methods in the dialog are what you'd probably expect:
private void btnEditTopicFile_Click(object sender, EventArgs e)
{
if( lvMapping.SelectedItems.Count == 1 )
{
ListViewItem lvi = lvMapping.SelectedItems[0];
lvi.BeginEdit();
}
}
private void lvMapping_AfterLabelEdit(object sender,
LabelEditEventArgs e)
{
this.Changed = true;
};
}
The details of implementing the dialog can be found in the source attached to this
article, but this should give you the basic idea.
We now have a functioning help system that can easily be configured post-compilation by
a help author. This section describes a few additional features to give you a
sense of how you can extend the system.
When you have a dialog with an associated ContextID
(e.g., one derived from
FormEx
), you may want the user to be able to click on the
"?" help button to bring up the HTML help for that context. We'll
modify FormEx
so that all dialogs have this behavior. Set the
following properties in the FormEx
designer to get the help button
to appear:
HelpButton = true;
MaximizeBox = false;
MinimizeBox = false;
Now we need to override the OnHelpButtonClicked()
method to call our help
handler and cancel the default behavior of changing the cursor.
protected override void OnHelpButtonClicked(CancelEventArgs e)
{
HelpUtility.ProcessHelpRequest(this);
base.OnHelpButtonClicked(e);
e.Cancel = true;
}
We're done :).
With a tab (or similar) control, you may want to change the default behavior of the help
system. For example, you'll probably want the ContextID
returned
when a tab is focused to be the ContextID
of the control contained
on the TabPage
. To do this, we just override the ContextID
property of the form that contains the tab control.
public override string ContextHelpID
{
get
{
switch (this.tcSettings.SelectedIndex)
{
case 0: return settingsGeneral1.ContextHelpID;
case 1: return settingsConfiguration1.ContextHelpID;
}
return base.ContextHelpID;
}
}
The attached sample application contains the source code for a fully instrumented
application illustrating the principles from this article. It also contains a
help file "ContextHelpMadeEasy.chm" that you can use to test
the application. This file should be located in the same directory as the built
executable. The topic file names in ContextHelpMadeEasy.chm are:
- Configuration_Settings.htm
- Configuration_Settings.htm
- Context_Help_Made_Easy_Test_Application.htm
- General_Settings.htm
- Settings.htm
- Topic_A.htm
- Topic_B.htm
- Topic_C.htm
- Topic_D.htm
- UI_1.htm
- UI_2.htm
- UI_1_Left_Side.htm
- UI_1_Right_Side.htm
- UI_2.htm
To see how it works, run the sample application and navigate to any of the application
screens using the File and View menus. Place your cursor in some control and
then, while holding down the Control key, click F1. The help author's dialog
will appear, which you can use to associate any of these topics with the
current context.
I'd like to thank my employer, Serena Software, for encouraging me to share these ideas
with the .NET community. And without my co-workers, of course, I'd be utterly lost.
- February 2, 2007: Initial version.
- February 8, 2007: Textual edits, TOC and reference to Charles Williams'
IExtenderProvider
suggestion.