Click here to Skip to main content
Click here to Skip to main content

Integrated Help system in a WPF Application

, 1 Mar 2013 CPOL
Rate this:
Please Sign up or sign in to vote.
Quick guideline to understand a workflow of screen without reading long and boring(!!) documentation guide.
This is an old version of the currently published article.

Introduction

I have a WPF application and I am thinking of creating a help documentation for it. If anyone needs help on some screen and press F1, I don’t like to open a traditional CHM or HTML based screen for that. So my plan is that the screen will describe its own help description and controls will give their introduction and also show you the activity flow to make the user understand about the basic flow of the screen. But what if the user also wants an introduction to each control click while working on a screen? OK, then I will also print the basic introduction into the below status bar on the mouse click of the control (if it has any). It is just a quick guideline to get rid of reading long and boring documentation and give you a very basic information of a screen.

Still not clear to you?!! No problem. Let us take a simple screen to understand my thinking here:

A simple Login screen which controls:

  1. Textbox for user name
  2. Password box
  3. Login button
  4. And a button to open a Wish-List child screen

Let us talk about the flow first. The first three controls have some flow to show. What I mean is that:

  1. User needs to set the username first
  2. Then enter the password
  3. And then click on the login button

Concept

So here you can see, I have also added a status bar to show the control description while selecting a control (here in the picture, the login button has been selected). But if the user asks for help for this screen by clicking F1, the screen should look like this:

To do this I have prepared XML documentation, where I have kept all the necessary descriptions of a control like Title, Help Description, URL for an online guide, shortcut key/hot key, and flow index (if any) for ordering the activity flow sequences. You do need to synchronize the XML yourself if you have changes to any workflow or short-cut/hot keys, which is true for other documentation also. It of course is not a replacement for a  documentation guide, just a quick guideline for the user. That is why I have kept an URL here to go to the on-line guide for more details.

Here I have put the element name as unique as I am going to map this documentation with the control used in the UI. Flow Indexes have also been set. If a control is not a part of some flow, I mean the user can use it whenever he wants, e.g., search control, launching the child window for settings or sending a wish-list simply keeps the flow index empty.

And the result will look something like this:

Using the code

To load and read this XML, I have prepared a loader class which loads and generate a Dynamic Help data model based on the chosen language. I am maintaining multiple XMLs same just like a resource file does. In this sample, during the initialization of the application I do XML loading and model generation in memory to cache. Later on I am going use these help definitions based on some UI work.

public class DynamicHelpStringLoader
{
    private const string HelpStringReferenceFolder = "DynamicHelpReference";
    private const string UsFileName = "DynamicHelp_EN_US.xml";
    private const string FrFileName = "DynamicHelp_FR.xml";
    private const string EsFileName = "DynamicHelp_ES.xml";
    private const string DefaultFileName = "DynamicHelp_EN_US.xml";

    /// <summary>
    /// This is the collection where all the JerichoMessage objects
    /// will be stored.
    /// </summary>
    private static readonly Dictionary<string, DynamicHelpModel> HelpMessages;

    private static Languages _languageType;

    /// <summary>
    /// The static constructor.
    /// </summary>
    static DynamicHelpStringLoader()
    {
        HelpMessages = new Dictionary<string,DynamicHelpModel>();
        _languageType = Languages.None;
    }
    /// <summary>
    /// Generates the collection of JerichoMessage objects as if the provided language.
    /// </summary>
    /// <param name="languages">The Languages enum. Represents the user's choice of language.</param>
    public static void GenerateCollection(Languages languages)
    {
        if (_languageType == languages)
        {
            return;
        }
        _languageType = languages;
        string startUpPath = Path.GetDirectoryName(
          System.Reflection.Assembly.GetExecutingAssembly().GetModules()[0].FullyQualifiedName);
        string fileName;
        switch (languages)
        {
            case Languages.English:
                fileName = UsFileName;
                break;
            case Languages.French:
                fileName = FrFileName;
                break;
            case Languages.Spanish:
                fileName = EsFileName;
                break;
            default:
                fileName = DefaultFileName;
                break;
        }

        Task.Factory.StartNew(() =>
                      {
                          LoadXmlFile(Path.Combine(startUpPath,
                                                   string.Format(@"{0}\{1}", HelpStringReferenceFolder,
                                                                 fileName)));
                      });
    }
    /// <summary>
    /// Load the provided xml file and populate the dictionary.
    /// </summary>
    /// <param name="fileName"></param>
    private static void LoadXmlFile(string fileName)
    {
        XDocument doc = null;
        try
        {
            //Load the XML Document                
            doc = XDocument.Load(fileName);
            //clear the dictionary
            HelpMessages.Clear();

            var helpCodeTypes = doc.Descendants("item");
            //now, populate the collection with JerichoMessage objects
            foreach (XElement message in helpCodeTypes)
            {
                var key = message.Attribute("element_name").Value;
                if(!string.IsNullOrWhiteSpace(key))
                {
                    var index = 0;
                    //get all Message elements under the help type
                    //create a JerichoMessage object and insert appropriate values
                    var dynamicHelp = new DynamicHelpModel
                                          {
                                              Title = message.Element("title").Value,
                                              HelpText = message.Element("helptext").Value,
                                              URL = message.Element("moreURL").Value,
                                              ShortCut = message.Element("shortcut").Value,
                                              FlowIndex = (int.TryParse(message.Element(
                                                "flowindex").Value, out index)) ? index : 0
                                          };
                    //add the JerichoMessage into the collection
                    HelpMessages.Add(key.TrimStart().TrimEnd(), dynamicHelp);
                }
            }
        }
        catch (FileNotFoundException)
        {
            throw new Exception(LanguageLoader.GetText("HelpCodeFileNotFound"));
        }
        catch (Exception ex)
        {
            throw ex;
        }
    }
 
    /// <summary>
    /// Returns mathced string from the xml.
    /// </summary>
    /// <param name="name"></param>
    /// <returns></returns>
    public static DynamicHelpModel GetDynamicHelp(string name)
    {
        
        if(!string.IsNullOrWhiteSpace(name))
        {
            var key = name.TrimStart().TrimEnd();
            if(HelpMessages.ContainsKey(key))
                return HelpMessages[key];
        }
        return new DynamicHelpModel();
    }
}

Now it is time to jump into the UI work. I have created an attach property which enables dynamic help for a screen or window. So it will be a simple boolean attach property. On setting it, I am creating a Help Group and adding into a list. This list is necessary while working with child windows. The help group keeping is the element which has been enabled as dynamic help. It is normally the root panel of a window. In the sample, I have used the first child panel of the window as the element to enable dynamic help to get the Adorner layer where I can set the Text – “Help Model (Press F1 again to Exit)”.

You could look into the XAML here where I have put the element name and later on these unique strings are mapped for retrieving help description.

I also have kept the window and hooked closing event and mouse click and bound Command of ApplicationCommands.Help. The mouse click event has been subscribed to find out the control it currently is in and checks for Help description in status bar. The Help command has been bound to get F1 pressed and toggle the help mode. In help mode I am going to find out all the controls with the help description in the children of the element where you have setup the attached property. I need to hook the closing event here to clear the Help Group with all its event-subscriptions.

private static bool HelpActive { get; set; }
 
public static void SetDynamicHelp(UIElement element, bool value)
{
    element.SetValue(DynamicHelpProperty, value);
}
public static bool GetDynamicHelp(UIElement element)
{
    return (Boolean)element.GetValue(DynamicHelpProperty);
}

public static readonly DependencyProperty DynamicHelpProperty =
  DependencyProperty.RegisterAttached("DynamicHelp", typeof(bool), typeof(UIElement),
                                      new PropertyMetadata(false, DynamicHelpChanged));

private static readonly List<HelpGroup> HelpGroups = new List<HelpGroup>();

public static HelpGroup Current
{
    get
    {
        return HelpGroups.LastOrDefault();
    }
}

private static void DynamicHelpChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
    var element = d as UIElement;

    if (null != element)
    {
        if (null != HelpGroups && !HelpGroups.Any(g => null != g.Element && g.Element.Equals(element)))
        {
            UIElement window = null;
            if (element is Window)
                window = (Window)element;
            else
                window = Window.GetWindow(element);

            //Note: Use below code if you have used any custom window class other
            //than child of Window (for example WindowBase is base of your custom window)
            //if (window == null)
            //{
            //    if (element is WindowBase)
            //        window = (WindowBase)element;
            //    else
            //        window = element.TryFindParent<WindowBase>();
            //}

            if (null != window)
            {
                var currentGroup = new HelpGroup { Screen = window, 
                    Element = element, ScreenAdorner = new HelpTextAdorner(element) };
                var newVal = (bool)e.NewValue;
                var oldVal = (bool)e.OldValue;
                                              
                // Register Events
                if (newVal && !oldVal)
                {
                    if (currentGroup.Screen != null)
                    {
                        if (!currentGroup.Screen.CommandBindings.OfType<CommandBinding>().Any(
                             c => c.Command.Equals(ApplicationCommands.Help)))
                        {
                            if (currentGroup._helpCommandBind == null)
                            {
                                currentGroup._helpCommandBind = new CommandBinding(ApplicationCommands.Help, HelpCommandExecute);
                            }
                            currentGroup.Screen.CommandBindings.Add(currentGroup._helpCommandBind);
                        }

                        if (currentGroup._helpHandler == null)
                        {
                            currentGroup._helpHandler = new MouseButtonEventHandler(ElementMouse);
                        }
                        currentGroup.Screen.PreviewMouseLeftButtonDown += currentGroup._helpHandler;
                        if (window is Window)
                            ((Window)currentGroup.Screen).Closing += WindowClosing;
                        //else
                        //    ((WindowBase)currentGroup.Screen).Closed += 
                        //        new EventHandler<WindowClosedEventArgs>(RadWindowClosed);
                    }
                }
                HelpGroups.Add(currentGroup);
            }
        }
    }
}

Let's come to the mouse click event and how I find the control with the help description. Here it traverses to the top until I got the control with the help description. On finding the control it will be able to show you the description in the below status bar. 

Here in this method ElementMouse a hit test has been executed using InputHitTest to get the control the user clicked. After that it checks for the help description, if not found it goes to the parent and checks. So I am traversing here to the top until I finds the nearest control with help description.  

static void ElementMouse(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
    if(e.ButtonState != MouseButtonState.Pressed
        || e.ClickCount != 1)
        return;

    var element = sender as DependencyObject;
    if (null != element)
    {
        UIElement window = null;
        if (element is Window)
            window = (Window)element;
        else
            window = Window.GetWindow(element);

        //Note:  Use bellow code if you have used any custom window class other
        //than child of Window (for example WindowBase is base of your custom window)
        //if (window == null)
        //{
        //    if (element is WindowBase)
        //        window = (WindowBase) element;
        //    else
        //        window = element.TryFindParent<WindowBase>();
        //}

        if (null != window)
        {
            // Walk up the tree in case a parent element has help defined
            var hitElement = (DependencyObject)window.InputHitTest(e.GetPosition(window));

            var checkHelpDo = hitElement;                    
            string helpText = Current.FetchHelpText(checkHelpDo);
            while ( string.IsNullOrWhiteSpace(helpText) && checkHelpDo != null &&
                    !Equals(checkHelpDo, Current.Element) &&
                    !Equals(checkHelpDo, window))
            {
                checkHelpDo = (checkHelpDo is Visual)?  VisualTreeHelper.GetParent(checkHelpDo) : null;
                helpText = Current.FetchHelpText(checkHelpDo);
            }
            if (string.IsNullOrWhiteSpace(helpText))
            {
                Current.HelpDO = null;
            }
            else if (!string.IsNullOrWhiteSpace(helpText) && Current.HelpDO != checkHelpDo)
            {
                Current.HelpDO = checkHelpDo;
            }

            if (null != OnHelpMessagePublished)
                 OnHelpMessagePublished(checkHelpDo, 
                   new HelperPublishEventArgs() { HelpMessage = helpText, Sender = hitElement});
            
        }
    }
}

On the help command execution it toggles the help mode. If the help mode is true, I have traversed the children recursively to find out all the children with Help description and started a timer there to show a popup on those controls in a sequential way.

private static void DoGenerateHelpControl(DependencyObject dependObj, HelperModeEventArgs e)
{
    // Continue recursive toggle. Using the VisualTreeHelper works nicely.
    for (int x = 0; x < VisualTreeHelper.GetChildrenCount(dependObj); x++)
    {
        DependencyObject child = VisualTreeHelper.GetChild(dependObj, x);
        DoGenerateHelpControl(child, e);
    }

    // BitmapEffect is defined on UIElement so our DependencyObject 
    // must be a UIElement also
    if (dependObj is UIElement)
    {
        var element = (UIElement)dependObj;
        if (e.IsHelpActive)
        {
            var helpText = e.Current.FetchHelpText(element);
            if (!string.IsNullOrWhiteSpace(helpText) && element.IsVisible
                && !IsWindowAdornerItem(element))
            {
                // Any effect can be used, I chose a simple yellow highlight
                _helpElements.Add(new HelpElementArgs() { Element = element, 
                    HelpData = DynamicHelperViewer.GetPopUpTemplate(element, helpText, e.Current), 
                    Group = e.Current });
            }
        }
        else if (element.Effect == HelpGlow)
        {
            if(null != OnHelpTextCollaped)
                OnHelpTextCollaped(null, new HelpElementArgs(){ Element =element, Group = e.Current});
        }
    }
}

Controls those don’t have any flow has been shown on the first tick of the timer. After that the flow text has been shown with the flow index in a sequential way. For this, I have found out the minimum flow index and then found out the data according to that index and shown their pop up. I have also removed those from the list since those have already been shown.

public static void HelpTimerTick(object sender, ElapsedEventArgs args)
{
    if(null != _helpElements && _helpElements.Count > 0)
    {
        int idx = _helpElements.Min(e => e.HelpData.Data.FlowIndex);
        var data = _helpElements.Where(e => e.HelpData.Data.FlowIndex.Equals(idx));
        foreach (var helpElementArgse in data.ToList())
        {
            _helpElements.Remove(helpElementArgse);
            if (null != OnHelpTextShown)
            {
                OnHelpTextShown(sender, helpElementArgse);
            }   
        }
    }
    else
    {
        _helpTimer.Enabled = false;
    }
}

For the child window, it will give you the same kind of result if you enable dynamic help for them.

Points of Interest

To show the popup, I have used Adorners as popup to get the relocate feature with controls on resizing the screen and it will not be the topmost of all applications. But this popup fails to be on top of screen if I set my control near the boundaries of the screen. The result will occur cut off the popup since space to show is unavailable. I could use a popup here changing the topmost behaviour. I am trying to provide a concept here which doesn't meet all the expectations or features we are expecting from the documentation or help guide. But I think it gives users a quick guideline to understand the workflow of a screen without reading a boring(!!) documentation guide. So it is not the replacement for a long documentation. You can keep both and allow the user to open your long documentation from this quick documentation.

References

License

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

Share

About the Author

Morshed Anwar
Team Leader Adaptive Enterprise Limited (www.ael-bd.com)
Bangladesh Bangladesh
No Biography provided

Comments and Discussions


Discussions posted for the Published version of this article. Posting a message here will take you to the publicly available article in order to continue your conversation in public.
 
QuestionProblems with implementing... PinprofessionalSperneder Patrick31-Oct-13 23:57 
Question.NET 4.0 PinmemberFernando E. Braz20-Jun-13 13:56 
AnswerRe: .NET 4.0 PinmemberMorshed Anwar21-Jun-13 0:42 
GeneralRe: .NET 4.0 PinmemberFernando E. Braz21-Jun-13 2:25 
GeneralRe: .NET 4.0 PinmemberMorshed Anwar21-Jun-13 11:27 
GeneralRe: .NET 4.0 PinmemberFernando E. Braz21-Jun-13 2:27 
GeneralRe: .NET 4.0 PinmemberMorshed Anwar21-Jun-13 11:28 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.

| Advertise | Privacy | Terms of Use | Mobile
Web02 | 2.8.141220.1 | Last Updated 1 Mar 2013
Article Copyright 2012 by Morshed Anwar
Everything else Copyright © CodeProject, 1999-2014
Layout: fixed | fluid