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

A Lightweight Implementation of UML Statecharts in C++

, 23 Aug 2007
Rate this:
Please Sign up or sign in to vote.
This lightweight class allows you to easily implement a UML statechart in C++.

Introduction

This statechart "engine" (implemented as a C++ template class) implements the most commonly used aspects of UML statecharts. All you need to do is define an array of states and implement event checking and handling methods. Then call the engine when an event occurs. The engine calls your event checking and handling methods in the correct order to figure out what event happened, and tracks the current state.

This is a lightweight implementation that compiles under Visual Studio 2005 and, with some slight modifications, under VC++ 6.0. This implementation requires less statechart-related housekeeping code than other C++ implementations. (See Miro Samek's, for example.)

Background

Statecharts were developed by David Harel to add nesting and other features to flat state machines. (See David Harel, "On Visual Formalisms", Communications of the ACM, Vol. 31, No. 5, pp 514-530, 1988.) Statecharts were later added to the Unified Modeling Language (UML) and standardized. They are an excellent tool for modeling classes, sub-systems and interfaces that have many distinct states and complex transitions among them.

My personal need for them arose while implementing a software interface between two real-time, embedded systems that controlled separate machines requiring physical synchronization. I have also used them for modeling and implementing user interfaces that featured many modes.

The following is a brief summary of the notation and behavior of statecharts. For a full presentation of the UML statechart notation, see the UML 2.0 specification available here.

Graphically, UML shows states as boxes with rounded corners, and transitions as arrows between boxes. (See Figure 1.) The transitions are labeled with the event that causes the transition, optionally followed by a forward slash, and the action(s) that will be taken upon transition. A condition, called a "guard," can be indicated in square brackets. If the event happens, the guard condition is evaluated and the transition is taken only if the condition is true.

Figure 1

States can be nested (See Figure 2.), which allows high-level events to invoke transitions that leave any of several states with just one arrow. The use of so-called "composite" states keeps statechart diagrams much simpler than what flat state machines would require. Even though states can be nested, the system must always transition to some simple (i.e. non-composite) state.

Figure 2

A solid circle at the beginning of an arrow indicates a default start state. A statechart must have at least one default start state designated, indicating the initial state of the system. If any transition -- including a default one -- ends on a compound state, then there must be a default state designation inside that compound state, and so on, eventually leading to a simple state. One exception to this is described below.

States can have internal transitions that do not take the system to another state, yet do have associated actions. These are shown inside the state where they are handled. Compound states may also have internal transitions. Two special internal transitions are "entry" and "exit." These are executed upon entry to, and exit from, the corresponding state. This allows common actions such as initialization and destruction to be expressed one time, rather than as actions on every event leading to/from a state. Custom internal events can also be specified.

If a transition takes the system across several state boundaries, the various actions are executed in the following order:

  1. The exit action(s) of all states that must be exited
  2. The action specified on the transition arrow
  3. The entry action(s) for all states that are entered

Statecharts allow a transition to return to a previous state within a composite state, without requiring that you specify what state you were in previously. This is represented by a transition arrow that ends in an encircled "H" for "history." (See Figure 3.) For example, say an event can be handled from any of two simple states within a compound state. If you show a transition from the compound state, back inside it to the encircled "H," this means "handle the event and return to the state you most recently left." That is much simpler than showing two (or more) transitions with identical event/action labels.

Figure 3

There are two kinds of history returns in UML statecharts: shallow and deep. Shallow transitions (indicated by an "H") return to the most recently exited state at the level where the "H" is shown. If that does not lead to a simple state, then it is an error. Deep history (indicated by an "H*") means that the system will return to the most recent simple state within the compound state that it most recently exited. So, if the system in Figure 3 was in state A when event x happened, the system would return to state A.

Using the Code

This implementation supports state nesting, entry, exit and custom internal events, default states and deep history transitions. If a history return cannot find a recently exited state at a given level, it will try to use default state designations to get the system to a simple state. Only failing that will it be considered an error.

This implementation does not currently support orthogonal states, factored transition paths, forks, joins, synch states or message broadcasting. Many of those unsupported features depend heavily on the system you are integrating this code into and can be simulated in your event handlers.

Once you have designed your statechart, do the following. First, define an enumeration of states. The following comes from the included sample application, which illustrates all of the above features. This application performs a path-cover test of the statechart engine.

enum eStates
{
    eStateA,
    eStartState = eStateA,
    eStateB,
    eStateC,
    eStateD,
    eStateE,
    eNumberOfStates
};

Here I name the start state and I also designate the number of states by the last enum value, so it will always be correct. Your specific state names will likely be more meaningful than these. Next, I allocate an array of the following:

typedef struct
{
    int32         m_i32StateName;
    std::string      m_sStateName;
    int32         m_i32ParentStateName;
    int32         m_i32DefaultChildToEnter;
    int32         (T::*m_pfi32EventChecker)(void);
    void          (T::*m_pfDefaultStateEntry)(void);
    void          (T::*m_pfEnteringState)(void);
    void          (T::*m_pfLeavingState)(void);
} xStateType;

For example,

TStatechart<CStateClass>::xStateType xaStates[eNumberOfStates] = {
/* name                      */    {eStateA,
/* string name               */    "A",
/* parent                    */    -1,
/* default_substate          */    eStateB,
/* event-checking func       */    &CStateClass::evStateA,
/* default state entry func  */    &CStateClass::defEntryStateA,
/* entering state func       */    &CStateClass::entryStateA,
/* exiting state func        */    &CStateClass::exitStateA},

/* name                      */    {eStateB,
/* string name               */    "B",
/* parent                    */    eStateA,
/* default_substate          */    eStateC,
/* event-checking func       */    &CStateClass::evStateB,
/* default state entry func  */    &CStateClass::defEntryStateB,
/* entering state func       */    &CStateClass::entryStateB,
/* exiting state func        */    &CStateClass::exitStateB},

/* name                      */    {eStateC,
/* string name               */    "C",
/* parent                    */    eStateB,
/* default_substate          */    -1,
/* event-checking func       */    &CStateClass::evStateC,
/* default state entry func  */    &CStateClass::defEntryStateC,
/* entering state func       */    &CStateClass::entryStateC,
/* exiting state func        */    &CStateClass::exitStateC},

/* name                      */    {eStateD,
/* string name               */    "D",
/* parent                    */    eStateA,
/* default_substate          */    -1,
/* event-checking func       */    &CStateClass::evStateD,
/* default state entry func  */    &CStateClass::defEntryStateD,
/* entering state func       */    &CStateClass::entryStateD,
/* exiting state func        */    &CStateClass::exitStateD},

/* name                      */    {eStateE,
/* string name               */    "E",
/* parent                    */    eStateB,
/* default_substate          */    -1,
/* event-checking func       */    &CStateClass::evStateE,
/* default state entry func  */    &CStateClass::defEntryStateE,
/* entering state func       */    &CStateClass::entryStateE,
/* exiting state func        */    &CStateClass::exitStateE}
};

The structs must be initialized in the same order as the states in the enumeration above. Only the top-most state, here eStateA, will have -1 for its parent designation. States without a default sub-state (which will include all simple states) must specify -1 for the default sub-state. Every state must have an event checking/handling method, but need not have the last three fields filled in. Specify 0 for those if they are not defined.

The string name field is used in printing out trace information. In the file TStatechart.hpp, set TRACING_STATUS to 1 to activate this. If compiled with MFC, the information will be written via the TRACE() macro. Otherwise, the text is sent to cout. The engine is referenced internally via a void pointer, so the class using the statechart must have a void pointer for its use:

class CStateClass
{
    .
    .
    .
    void    *engine;
};

The engine must be created and destroyed in your class. These macros and the ones below hide some of the necessary details. The engine name appears in all of them so that you may have more than one declared in the same client class.

CStateClass::CStateClass(void)
{
    CREATE_ENGINE(CStateClass, engine, 
        xaStates, eNumberOfStates, eStartState);
}

CStateClass::~CStateClass(void)
{
    DESTROY_ENGINE(CStateClass, engine);
}

At the point where events happen, place the following call:

PROCESS_EVENT(CStateClass, engine)

Since an event may be far more complex than examining a mere scalar value, the engine does not pass the event around to your event checking/handling methods. Rather, you must store the event in member variable(s) in your class before calling PROCESS_EVENT so that the event checking/handling methods can test for it. Thus, it does not appear in the above call.

For each state in your statechart diagram, an event checking/handling method must be defined that checks for all events that can be handled by that state. Simply test for each event that can happen while you are in the given state, as in the following:

uint32 CStateClass::evStateA(void)
{
    // Checking for event in state A.
    if ('g' == m_cCharRead)        // include any guard conditions here
    {
        BEGIN_EVENT_HANDLER(CStateClass, engine, eStateA);
        // Put the transition action code here.
        END_EVENT_HANDLER(CStateClass, engine);
        return (iHandlingDone);
    }
    return (iNoMatch);
}

This arrangement allows any guard conditions to be tested at the same point in the code as the event itself, simplifying the code. Return iNoMatch from a handler that does not find an event it is supposed to handle.

The BEGIN_EVENT_HANDLER macro lets the engine know that you have found a match for an event. It records this fact and executes the exit handlers for every state that must be exited to get to the destination state. It also records the fact that eStateA will be the state the system goes to after executing the handler code. If the given state is a composite state, then of course you will end up in some simple state inside that composite state.

Control then returns to this method, where any transition actions are carried out. The END_EVENT_HANDLER macro executes any state entry handlers for states that you must enter to end up in the correct simple state. If you wish to transition to a composite state with history, "OR" the history flag onto the state name in the BEGIN_EVENT_HANDLER macro:

BEGIN_EVENT_HANDLER(CStateClass, engine, eStateA | iWithHistory);

For an internal transition, use the "no state change" flag:

BEGIN_EVENT_HANDLER(CStateClass, engine, iNoStateChange);

That's it!

Internals

Internally, the state definition array is parsed upon initialization and more sophisticated data structures are created from it. Those data structures, plus the state definition array, are referenced when an event is being processed. Don't even think about changing the state definition array at run-time!

Because your code must call the statechart engine, it calls your event handlers back in order to find out who will be handling the event and where to go next. Hence, when executing an event handler, you are on the same thread that initially called the engine.

The code contains numerous assert() statements, which check for a variety of simple mistakes that can be made when defining the array of states. Examples are failure to have an initial default state, and a state not having a valid parent state. Such errors are caught during initialization, rather than at run-time.

History

  • 23 August, 2007 -- Article and downloads updated
    • The code has been updated to compile/run with Visual Studio 2005.
    • A tracing feature has been added to assist in debugging.
    • The article was updated to match.
  • 23 August, 2005 -- Original version posted

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

About the Author

GDSchultz
Software Developer (Senior)
United States United States
No Biography provided

Comments and Discussions

 
GeneralDon't understand the assertEvent() methods in StateClass.hpp PinmemberMember 10937063-Feb-11 7:48 
GeneralConcurrent states Pinmembernonamir11-Feb-09 10:28 
GeneralRe: Concurrent states [modified] PinmemberNick Alexeev11-Feb-09 20:54 
GeneralRe: Concurrent states Pinmembernonamir13-Feb-09 21:47 
GeneralSDL and Z.100 ITU Recommendation PinmemberOleksiy Sokolovskiy15-May-06 13:51 
GeneralVery interesting... Pinmemberboutblock27-Jan-06 2:14 
GeneralRe: Very interesting... Pinmemberboutblock27-Jan-06 2:16 
the interface looked like this :
 
//
// STATE MACHINE ENGINE BASE CLASS
//
 
#ifndef MOD_CSTATEMACHINE_H
#define MOD_CSTATEMACHINE_H
 
#include "delegate.h"
 
//------------- CLASS INTERFACE ---------------
 
namespace StateMachines
{
// a guard must be true to validate a transition triggered by an event
// (remember that if you declare the same event with different guards
// for a finite state, only one transition path must be true at a time !)
typedef delegate fguard;
 
// an action is simply a boolean function call with an untyped parameter
typedef delegate faction;
 
// a function which must return a timeout value in milliseconds
typedef delegate ftimer;
 
// inherite from this base class to build your own state machines
class CStateMachine
{
public:
 
// private inner class hidding implementation
class CHandle;
 
// a event must be unique in a state machine context
// we have two solutions to achieve this goal :
// - declare static events with a name
// - inherate each event you need from a base class
class CEvent
{
public:
operator const char *();
operator bool();
 
operator == (CEvent &evt);
operator != (CEvent &evt);
 
// needed internally for STL
operator == (CEvent *evt);
operator != (CEvent *evt);
 
virtual ~CEvent() {};
 
protected: friend CHandle;
CEvent(IN const char *name, IN bool need_args);
 
const char *m_name;
bool m_need_args;
void *m_args;
};
 
class CDynamicEvent : public CEvent
{
public:
CDynamicEvent (IN const char *name, IN bool need_args = false);
};
 
class CStaticEvent : public CEvent
{
public:
// to ease user life, reference is made fully automatic
operator CEvent * () { return static_cast(this); }
 
CStaticEvent (IN const char *name, IN bool need_args = false);
};
 
// predefined events
static CStaticEvent EVT_NULL; // do nothing but helps generalizing msg pump loops
static CStaticEvent EVT_TIMER; // on WM_TIMER, send EVT_TIMER with event_args equals to msg.wParam (ie. the timer id)
 
// need to be declared here because of cross references between transition and state classes
class CTransition;
class CTransitionUnnamed;
 
// a state is a set of transitions to states
// (a state machine is a tree set of states)
virtual class CState
{
public:
operator const char *();
~CState (void);
 
protected: friend class CHandle;
 
// Note: VC++ bugs when using ellipses AND default arguments at the same time
bool Set (const char * name,
faction & on_entry,
faction & on_exit,
class CState & upper_state,
class CState & default_state,
class CTransition ** transition);
 
virtual bool Behave (IN CHandle *Handle, void *event_args) = 0;
 
const char * m_name;
faction m_on_entry;
faction m_on_exit;
class CState * m_upper_state;
class CState * m_default_state;

// dynamic
class CState * m_last_substate;
class CStateMachine *m_self;
 
vector m_transition;
};
 
// abstract class used to make a difference between persistent states and transcient pseudostates
virtual class CSubState : public CState
{
};
 
// abstract class used to make a difference between persistent states and transcient pseudostates
virtual class CPseudoState : public CState
{
};
 
class CContainerState : public CSubState
{
public:
 
bool Set (const char * name,
faction & on_entry,
faction & on_exit,
CContainerState & upper_state,
CSubState & initial_substate,
CTransition * transition,
...);
 
bool Set (const char * name,
CContainerState & upper_state,
CSubState & initial_substate,
CTransition * transition,
...);
private:
bool Behave (IN CHandle *Handle, void *event_args);
};
 
class CTopState : public CContainerState
{
public:

bool Set (const char * name,
faction & on_entry,
faction & on_exit,
CSubState & initial_substate,
CTransition * transition,
...);
 
bool Set (const char * name,
CSubState & initial_substate,
CTransition * transition,
...);
 
private: friend class CHandle;
bool Behave (IN CHandle *Handle, void *event_args);
 
// dynamic
class CContainerState * m_including_state;
};
 
class CIncludeState : public CContainerState
{
public:
 
bool Set (const char * name,
faction & on_entry,
faction & on_exit,
CContainerState & upper_state,
CStateMachine & substatemachine,
CTransition * transition,
...);
 
bool Set (const char * name,
CContainerState & upper_state,
CStateMachine & substatemachine,
CTransition * transition,
...);
 
private: friend class CHandle;
bool Behave (IN CHandle *Handle, void *event_args);
 
// dynamic
class CStateMachine * m_substatemachine;
};
 
class CSimpleState : public CSubState
{
public:
 
bool Set (const char * name,
faction & on_entry,
faction & on_exit,
CContainerState & upper_state,
CTransition * transition,
...);
 
bool Set (const char * name,
CContainerState & upper_state,
CTransition * transition,
...);
 
private:
bool Behave (IN CHandle *Handle, void *event_args);
};
 
class CFinalState : public CPseudoState
{
public:

bool Set (const char * name,
faction & on_entry,
CContainerState & upper_state);
 
bool Set (const char * name,
CContainerState & upper_state);
 
private:
bool Behave (IN CHandle *Handle, void *event_args);
};
 
class CDeepHistoryState : public CPseudoState
{
public:

bool Set (const char * name,
faction & on_entry,
CContainerState & upper_state,
CSubState & default_state);
 
bool Set (const char * name,
CContainerState & upper_state,
CSubState & default_state);
 
private:
bool Behave (IN CHandle *Handle, void *event_args);
};
 
class CShallowHistoryState : public CPseudoState
{
public:

bool Set (const char * name,
faction & on_entry,
CContainerState & upper_state,
CSubState & default_state);
 
bool Set (const char * name,
CContainerState & upper_state,
CSubState & default_state);
 
private:
bool Behave (IN CHandle *Handle, void *event_args);
};
 
class CChoiceState : public CPseudoState
{
public:
 
bool Set (const char * name,
faction & on_entry,
CContainerState & upper_state,
CTransitionUnnamed * transition,
...);
 
bool Set (const char * name,
CContainerState & upper_state,
CTransitionUnnamed * transition,
...);
 
private:
bool Behave (IN CHandle *Handle, void *event_args);
};
 
// a transition is triggered by an event associated to { a guard, a next state, an action }
class CTransition
{
public:
 
// used to initialize NO_MORE_TRANSITIONS
CTransition (void);
~CTransition (void);
 
// evt : the event triggering the transition
// guard : must return true to validate the event and so the transition
// next_state : the next state to the be set as the current active state when transition is validate by the event and guard
// action : the action to be done between the exit and entry actions of the destination state
CTransition (class CEvent & event,
fguard & guard,
class CState & next_state,
faction & action);
 
// evt : the event triggering the transition
// guard : must return true to validate the event and so the transition
// next_state : the next state to the be set as the current active state when transition is validate by the event and guard
CTransition (class CEvent & event,
fguard & guard,
class CState & next_state);

// evt : the event triggering the transition
// next_state : the next state to the be set as the current active state when transition is validate by the event and guard
// action : the action to be done between the exit and entry actions of the destination state
CTransition (class CEvent & event,
class CState & next_state,
faction & action);
 
// evt : the event triggering the transition
// next_state : the next state to the be set as the current active state when transition is validate by the event and guard
CTransition (class CEvent & event,
class CState & next_state);
 
// a transition internal to a state (no state change, no exit/entry action done)
// evt : the event triggering the internal transition
// guard : must return true to validate the event and so the transition
// action : the action to be done inside the current state context
CTransition (class CEvent & event,
fguard & guard,
faction & action);
 
// a transition internal to a state (no state change, no exit/entry action done)
// evt : the event triggering the transition
// action : the action to be done inside the current state context
CTransition (class CEvent & event,
faction & action);
 
// default transition if no others matches the current event
// next_state : the next state to the be set as the current active state when transition is validate by the event and guard
// action : the action to be done inside the current state context
CTransition (class CState & next_state,
faction & action);
 
// default transition if no others matches the current event
// next_state : the next state to the be set as the current active state when transition is validate by the event and guard
CTransition (class CState & next_state);
 
// default internal transition if no others matches the current event (no state change, no exit/entry action done)
// action : the action to be done inside the current state context
CTransition (faction & action);
 
// assignment operator needed by vector template
virtual CTransition& operator=( const CTransition &t );
 
// comparison operators needed by delegate template
operator == (CTransition &transition);
operator != (CTransition &transition);
 
protected: friend class CHandle;
class CEvent m_event;
fguard m_guard;
class CState * m_next_state;
faction m_action;
 
// dynamic
UINT_PTR m_timer;
DWORD m_timer_duration_ms; // milliseconds
ftimer m_ftimer;
bool guard_timer (void * event_args);
};
 
// a transition not triggered by an event but only by its guard
class CTransitionUnnamed : public CTransition
{
public:
// guard : must return true to validate the transition
// next_state : the next state to the be set as the current active state when transition is validate by the guard
// action : the action to be done between the exit and entry actions of the destination state
CTransitionUnnamed (
fguard & guard,
class CState & next_state,
faction & action);
 
// guard : must return true to validate the transition
// next_state : the next state to the be set as the current active state when transition is validate by the guard
CTransitionUnnamed (
fguard & guard,
class CState & next_state);
 
// guard : must return true to validate the internal transition
// action : the action to be done inside the current state context
CTransitionUnnamed (
fguard & guard,
faction & action);
};
 
// a transition internal to a state (no state change, no exit/entry action done) that defers the event to the next state change
class CTransitionDeferEvent : public CTransition
{
public:
// evt : the event to be deferred
// guard : (delegate) must return true for the event to be deferred
CTransitionDeferEvent (class CEvent & event,
fguard & guard);
 
// evt : the event to be deferred
CTransitionDeferEvent (class CEvent & event);
 
// a transition internal to a state (no state change, no exit/entry action done)
// that defers the event to the next state change by default
CTransitionDeferEvent (void);
};
 
// a transition performing an action and a state change when the timeout occured
class CTransitionTimer : public CTransition
{
public:
// duration : value of the timeout in milliseconds
// next_state : the next state to the be set as the current active state when transition timed out
// action : the action to be done between the exit and entry actions of the destination state
CTransitionTimer (
DWORD duration_ms,
class CState & next_state,
faction & action);
 
// duration : value of the timeout in milliseconds
// next_state : the next state to the be set as the current active state when transition timed out
CTransitionTimer (
DWORD duration_ms,
class CState & next_state);
 
// ftimer : function which returns the timeout value in milliseconds
// next_state : the next state to the be set as the current active state when transition timed out
// action : the action to be done between the exit and entry actions of the destination state
CTransitionTimer (
ftimer & fduration,
class CState & next_state,
faction & action);
 
// ftimer : function which returns the timeout value in milliseconds
// next_state : the next state to the be set as the current active state when transition timed out
CTransitionTimer (
ftimer & fduration,
class CState & next_state);
 
// duration : value of the timeout in milliseconds
// action : (delegate) the action to be done between the exit and entry actions of the destination state
CTransitionTimer (
DWORD duration_ms,
faction & action);
 
// ftimer : function which returns the timeout value in milliseconds
// action : (delegate) the action to be done between the exit and entry actions of the destination state
CTransitionTimer (
ftimer & fduration,
faction & action);
};
 
static CTransition NO_MORE_TRANSITIONS;
 
// fire an event with or without args to a state machine (in the current thread context)
bool Fire (IN CEvent &event, void *event_args = NULL);
 
// retrieve the variable parameters as a (va_list *) in action function
// (be careful, events can't be deferred because args are not persistent)
bool Fire (IN CEvent *event, ...);
 
// send an event with or without args to a state machine internal thread
// event pointer must be allocated with "new" and args must be persistent
// Note that whether a Send() is called within an action,
// the event will be deferred to comply with the "step to completion" rule
bool Send (IN CEvent *event, void *event_args = NULL, bool high_priority = false);
 
// used usually in an action transition for forwarding the event to sub-machines
// (may also be the last sent if not called within an action function context)
CEvent * GetCurrentEvent (void);
 
// used in guards to synchronize concurrent machines
bool Is_In (IN CState &state);
 
// return the state machine name
operator const char *();
 
// useful to add traces in actions
void Error (IN char *printf_format, ...);
void Trace (IN char *printf_format, ...);
 
CStateMachine::CStateMachine (IN CTopState &top_state,
IN char *name = "",
IN FILE *traceout = NULL,
IN FILE *errorout = NULL);

virtual CStateMachine::~CStateMachine ();
 
// initialize the machine at its initial active state
// and within or not its own thread
bool Run(bool thread = false);
 
private: friend CHandle; friend CIncludeState;
CHandle *Handle;
};
}
 
#endif // MOD_CSTATEMACHINE_H

GeneralRe: Very interesting... Pinmemberboutblock27-Jan-06 2:40 
GeneralRe: Very interesting... Pinmemberboutblock27-Jan-06 2:43 
GeneralRe: Very interesting... Pinmembergschultz30-Jan-06 5:23 
GeneralAn excellent start for an under-publicized methodology. PinmemberWREY30-Aug-05 6:16 
GeneralRe: An excellent start for an under-publicized methodology. PinmemberJim Crafton2-Sep-05 13:25 
GeneralRe: An excellent start for an under-publicized methodology. PinmemberWREY6-Sep-05 12:07 
AnswerRe: An excellent start for an under-publicized methodology. Pinmembergschultz7-Sep-05 3:16 
GeneralStateChart vs. FlowChart PinmemberKochise23-Aug-07 21:57 
GeneralStateChart vs. FlowChart PinmemberNick Alexeev9-Feb-09 17:01 

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 | Mobile
Web01 | 2.8.140721.1 | Last Updated 23 Aug 2007
Article Copyright 2005 by GDSchultz
Everything else Copyright © CodeProject, 1999-2014
Terms of Service
Layout: fixed | fluid