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

Custom Configuration Sections for Lazy Coders

, 13 Jan 2009 CPOL
Rate this:
Please Sign up or sign in to vote.
Using custom configuration sections without having to understand them... much.

Introduction

This article presents some of the solutions I found in trying to use custom Configuration Sections, and explains how the process can be simplified for the novice who doesn’t have the time to learn them thoroughly. I am greatly indebted to the many authors whose articles about Configuration Sections enabled me to eventually understand what the MSDN documentation so eloquently obscured. Reading the introductory materials from one or two of their articles (Mysteries of Configuration by John Rista and Read/Write App.Config File with .NET 2.0 by Alois Kraus are great resources) can help in understanding the material below, although it isn’t essential.

The presentation of information here reflects the evolution of my code, although in a more organized sequence than I experienced. Each step of the way added more functionality and understanding. As you read through the progression, feel free to jump out at the level that addresses your needs. If you know or find a simpler way to accomplish things, please let me know so I can add it to my knowledge and include it in updates to this article.

Background

The configuration file is an XML document for storing data relevant to how your application behaves. The file name is your application’s EXE file name plus the extension “.config”, like MyProg.exe.config. When you add an “app.config” file to your project, its contents becomes this “exe.config” file’s content.

The simplest, and most useless, configuration file contains only the basic structure of the XML document.

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
</configuration>

The beginner’s first use of configuration files usually makes use of the appSettings section. This is a pre-defined section with some accessor tools built in to the .NET Framework. In this section, you can store parameters that “usually” aren’t modified, but you want to be able to modify them under special circumstances without rebuilding the application. A simple use might be as follows:

<?xml version="1.0" encoding="utf-8" >
<configuration>
  <appSettings>
    <add key="Port" value="COM2"/>
    <add key="Level" value="3"/>
  </appSettings>
</configuration>

This defines two parameters that the application might access using the following code:

string port = Configuration.AppSettings["Port"];
int mevLevel = int.Parse(Configuration.AppSettings["Level"]);

You can distribute your application with a default set of values in the configuration file, and modify those values when necessary for a given installation. Note that parameters in the appSettings section are stored and returned as strings. When converting to other types, be sure to provide appropriate exception handling.

You can also update these values programmatically if you want to allow users to modify them, although it is not as straightforward as you might want. You have to instantiate a Configuration object to be able to save the changes, and property values have to be accessed through a different route. This is jumping ahead of where we are in our explanation, but the code looks like this:

Configuration config = 
    ConfigurationManager.OpenExeConfiguration(ConfigurationUserLevel.None);
config.AppSettings.Settings["Port"].Value = "COM4";
config.Save(ConfigurationSaveMode.Modified);

The application configuration file is combined with other configuration files that the Framework knows about to make the complete Configuration object, but people interested in that information are already well beyond the complexity I want to address here.

The “Hello, World” ConfigurationSection

My foray into custom configuration sections started with the recognition that the appSettings section of the configuration file for our application was getting cluttered with a lot of disorganized data. This is one of the reasons the custom section exists. The first iteration created a simple class derived from ConfigurationSection, something like this:

public class SomeSettings : ConfigurationSection
{
    private SomeSettings() { }

    [ConfigurationProperty("FillColor", DefaultValue="Cyan")]
    public System.Drawing.Color FillColor
    {
        get { return (System.Drawing.Color)this["FillColor"]; }
        set { this["FillColor"] = value; }
    }

    [ConfigurationProperty("TextSize", DefaultValue="8.5")]
    public float TextSize
    {
        get { return (float)this["TextSize"]; }
        set { this["TextSize"] = value; }
    }

    [ConfigurationProperty("FillOpacity", DefaultValue="40")]
    public byte FillOpacity
    {
        get { return (byte)this["FillOpacity"]; }
        set { this["FillOpacity"] = value; }
    }
}

This is pretty basic, allowing us to use a configuration file that looks like this:

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
  <configSections>
    <section name="SomeSettings" type="MyApp.SomeSettings, SomeSettings" />
  </configSections>
  <SomeSettings FillColor="LightBlue" TextSize="9.5" FillOpacity="50" />
</configuration>

The settings we defined appear as an XML node, with attributes corresponding to the properties we included. There is also a new section named configSections that describes our new XML node to the Framework. (Understanding the section node took some work that I don’t wish you to repeat. Ignore that section for now, and we’ll take care of it later.) You can now access these data in your code with the following:

Configuration config = 
    ConfigurationManager.OpenExeConfiguration(ConfigurationUserLevel.None);
SomeSettings gui = (SomeSettings)config.Sections["SomeSettings"];
float fSize = gui.TextSize;

Note that even though the values are strings in the configuration file, the properties of the SomeSettings class give us type-safe access to our parameters. The parsing is done for you. There are validating attributes, both simple and complex, that can also be applied to the properties to reduce the validation work we have to do in our code. Look at the MSDN documentation on IntegerValidator for a starting point on understanding those.

If a few items are all you need, you don’t need this, and you can stick with the built-in appSettings section. I had a lot more things to organize.

Adding More Data

Adding more properties to the SomeSettings class adds more attributes to the XML node, and eventually makes it rather messy to read. We are looking for easy-to-read organization. Adding more classes to hold the additional data adds another section node under configSections for each such class. If your data fits that organization, that is the approach you want, but I want to group a bunch of related subgroups in one section. This can be done by deriving from the ConfigurationElement class.

public class SomeSettings : ConfigurationElement
{
    private SomeSettings() { }

    [ConfigurationProperty("FillColor", DefaultValue="Cyan")]
    public System.Drawing.Color FillColor
    {
        get { return (System.Drawing.Color)this["FillColor"]; }
        set { this["FillColor"] = value; }
    }
    [ConfigurationProperty("TextSize", DefaultValue="8.5")]
    public float TextSize
    {
        get { return (float)this["TextSize"]; }
        set { this["TextSize"] = value; }
    }

    [ConfigurationProperty("FillOpacity", DefaultValue="40")]
    public byte FillOpacity
    {
        get { return (byte)this["FillOpacity"]; }
        set { this["FillOpacity"] = value; }
    }
}

Note that the ConfigurationElement class looks just like the ConfigurationSection class does. All we changed was the base class. Our gain comes from using the element as a ConfigurationProperty in the ConfigurationSection.

public class MySection : ConfigurationSection
{
    private MySection() { }

    [ConfigurationProperty("Sector")]
    public SomeSettings SectorConfig
    {
        get { return (SomeSettings)this["Sector"]; }
    }
}

The exe.config file can now contain something like this:

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
  <configSections>
    <section name="MySection" type="MyApp.MySettings, MySection" />
  </configSections>
  <MySection>
    <Sector FillColor="Cyan" TextSize="8.5" FillOpacity="40" />
  </MySection>
</configuration>

Note that I can now use the node name of “Sector” in the configuration file instead of “SomeSettings” as it previously was. I can also give the corresponding property a different name, like “SectorConfig”. This is enough for you to access a configuration field with the following code:

Configuration config = 
    ConfigurationManager.OpenExeConfiguration(ConfigurationUserLevel.None);
MySection gui = (MySection)config.Sections["MySection"];
float fSize = gui.SectorConfig.TextSize;
SomeSettings set1 = gui.SectorConfig;

At this level, it doesn’t look much better than the first version, but now, I can create other ConfigurationElement classes and add them to MySection without making the file harder to understand, as we’ll see below.

Exploiting Laziness

As I hinted above, I don’t want to have to figure out the <configSections> part of the configuration file, especially when I can get the system to do it for me. We also want a means of more easily accessing the section, without having to invoke OpenExeConfiguration every time I need one of these parameters in a new part of the code. The following additions to the MySection class provide this, along with some important flexibility:

public static MySection Open()
{
    System.Reflection.Assembly assy = 
            System.Reflection.Assembly.GetEntryAssembly();
    return Open(assy.Location);
}

public static MySection Open(string path)
{
    if ((object)instance == null)
    {
        if (path.EndsWith(".config", 
                StringComparison.InvariantCultureIgnoreCase))
            path = path.Remove(path.Length - 7);
        Configuration config = 
                ConfigurationManager.OpenExeConfiguration(path);
        if (config.Sections["MySection"] == null)
        {
            instance = new MySection();
            config.Sections.Add("MySection", instance);
            config.Save(ConfigurationSaveMode.Modified);
        }
        else
            instance = (MySection)config.Sections["MySection"];
    }
    return instance;
}

#region Fields
private static MySection instance;
#endregion Fields

Now, we have the ability to access the section with this code:

MySection gui = MySection.Open();
float fSize = gui.SectorConfig.TextSize;
SomeSettings set1 = gui.SectorConfig;

The following features are worthy of note here:

  • The configuration file doesn’t have to contain any of the custom data for this to work. The first time we access this MySection.Open() method, the Framework will insert all the necessary overhead into the configuration file for us. Sweet. If you have defined default values for all your properties, this is all you need to use the custom section. (And, it gets even sweeter below.)
  • The configuration section is accessed and saved in a static variable. Other segments of our code can call the same MySection.Open() method and obtain the same set of data. Changes made in one part of your code can be read by other parts.
  • By including the overload MySection.Open(string path), I could access this ConfigurationSection in any configuration file I choose. You’ll see how I exploited this later. The test for path.EndsWith(...) comes in handy in those situations.

User Customization and Saving

Now that we have simple access to some nicely organized data, it would be even nicer if we could allow the user to customize his/her settings without manually editing the configuration file. Doing this requires that we have a way to save the changed settings. I touched on this earlier when discussing the appSettings section. After a few false starts associated with the access of the Section in multiple threads (where the application sometimes had to be restarted for the changes to take effect), I ended up with the following Save method that crosses thread boundaries like I wanted:

public class MySection : ConfigurationSection
{
    ...

    public void Save()
    {
        Configuration config = 
                ConfigurationManager.OpenExeConfiguration(spath);
        MySection section = (MySection)config.Sections["MySection"];
        section.SectorConfig = this.SectorConfig; //Copy the changed data
        config.Save(ConfigurationSaveMode.Full);
    }

    [ConfigurationProperty("Sector")]
    public SomeSettings SectorConfig
    {
        get { return (SomeSettings)this["Sector"]; }
        set { this["Sector"] = value; }  //***Added set accessor
    }

    #region Fields
    private static string spath;  //***Added saved file path
    private static MySection instance;
    #endregion Fields
}

Not shown above is that the static spath field is populated from the edited string parameter in the Open(string path) method. The Save method opens the configuration file, copies the values of all properties from the current instance of the Section to the newly opened one, and then writes the changes back to the file. To be able to assign the properties, we added a set accessor to the SectorConfig property. (My early versions tried keeping the Configuration object static, but that did not produce the desired results, and it had to be made a dynamic object.) Saving new values is now this easy:

MySection settings = MySection.Open();
// Make changes to the values as needed
settings.SectorConfig.FillOpacity = (byte)75;
// and then... save them
settings.Save();

One beautiful benefit of this results from the ConfigurationSaveMode.Full argument in the config.Save invocation. Because of this, with our first Save to an empty configuration file, the Framework will create the complete section for us with all the default values we specified, and nicely formatted too. How sweet it is! I use this feature to generate the lines that I then copy into my app.config file. Note that this trick requires that all properties have default values. You can leave the mode as Full, or change it to Modified if you want; I haven’t noticed any discernable performance hit for it.

As an alternative to the assignment for the SectorConfig property (and each of the other properties you may add later) in the Save method, I have been experimenting with the following code which requires less custom editing when I repeat this in another application:

public void AltSave()
{
    Configuration config = ConfigurationManager.OpenExeConfiguration(spath);
    MySection section = (MySection)config.Sections["MySection"];
    section.LockItem = true;
    foreach (ConfigurationProperty prop in section.Properties)
    {
        string name = prop.Name;
        section.SetPropertyValue(section.Properties[name], this[name], false);
    }
    config.Save(ConfigurationSaveMode.Full);
}

I can’t say yet that this works all the time, but it has so far in the few tests I’ve run, even with nested elements (see Adding Data Complexity below). The call to section.SetPropertyValue(...) is necessary because the section.Properties collection is read-only, and its elements can’t be modified directly.

Goof Protection

If the user can now alter (i.e., mess up) the settings, you want to give them a way to restore the defaults. This is easily accomplished with the following additions:

public static MySection Default
{
    get { return defaultInstance; }
}

private readonly static MySection defaultInstance = new MySection();

Now, the default values can be accessed through the fields of MySection.Default.

settings.SectorConfig.FillColor = MySection.Default.SectorConfig.FillColor;

Beware, however, of using code like the following to reset all the values to defaults:

settings = MySection.Default;

Both sides of that assignment are class instances, and any future attempts to modify settings will result in an exception being thrown because you will be attempting to change the read-only field defaultInstance. To avoid that risk, we can either access the default values individually, or use the following Copy method:

public MySection Copy()
{
    MySection copy = new MySection();
    string xml = SerializeSection(this, "MySection", 
                                  ConfigurationSaveMode.Full);
    System.Xml.XmlReader rdr = 
        new System.Xml.XmlTextReader(new System.IO.StringReader(xml));
    copy.DeserializeSection(rdr);
    return copy;
}

And, resetting the default values all at one time can now be done through this code:

settings = MySection.Default.Copy();

This is not the easiest way to do this, but it has a rather obvious fringe benefit of being able to make an independent copy of the configuration section (no matter how complex), and it also shows a use of the ConfigurationSection.SerializeSection method. You could have the Default property simply return a new instance of the class, as shown below, but that would instantiate yet another object every time you wanted a default value.

public static MySection Default
{
    get { return new MySection(); }
}

Adding Data Complexity

So now, we have a custom section, and can access it easily. Next, let’s add some more complex organization to the section elements. First are a couple new ConfigurationElement definitions, starting with a simple ConfigurationElement having two properties.

public class AShapeSetting : ConfigurationElement
{
    public AShapeSetting() { }

    [ConfigurationProperty("Shape", DefaultValue="Circle")]
    public string Shape
    {
        get { return this["Shape"]; }
        set { this["Shape"] = value; }
    }

    [ConfigurationProperty("Size", DefaultValue="12")]
    public int Size
    {
        get { return (int)this["Size"]; }
        set { this["Size"] = value; }
    }
}

The second ConfigurationElement also has two simple properties, but adds two instances of our new AShapeSetting element type.

public class ShapeSettings : ConfigurationElement
{
    public ShapeSettings() { }

    [ConfigurationProperty("SizeMultiple", DefaultValue="2")]
    public int SizeMultiple
    {
        get { return (int)this["SizeMultiple"]; }
        set { this["SizeMultiple"] = value; }
    }

    [ConfigurationProperty("Enable", DefaultValue="Yes")]
    private string pEnable
    {
        get { return this["Enable"].ToString(); }
        set { this["Enable"] = value; }
    }

    public bool Enable
    {
        get { return pEnable == "Yes"; }
        set { pEnable = value ? "Yes" : "No"; }
    }

    [ConfigurationProperty("DevA")]
    public AShapeSetting DevA
    {
        get { return (AShapeSetting)this["DevA"]; }
    }

    [ConfigurationProperty("DevB")]
    public AShapeSetting DevB
    {
        get { return (AShapeSetting)this["DevB"]; }
    }
}

We add this second element to the MySection class as follows:

public class MySection : ConfigurationSection
{
    private MySection() { }
    public static MySection Open() ...
    public static MySection Open(string path) ...
    public MySection Copy() ...

    public void Save()
    {
        Configuration config = 
                ConfigurationManager.OpenExeConfiguration(spath);
        MySection section = (MySection)config.Sections["MySection"];
        section.SectorConfig = this.SectorConfig;  //Copy the changed data
        //***Added line for the new property:
        section.UnitShapeConfig = this.UnitShapeConfig; //Copy the changed data
        config.Save(ConfigurationSaveMode.Full);
    }

    [ConfigurationProperty("Sector")]
    public SomeSettings SectorConfig ...

    //***Added property:
    [ConfigurationProperty("UnitShapes")]
    public ShapeSettings UnitShapeConfig
    {
        get { return (ShapeSettings)this["UnitShapes"]; }
        set { this["UnitShapes"] = value; }
    }

    public static MySection Default ...

    #region Fields
    private static MySection instance;
    private readonly static MySection defaultInstance = new MySection();
    #endregion Fields
}

And, our configuration file section can now look like this:

  <MySection>
    <Sector FillColor="Cyan" TextSize="8.5" FillOpacity="40" />
    <UnitShapes SizeMultiple="2" Enable="Yes">
      <DevA Shape="Circle" Size="12" />
      <DevB Shape="Star" Size="14" />
    </UnitShapes>
  </MySection>

Some niceties to note here include:

  • Embedded multiple levels of organization that are easy to follow. The UnitShapes element contains its own properties of SizeMultiple and Enable, plus two elements of AShapeSetting with their own properties, Shape and Size. These two occurrences of AShapeSetting could be put in the more flexible ConfigurationElementCollection, but that is outside my current scope and, for now, the number of occurrences is fixed.
  • The use of a private configuration property to translate between the boolean values True/False and the more intuitive strings Yes/No.
  • The independence of the property name (UnitShapeConfig), the ConfigurationProperty identifier (UnitShapes), and the property type (ShapeSettings), although it is usually helpful to have at least two of them match.

Do I Have To Do It Again?!

As we began to utilize this structure in more and more places, I got tired of copying the common code and changing the names. From that came a code template to create a starting custom configuration section, with code like that shown below:

using System;
using System.Collections.Generic;
using System.Configuration;
using System.Text;

namespace yourApp
{
    class CustomConfigSection : ConfigurationSection
    {
        private CustomConfigSection() { }

        #region Public Methods

        ///<summary>
        ///Get this configuration set from the application's default config file
        ///</summary>
        public static CustomConfigSection Open()
        {
            System.Reflection.Assembly assy = 
                    System.Reflection.Assembly.GetEntryAssembly();
            return Open(assy.Location);
        }

        ///<summary>
        ///Get this configuration set from a specific config file
        ///</summary>
        public static CustomConfigSection Open(string path)
        {
            if ((object)instance == null)
            {
                if (path.EndsWith(".config", 
                            StringComparison.InvariantCultureIgnoreCase))
                    spath = path.Remove(path.Length - 7);
                else
                    spath = path;
                Configuration config = ConfigurationManager.OpenExeConfiguration(spath);
                if (config.Sections["CustomConfigSection"] == null)
                {
                    instance = new CustomConfigSection();
                    config.Sections.Add("CustomConfigSection", instance);
                    config.Save(ConfigurationSaveMode.Modified);
                }
                else
                    instance = 
                        (CustomConfigSection)config.Sections["CustomConfigSection"];
            }
            return instance;
        }

        ///<summary>
        ///Create a full copy of the current properties
        ///</summary>
        public CustomConfigSection Copy()
        {
            CustomConfigSection copy = new CustomConfigSection();
            string xml = SerializeSection(this, 
                    "CustomConfigSection1", ConfigurationSaveMode.Full);
            System.Xml.XmlReader rdr = 
                    new System.Xml.XmlTextReader(new System.IO.StringReader(xml));
            copy.DeserializeSection(rdr);
            return copy;
        }

        ///<summary>
        ///Save the current property values to the config file
        ///</summary>
        public void Save()
        {
            // The Configuration has to be opened anew each time we want to 
            // update the file contents.Otherwise, the update of other custom 
            // configuration sections will cause an exception to occur when we 
            // try to save our modifications, stating that another app has 
            // modified the file since we opened it.
            Configuration config = ConfigurationManager.OpenExeConfiguration(spath);
            CustomConfigSection section = 
                    (CustomConfigSection)config.Sections["CustomConfigSection"];
            //
            // TODO: Add code to copy all properties from "this" to "section"
            //
            section.Sample = this.Sample;
            config.Save(ConfigurationSaveMode.Modified);
        }

        #endregion Public Methods

        #region Properties

        public static CustomConfigSection Default
        {
            get { return defaultInstance; }
        }

        // TODO: Add your custom properties and elements here.
        // All properties should have both get and set accessors 
        // to implement the Save function correctly
        [ConfigurationProperty("Sample", DefaultValue = "sample string property")]
        public string Sample
        {
            get { return (string)this["Sample"]; }
            set { this["Sample"] = value; }
        }

        #endregion Properties

        #region Fields
        private static string spath;
        private static CustomConfigSection instance = null;
        private static readonly CustomConfigSection defaultInstance = 
                  new CustomConfigSection();
        #endregion Fields
    }
}

Download this template and put the zip file in your ItemTemplates directory. For VS 2005, the default location of this directory is My Documents\Visual Studio 2005\Templates\ItemTemplates. (There must be a cleaner way... if I could just find it.) When you next do an “Add Item…” to your project, the CustomConfigSection will show up under the heading “My Templates”.

A Sweet Discovery

The overloads for Open came in handy when setting the options for a Service. Although this isn’t really about configuration files, it is a usage example, and carries some fringe benefit training with it.

The target configuration file is MyService.exe.config. I want to edit the MySettings section using another application, MyServiceController.exe. This is done using the following (slightly modified) excerpt from MyServiceController:

cntrl = new System.ServiceProcess.ServiceController("MyService");
// Find the config file that controls the service
RegistryKey HKLM_System = Registry.LocalMachine.OpenSubKey("System");
RegistryKey HKLM_CCS = HKLM_System.OpenSubKey("CurrentControlSet");
RegistryKey HKLM_Services = HKLM_CCS.OpenSubKey("Services");
RegistryKey HKLM_Service = HKLM_Services.OpenSubKey(cntrl.ServiceName);
string path = (string)HKLM_Service.GetValue("ImagePath");
path = path.Replace("\"", ""); //Remove the quotes
// Access the configuration parameters for the service
mySettings = MySettings.Open(path);

The use of cntrl.ServiceName in this instance is not necessary, but shows how you can get there from a ServiceController object. From there, I can modify mySettings and save the modifications, and the service will read the new settings the next time it runs.

History

  • 13 Jan. 2009 -- Original version.

License

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

Share

About the Author

John Whitmire
Systems Engineer
United States United States
I fell in love with software development in 1973 and have been reveling in it ever since. My experience since then has had me coding in over 20 programming languages and included stints in embedded systems, SCM, SQA, and software test. Current activities are focused in C# 4 & 5 with lots of XML. I love my job!

Comments and Discussions

 
BugNamespace should be there instead of class name. PinmemberMember 327776015-Nov-14 6:50 
Question5 stars from me PinmemberMikeLeeisback5-Aug-14 4:55 
QuestionAdd section with keys PinmemberMember 103594646-Jun-14 1:10 
AnswerRe: Add section with keys PinprofessionalJohn Whitmire14-Jun-14 9:01 
AnswerRe: Add section with keys PinmemberMikeLeeisback5-Aug-14 4:49 
GeneralMy vote of 5 PinmemberErrolM8-Jul-13 9:23 
GeneralAwesome!! Pinmemberraananv3-Jul-13 9:44 
GeneralRe: Awesome!! PinmemberJohn Whitmire4-Jul-13 8:18 
GeneralMy vote of 5 PinmemberMarco Bertschi24-Sep-12 20:35 
GeneralRe: My vote of 5 PinmemberJohn Whitmire4-Jul-13 8:19 
QuestionNice one! PinmemberNaerling26-May-12 5:28 
AnswerRe: Nice one! PinmemberJohn Whitmire30-May-12 9:15 
GeneralRe: Nice one! PinmemberNaerling30-May-12 9:27 
QuestionMy Five Cents Pinmemberrobertrevolver6-Apr-12 8:19 
GeneralMy vote of 5 PinmemberMikeS6-Jan-12 5:09 
GeneralRe: My vote of 5 PinmemberJohn Whitmire6-Apr-12 19:29 
GeneralMy vote of 5 PinmemberAbinash Bishoyi19-Oct-11 3:07 
GeneralRe: My vote of 5 PinmemberJohn Whitmire6-Apr-12 19:24 
GeneralMy vote of 5 Pinmembervins_boss13-Apr-11 21:17 
GeneralRe: My vote of 5 PinmemberJohn Whitmire6-Apr-12 19:24 
Generalgeneric version Pinmemberdarthluke27-May-09 1:07 
GeneralRe: generic version PinmemberJohn Whitmire27-May-09 9:38 
GeneralRe: generic version PinmemberMalcolm Pirie10-Aug-10 6:21 
GeneralRe: generic version PinmemberJohn Whitmire10-Aug-10 10:39 
GeneralGreat Article!!! [modified] Pinmemberpayini11-May-09 12:56 

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
Web03 | 2.8.1411023.1 | Last Updated 13 Jan 2009
Article Copyright 2009 by John Whitmire
Everything else Copyright © CodeProject, 1999-2014
Layout: fixed | fluid