Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

Context Help Made Easy

0.00/5 (No votes)
2 Feb 2007 4  
This article introduces a new way of instrumenting your code that enables help authors 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.

Contents

Introduction

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.

The Context Mapping dialog box

Background

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.

What is Context?

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.

Implementing F1 Help

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 IContextHelp Interface

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:

// PanelEx is like a Panel, except that it implements IContextHelp
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.

Trapping the F1 Key

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()
{
   //add message filter
   MessageFilter oFilter = new MessageFilter();
   System.Windows.Forms.Application.AddMessageFilter(
                       (IMessageFilter)oFilter);
   // etc.
}
internal class MessageFilter : IMessageFilter
{
  #region IMessageFilter Members
  bool IMessageFilter.PreFilterMessage(ref Message m)
  {
     //Use a switch so we can trap other messages in the future.
     switch (m.Msg)
     { 
       case 0x100 : // WM_KEYDOWN
         if ((int)m.WParam == (int)Keys.F1)
         {
           HelpUtility.ProcessHelpRequest(Control.FromHandle(m.HWnd));
           return true;
         }
         break;
     }
     return false;
  }
  #endregion
}

Handling the F1 Key

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:

  1. implements IContextHelp,
  2. has a non-empty IContextHelp.ContextHelpID, and
  3. 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.

// Process a request to display help
// for the context specified by ctrContext.
public static void ShowContextHelp(Control ctrContext)
{
  Control ctr = ctrContext;

  string sHTMLFileName = null;
  while (ctr != null)
  {
    // Get the first control in the parent chain
    // with the IContextHelp interface.
    IContextHelp help = GetIContextHelpControl(ctr);
    // If there isn't one, display the default help for the application.
    if (help == null)
      break;
    // Check to see if it has a ContextHelpID value.
    if (help.ContextHelpID != null)
    {
      // Check to see if the ID has a mapped HTML file name.
      sHTMLFileName = LookupHTMLHelpPathFromID(help.ContextHelpID);
      if (sHTMLFileName != null && ShowHelp(ctrContext, sHTMLFileName))
        return;
    }
    // Get the parent control and repeat.
    ctr = ((Control)help).Parent;
  }
  // Show the default topic.
  ShowHelp(ctrContext, "");
}

The GetIContextHelpControl() method traverses up the parent chain, looking for an IContextHelp control.

// Get the first control in the parent chain
// (including the control passed in)
// that implements IContextHelp.
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.

// Display the specified help page.
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)
  {
    // Ideally, we would return false when
    // the HTML file isn't found in the CHM file.
    // Unfortunately, there doesn't seem to be
    // a way to do this without parsing the CHM.  
    return false;
  }
  return true;
}
// Define this contstant at the top of the file.
private const string mc_sHELPFILE = "ContextHelpMadeEasy.chm";
// Return the path to the CHM file.
private static string HelpFilePath 
{
  get 
  { 
    return Path.Combine(System.Windows.Forms.Application.StartupPath, 
                        mc_sHELPFILE); 
  }
}

Reading the Mapping File

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.

// Read the mapping file to create a list of ID to HTML file mappings.
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:

<?xml version="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.

// Given an ID, return the associated HTML Help path
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.

Instrumenting the Code - Empowering the Help Author

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 ContextIDs 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 ContextIDs 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.

Trapping Ctrl-F1

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.

Displaying the Mapping Dialog

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.

// Traverse the parent control chain looking for controls that implement the
// IContextHelp interface.  For each one found, add it to the list of available
// contexts.  Include the associated HTML path if it's define
// Finally, show the dialog for the help author to edit the mappings.
public static void ShowHelpMappingDialog(Control ctrContext)
{
  IContextHelp help = GetIContextHelpControl(ctrContext);
  List<ContextIDHTMLPathMap> alContextPaths = new List<ContextIDHTMLPathMap>();
  // Create a list of contexts starting with the current help context
  // and moving up the parent chain.
  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);
  }
  // Pop up the mapping dialog. If it returns true, this means a change was made
  // so we rewrite the XML mapping file with the new information.
  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:

// Utility class for maintaining relationship between context Id and path.
public class ContextIDHTMLPathMap
{
  public string ContextID;
  public string HTMLPath;
  public ContextIDHTMLPathMap(string ID, string Path)
  {
    ContextID = ID;
    HTMLPath = Path;
  }
}

Writing the Results

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:

//  Saves the specified StringDictionary that contains ID to Path mappings to the
//  XML mapping file.
private static void SaveMappingFile(StringDictionary sdMappings)
{
  // Create a new XML document and initialize it with the XML declaration and the
  // outer IDMap element.
  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);
  // Add the defined mappings between contextID and filename.
  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:

// Small utility method to add XML elements to a parent node.
private static XmlElement AddChildElementToNode(XmlNode node, 
               XmlDocument doc, string elementName)
{
  XmlElement el = doc.CreateElement(elementName);
  node.AppendChild(el);
  return el;
}

The Mapping Dialog

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.

// Static entry point to pop up this form.
public static bool 
       ShowHelpWriterHelper(List<ContextIDHTMLPathMap> contextIDs)
{
  FHelpMappingDialog frmHelper = new FHelpMappingDialog();
  frmHelper.IDList = contextIDs;  // Populate the treelist.
  if( frmHelper.lvMapping.Items.Count > 0 )
    frmHelper.lvMapping.SelectedIndices.Add(0);
  frmHelper.ShowDialog();         // Popup the form.
  if (frmHelper.Changed)
  {
    // For each item in the ListView,
    // change the path map to correspond to the UI.
    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:

// Gets and sets the list of ids.  The setter updates the UI.
public List<ContextIDHTMLPathMap> IDList
{
  set 
  {
    lvMapping.Items.Clear();
    foreach (ContextIDHTMLPathMap pathMap in value)
    {
      AddMappingNode(pathMap);
    }
  }
}

// Utility to add a node to the treelist.
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:

// Begin editing the label of the selected
// item when they click this button
private void btnEditTopicFile_Click(object sender, EventArgs e)
{
  if( lvMapping.SelectedItems.Count == 1 )
  {
    ListViewItem lvi = lvMapping.SelectedItems[0];
    lvi.BeginEdit();
  }
}

// If the item has changed after editing
// the label, flag the dialog as changed.
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.

Finishing Touches

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.

Handling the "?" Dialog Help Button

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 :).

Handling Tab Controls

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 Source Code and Sample Application

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.

Acknowledgements

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.

Version History

  • February 2, 2007: Initial version.
  • February 8, 2007: Textual edits, TOC and reference to Charles Williams' IExtenderProvider suggestion.

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here