Click here to Skip to main content
15,860,972 members
Articles / Programming Languages / C#

TSWizard - A Wizard Framework for .NET

Rate me:
Please Sign up or sign in to vote.
4.86/5 (55 votes)
26 May 2003BSD17 min read 459.5K   4.6K   209   134
Provides a framework for creating wizards for use in your .NET applications

Image 1

Table of Contents

Introduction

I started work on the wizard framework at the end of July 2002 when I started a project that needed to be easy to use and that demanded that I hold the hands of the user as they proceeded to do their work.

Unfortunately, the .NET Framework lacks anything for quick wizard creation; thus I started my work in designing a reusable framework. What you see before you is the second revision of the original framework. The changes were minor and were only done to emulate the .NET framework's design standards.

At the time, I hadn't yet known that Chris Sells' Genghis project[^] was going to include a wizard framework. I will let you decide which is more suited for what you want to do. If this design doesn't suit you well, psdavis has created one based on the Tab control (Wizard Tab Control).

I've had the code sitting around on my hard drive for a while now; and in a break from writing, I wrote this article.

The Basics

A wizard is fairly basic, it consists of a window (BaseWizard) that contains individual steps (BaseStep) that can be navigated to based on the state of other steps. The framework I present here has two main parts to it, the BaseWizard which will contain the individual steps and the BaseStep which represents a single step of the wizard.

Page Layouts

Pages in wizards typically have two different layouts, one for exterior pages -- that is the first and last pages in the wizard -- and one for interior pages -- that is the pages in the middle of the wizard. TSWizard implements this by letting the wizard itself decide the best way to format itself, and having each individual step tell the wizard how it should be formatted. The difference between the two is drastic, but can be visually appealing. Here is a summary of what Microsoft advises in its Wizard97 specification.

Exterior Pages

Exterior pages have a sidebar graphic with a boxed logo. It should also have text in a larger font containing the title. If the page is the welcome page, then it should have a paragraph or two describing what the wizard will be doing. If it is the finish page, then it should have a list of things the wizard has done or will do, plus any instructions for the user to do after the wizard closes.

Interior Pages

Interior pages are simpler in the step design but more complex in the wizard design. At the top, it should have the step's title in bold, followed by a short description of that step, which is indented. A small logo is also provided on the right hand side. In the step itself you can place your controls in a layered manner. The labels should be on the second line, followed by the control they label on the third line. If you need more area, you can move out a line (label on the first, control on the second).

PageLayout enum

Name Description
InteriorPage The wizard and page should be formatted as an interior page.
ExteriorPage The wizard and page should be formatted as an exterior page.
None The wizard and page should use the last format used by the wizard.

BaseWizard

The wizard being designed in interior page mode

The wizard being designed in exterior page mode

Visually, the wizard consists of several different parts:

  • The wizard's caption
  • An info bar containing
    • The step title
    • The step description
    • A logo
  • A side panel containing
    • A filler graphic
    • Another logo
  • The step container
  • Navigation buttons

BaseWizard Members

Name Description
Image 4 AllowClose AllowClose Gets/Sets how the wizard should react to being closed.
Image 5 bool BackEnabled Gets/Sets whether the back button should be enabled.
Image 6 string FirstStepName Gets/Sets the name of the first step the wizard should show.
Image 7 Image Logo Gets/Sets the image that should appear in the upper-right corner (appears only for Interior pages).
Image 8 Image SideBarLogo Gets/Sets the image that should appear in the upper-right corner of the sidebar (appears only for Exterior pages).
Image 9 Image SideBarImage Gets/Sets the image that should appear in the sidebar (appears only for Exterior pages).
Image 10 bool NextEnabled Gets/Sets whether the next button should be enabled.
Image 11 string Title Gets/Sets the step title.
Image 12 WizardStepDictionary Steps Gets the name/BaseStep pair dictionary class for this wizard.
Image 13 bool IsFinish Gets whether the current step's NextStep property is equal to the const value BaseStep.FinishStep.
Image 14 void MoveBack() Moves to the previous step
Image 15 void MoveNext() Moves to the next step
Image 16 void MoveTo(string name) Moves to the step with the specified name
Image 17 void AddStep(string name, BaseStep step) Adds a new step to the wizard with the specified name.
Image 18 BaseStep GetStep(string name) Returns a step, given the name.
Image 19 BaseStep RemoveStep(string name) Removes a step from the wizard, given the name; it returns a reference to that step.
Image 20 void ResetSteps() Fires the ResetStep event on each of the BaseSteps added to the wizard.
Image 21 void SetCurrentStep(string name) Sets the step shown in the wizard to the one with the given name.
Image 22 void SetFinish(bool bFinish) Changes the text of the Next button to reflect the state passed in.
Image 23 void OnLoadSteps(EventArgs e) Raises the LoadSteps event.
Image 24 LoadSteps Fired when the steps should be added to the wizard. Steps can be added before and after this event is fired; but the step specified by the FirstStepName property should be added at this point. This has also been made the default event.

BaseWizard.StepDirection Members

Member Description
InitialStep This step is being displayed because it is the first step in the wizard
NextStep This step is being displayed because it was the next step in the wizard
PreviousStep This step is being displayed because it was the previous step in the wizard
Jump This step is being displayed because BaseWizard.SetCurrentStep was called directly

As always, what little documentation is here is only the intended behavior; actual behavior is dictated by the code.

Before I explain typical usage of the BaseWizard class, I first want to show the properties, methods, and events exposed by the BaseStep class.

BaseStep

An interior step being designed

An exterior step being designed

The BaseStep object is implemented as a UserControl. This has two advantages, first it allows the user to visually design the step and it makes it easier to display it in the wizard without resorting to 'trickery' in making a Form appear as a Control.

BaseStep Members

Name Description
Image 27 bool IsFinished Gets/Sets whether clicking next will end the wizard. This value will also change the text displayed in the next button.
Image 28 string NextStep Gets/Sets the name of the next step in the wizard. Set this to "FINISHED" or BaseStep.FinishStep if clicking next should close the wizard.
Image 29 string PreviousStep Gets/Sets the name of the previous step in the wizard.
Image 30 string StepTitle Gets/Sets the text that will be displayed next to the logo in the wizard.
Image 31 string StepDescription Gets/Sets a short description of this step. It will be displayed underneath the title for Interior pages and in a label at the top of the step for Exterior pages.
Image 32 BaseWizard Wizard Gets/Sets the instance of the wizard that this step belongs to. The wizard framework will take care of setting this property, so user code should only use the accessor (get) method.
Image 33 Image Logo Gets/Sets an Image which will override the Logo used by the Wizard, if this is non-null.
Image 34 Image SideBarLogo Gets/Sets an Image which will override the SideBarLogo used by the Wizard, if this is non-null.
Image 35 Image SideBarImage Gets/Sets an Image which will override the SideBarImage used by the Wizard, if this is non-null.
Image 36 PageLayout PageLayout Gets/Sets the wizard layout that should be used for this page. Can be set directly or you can derive from BaseInteriorStep or BaseExteriorStep which does this, plus sets the default size.
Image 37 int NumLinesToDraw A design-time property to adjust the number of helper lines to draw on the step.
Image 38void OnNext() Called when the next button has been clicked and this step is the current one. By default, this method tells the Wizard to move to the next step.
Image 39 void OnBack() Called when the back button has been clicked and this step is the current one. By default, this method tells the Wizard to move to the previous step.
Image 40 void OnFinish() Called when the finish button has been clicked and this step is the current one. By default, this method tells the Wizard to close.
Image 41 void OnNextStepChanged(EventArgs e) Fires the NextStepChanged event.
Image 42 void OnPreviousStepChanged(EventArgs e) Fires the PreviousStepChanged event.
Image 43 void OnStepTitleChanged(EventArgs e) Fires the StepTitleChanged event.
Image 44 void OnShowStep(EventArgs e) Fires the ShowStep event.
Image 45 void OnResetStep(EventArgs e) Fires the ResetStep event.
Image 46 NextStepChanged Occurs when the NextStep property has changed.
Image 47 PreviousStepChanged Occurs when the PreviousStep property has changed.
Image 48 StepTitleChanged Occurs when the StepTitle property has changed.
Image 49 StepDescriptionChanged Occurs when the StepDescription property has changed.
Image 50 ShowStep Occurs when the step is shown in the wizard; this event will occur each time the step is shown, not just the first time. Now provides information about what direction this step is shown by.
Image 51 ResetStep Occurs when the step should reset its fields, this event will fire after steps are loaded by LoadSteps in the Wizard and whenever ResetSteps is called in the Wizard.
Image 52 LogoChanged Occurs when the Logo property has changed.
Image 53 SideBarLogoChanged Occurs when the SideBarLogo property has changed.
Image 54 SideBarImageChanged Occurs when the SideBarImage property has changed.
Image 55 PageLayoutChanged Occurs when the PageLayout property has changed.
Image 56 ValidateStep Occurs after the Next button has been clicked but before the wizard moves to the next step. Now fixed to fire when the Next button is now the Finish.

Demo Walkthrough

You have a couple different ways of using the wizard framework as far as how you design the wizard and its steps. My preferred way of creating a wizard is to use the Inherited Form and UserControl features of VS.NET which requires that the form and UserControl you wish to inherit from be in the current solution or for you to select the assembly they are located in. To accomplish this, you can add the TSWizard project to your solution or choose to browse for the assembly each time you wish to add a new wizard or step.

The rest of this article will focus on the creation of the simple wizard found in the demo.

Wizard Design

The wizard will consist of five steps:

  1. Introductory step; tells the user what the wizard will accomplish
  2. Information step; allows the user to enter in any information needed by the wizard
  3. Confirm step; recaps the information entered, then prompts for the user to click Next to do the work
  4. Work step; here the work will actually be done; once it is done, the wizard will automatically proceed to step 5
  5. Finish step; recap the work that was done and ask the user if they would like to run the wizard again

This should show off all the features I made available in the framework; plus give another demo on threading with windows forms. Off to start the work fun!

First, add a new Form and have it inherit from TSWizards.BaseWizard (don't forget to reference either the TSWizards project or the compiled DLL), I named it DemoWizard. Then prior to starting work on the actual content of the steps, I added 5 new UserControls to the project. The first one inherits from TSWizards.BaseExteriorPage, the next three inherit from TSWizards.BaseInteriorPage, and the last inherits from TSWizards.BaseExteriorPage. I named them Step1, Step2, Step3, Step4, and Step5.

The Wizard

In the wizard's LoadSteps event, I added code to add each of the steps to the wizard's step list.

C#
AddStep("Step1", new Step1());
AddStep("Step2", new Step2());
AddStep("Step3", new Step3());
AddStep("Step4", new Step4());
AddStep("Step5", new Step5());
DemoWizard Properties
AllowClose AskIfNotFinish
FirstStepName Step1

Step 1

Image 57

Pretty simple first screen, we don't want to scare the user away after all. Aside from the design of the step; the only code I added was a property exposing the checked state of the checkbox. In production code, you would also have it save and load this value so that the first step could be skipped. This should all be done within the LoadSteps event, because the FirstStepName property is used after it has completed.

Step1 Properties
StepTitle Welcome to the Wait On Us ordering wizard!
StepDescription In this wizard I will take your order, then fetch it in 30 seconds or less; or else its free!
NextStep Step2

Step 2

Image 58

A bit more work here; the two combo boxes contain the values "Rare", "Medium Rare", "Medium Well", "Well Done". A lot more code is in this one as well. First, handle the ResetStep event and add the following line of code.

C#
ResetCheckBoxes(this);

Now add the following method to the Step2 class:

C#
private void ResetCheckBoxes(Control parent)
{
    CheckBox chk = null;
    ComboBox cbo = null;
    
    foreach(Control c in parent.Controls)
    {
        chk = c as CheckBox;
        cbo = c as ComboBox;
        
        if( chk != null )
        {
            chk.Checked = false;
        }
        else if( cbo != null )
        {
            cbo.SelectedIndex = -1;
        }
        else
        {
            ResetCheckBoxes(c);
        }
    }
}

This code takes in a Control as a parameter, then it loops through all child controls of the parent and then checks to see if the child control is a CheckBox or a ComboBox. If it is, it resets the values to the default. If it isn't either of those two controls, then it will recursively call itself using the child control as the new parent. The call starts by passing in the form instance. I could have done the same by calling it three times, once for each GroupBox on the UserControl.

Next, we need to validate the order; ensuring that we have that particular item in the kitchen to prepare. This is done by handling the ValidateStep event on the step object. Taking a look at the inventory in the kitchen, it looks like the restaurant ran out of tofu; so no more tofu burgers can be ordered. In the ValidateStep event handler, add the following code to make sure the customer didn't order any tofu burgers.

C#
if( tofu.Checked )
{
    MessageBox.Show(
        "We are out of tofu burgers please reselect", 
        "Reselect items");
        
    e.Cancel = true;
}

We also need a way to get the data back out of the UserControl; I chose to do this by creating a StringCollection and adding the food name if the appropriate check box is checked. I put this into a public method called GetOrder. I'll only include a snippet of it as the code is fairly lengthy, but very redundant.

C#
public StringCollection GetOrder()
{
    StringCollection sCol = new StringCollection();
    
    if( caesarSalad.Checked )
    {
        sCol.Add("Caesar Salad");
    }
    
    if( tossedSalad.Checked )
    {
        sCol.Add("Tossed Salad");
    }
    
    // ...
    
    return sCol;
}
Step2 Properties
StepTitle Place order
StepDescription What would you like to order?
NextStep Step3
PreviousStep Step1

With that code out of the way, we're ready to move on to Step 3!

Step 3

Image 59

Another simple step to design, a simple mutli-line TextBox with the ReadOnly property set to true. In the ShowStep event, add the following bit of code to have the order reflect the latest selection.

C#
System.Text.StringBuilder sb = new System.Text.StringBuilder();
Step2 step2 = Wizard.GetStep("Step2") as Step2;

if( step2 == null )
{
    throw new ApplicationException(
        "Step2 of the wizard wasn't really step2");
}

StringCollection order = step2.GetOrder();

foreach(string item in order)
{
    sb.AppendFormat("{0},\r\n", item);
}

orderConfirm.Text = sb.ToString();

Pretty simple really, it gets the instance of Step2 from the wizard, then uses the GetOrder method to retrieve the order that was placed. Now that this step is done, it's on to Step 4.

Step3 Properties
StepTitle Order confirmation
StepDescription Please confirm your order. If you are satisfied with your choices click next to have our talented chef prepare your order before your very eyes!
NextStep Step4
PreviousStep Step2

Step 4

Image 60

Ahhhh, the fun part! This step is what actually does the 'work' of preparing the order.

It is important to note that all work has to be done in another thread; or else there won't be any visual confirmation of what is happening. This is because the ShowStep event runs in the context of either the Load event for the Wizard form or the Click event when Back or Next/Finish is clicked.

"But James," you may say; "I thought we couldn't do anything to the GUI while operating in another thread?" And that itself is true, but that is what the BeginInvoke, EndInvoke, and Invoke methods on the Control class are for! They take a delegate and run it on the thread that owns the handle to the underlying Win32 window. With that said, let's get into how we will go about preparing this order.

  • First, we begin by getting the list of items that were ordered
  • For each item ordered, we will:
    • Begin to prepare the item (put its name in the Now preparing label)
    • Make the item (sleep on the thread for 1 second)
    • Finish preparing the item (increase the progress bar to account for the item being done)
  • Once that is done, we will move to the next step of the wizard (the summary step)

Let's get to some code now.

C#
private void DoWork()
{
    Step2 step2 = Wizard.GetStep("Step2") as Step2;
    
    if( step2 == null )
    {
        throw new ApplicationException(
            "Step2 of the wizard wasn't really step2");
    }
    
    StringCollection order = step2.GetOrder();
    
    if( order.Count > 0 )
    {
        BeginPreparingOrder(order.Count);
        
        foreach(string item in order)
        {
            Preparing(item);
            Thread.Sleep(1000);
            Prepared();
        }
    }
}

The method above will perform the steps I laid out above; doing nothing but moving to the next step if there were no items ordered. I've placed copies of the helper methods used in the method below.

First, I will discuss the technique that I use for ensuring that GUI matters take place on the correct thread.

C#
private void BeginPreparingOrder(int items)
{
    if( InvokeRequired )
    {
        this.Invoke( new IntDelegate(BeginPreparingOrder), 
            new object [] { items } );
        return ;
    }
    
    progress.Maximum = items * 10;
    progress.Value = 0;
}

The Control class defines a property called InvokeRequired which will return whether Invoke (or its kin) needs to be called to place itself in the correct thread. I use that property to my advantage by calling Invoke on itself should it be required; if it isn't required, then continues to do its GUI work uninhibited. The downside to this practice is that you need to find or create delegates to match the signature of the method. I have done so for the two methods that I call with parameters, IntDelegate and StringDelegate.

C#
private void Preparing(string item)
{
    if( InvokeRequired )
    {
        this.Invoke( new StringDelegate(Preparing), 
            new object [] { item } );
        return ;
    }
    
    preparing.Text = item;
}

private void Prepared()
{
    if( InvokeRequired )
    {
        this.Invoke( new MethodInvoker(Prepared), 
            new object [] { } );
        return ;
    }
    
    progress.PerformStep();
}

private void DonePreparing(IAsyncResult result)
{
    if( InvokeRequired )
    {
        Invoke(new AsyncCallback(DonePreparing), 
            new Object [] { result } );
        return ;
    }
    
    NextStep = "Step5";
    Wizard.MoveNext();
}

private delegate void IntDelegate(int num);
private delegate void StringDelegate(string str);

By itself, this code does nothing because nothing is ever calling the DoWork method. I do this by placing the following code in the ShowStep event.

C#
MethodInvoker mi = new MethodInvoker( this.DoWork );

mi.BeginInvoke(new AsyncCallback(DonePreparing), null);

I suppose I'll give the property listing for this step now.

Step4 Properties
StepTitle Now Preparing
StepDescription Our world class chef is now preparing your order, please wait.
NextStep String.Empty
PreviousStep String.Empty

Step 5

Image 61

This step changed a bit more than the others since the last version. The bulleted list is created by using another control that is part of the framework, BulletList, it is really easy to use. Set the Text property to the text to display above the list, and use the StringCollection provided by the Items property to add each item to the list. The order was retrieved the same way as it was in Step 3, so refer to that if you need a refresher. At the moment, the default behavior of OnFinish is to close the wizard; we will change this behavior to move to the first or second step if the CheckBox is checked.

Here it is folks, the last bit of code for this article:

C#
protected override void OnFinish()
{
    if( runAgain.Checked )
    {
        string moveTo;
        
        Step1 step1 = Wizard.GetStep("Step1") as Step1;
        
        if( !step1.NoShowWelcomeAgain )
            moveTo = "Step1";
        else
            moveTo = "Step2";
        
        Wizard.ResetSteps();
        
        Wizard.MoveTo(moveTo);
        
        IsFinished = true;
    }
    else
    {
        base.OnFinish();
    }
}
Step5 Properties
StepTitle Your order is complete!
StepDescription Our world class chef is now preparing your order, please wait.
NextStep String.Empty
PreviousStep String.Empty

Conclusion

There isn't much here to learn from; it just took some ambition to create the classes in the first place.

Whew, this is by far my longest article so far; and it has been a while since I've written one. I hope all of you at least enjoyed reading the article and maybe you'll find the framework useful.

Acknowledgments

  • Bug findings/fixes
    • Chris Dufour
    • Steaven Woyan
    • Ryan LaNeve
    • Urs Enzler
  • Suggestions
    • Ken Ostrin
  • Suggestions without knowing it
    • Alex Kucherenko - for his Wizard article which inspired me to add the design-time guides to the steps

History

September 19, 2002

  • Initial posting

September 30, 2002

  • Added ValidateStep event, suggested and implemented by Chris Dufour. Thanks Chris! :-)

October 13, 2002

  • Fixed a bug where the wizard wouldn't close if it wasn't shown modally. (Bug found by Steaven Woyan; fix suggested by Ryan LaNeve)

May 24, 2003 - Version 1.1 - Happy 23rd Anniversary Mom & Dad!

  • Added the WizardLayout property as suggested by Ken Ostrin
  • Added the StepDirection enum and modified the ShowStep event to use it, also suggested by Ken Ostrin
  • Added properties to the BaseStep to override the graphics selected by the Wizard.
  • Added two new BaseSteps, one for Exterior pages (first and last steps) and one for Interior pages (all the ones in the middle)
  • Integrated fixes from
    • Chris Storha - ValidateStep event wasn't fired if the Finish button was clicked
    • Urs Enzler - The AskIfNotFinish member of the AllowClose enum is no longer needed and is now marked obsolete
    • Sorry to anyone if I added your fix/bug report but forgot to mention you.

License

This article, along with any associated source code and files, is licensed under The BSD License


Written By
Software Developer (Senior) InfoPlanIT, LLC
United States United States
James has been programming in C/C++ since 1998, and grew fond of databases in 1999. His latest interest has been in C# and .NET where he has been having fun writing code starting when .NET v1.0 was in its first beta.

He is currently a senior developer and consultant for InfoPlanIT, a small international consulting company that focuses on custom solutions and business intelligence applications.

He was previously employed by ComponentOne where he was a Product Manager for the ActiveReports, Data Dynamics Reports, and ActiveAnalysis products.

Code contained in articles where he is the sole author is licensed via the new BSD license.

Comments and Discussions

 
QuestionA++ Pin
Doug Royer6-Dec-13 10:10
professionalDoug Royer6-Dec-13 10:10 
GeneralGreat Framework Pin
Mushtaque Nizamani17-Sep-12 8:05
Mushtaque Nizamani17-Sep-12 8:05 
BugComplete FAIL VS 2010 Project Import Pin
8rokn8on311-Aug-12 16:53
8rokn8on311-Aug-12 16:53 
GeneralRe: Complete FAIL VS 2010 Project Import Pin
waynez998-Oct-12 12:32
waynez998-Oct-12 12:32 
GeneralRe: Complete FAIL VS 2010 Project Import Pin
Anythingwill10-Oct-12 8:46
Anythingwill10-Oct-12 8:46 
GeneralMy vote of 5 Pin
Manoj Kumar Choubey17-Apr-12 18:34
professionalManoj Kumar Choubey17-Apr-12 18:34 
GeneralRe: My vote of 5 Pin
waynez998-Oct-12 12:34
waynez998-Oct-12 12:34 
GeneralUseful extension Pin
Ergun Coruh8-Feb-11 12:29
Ergun Coruh8-Feb-11 12:29 
GeneralMy vote of 5 Pin
Ergun Coruh19-Jan-11 10:49
Ergun Coruh19-Jan-11 10:49 
GeneralCorrecting the bottomPanel button positioning Pin
pablleaf10-Jul-09 10:39
pablleaf10-Jul-09 10:39 
QuestionProblems under visual studio 2005? Pin
alon198029-Apr-08 22:55
alon198029-Apr-08 22:55 
GeneralThanks... Pin
BugMeNot ACCOUNT21-Mar-08 19:44
BugMeNot ACCOUNT21-Mar-08 19:44 
QuestionLicensing Pin
lashawntab1-Feb-08 12:42
lashawntab1-Feb-08 12:42 
GeneralRe: Licensing Pin
James T. Johnson1-Feb-08 14:37
James T. Johnson1-Feb-08 14:37 
General[Problem] - The text of “next” button does not modify Pin
Cleber Ramos15-Aug-07 4:10
Cleber Ramos15-Aug-07 4:10 
GeneralRe: [Problem] - The text of “next” button does not modify Pin
Stirbelwurm30-Oct-07 0:17
Stirbelwurm30-Oct-07 0:17 
GeneralForm Resizing Pin
dmayberry31-Jul-07 5:49
dmayberry31-Jul-07 5:49 
Generalgood Pin
JaneHlp19-Mar-07 20:45
JaneHlp19-Mar-07 20:45 
GeneralAbout Wizard.GetStep Pin
jhcp2-Mar-07 7:24
jhcp2-Mar-07 7:24 
GeneralRe: About Wizard.GetStep Pin
artttom5-Mar-07 2:15
artttom5-Mar-07 2:15 
GeneralProcess.Start impossible after wizard instanciated Pin
daues30-Jan-07 4:00
daues30-Jan-07 4:00 
QuestionUse EnableVisualStyles Pin
fabsen210229-Jan-07 11:34
fabsen210229-Jan-07 11:34 
AnswerRe: Use EnableVisualStyles Pin
James T. Johnson29-Jan-07 16:04
James T. Johnson29-Jan-07 16:04 
GeneralRe: Use EnableVisualStyles Pin
fabsen210230-Jan-07 0:10
fabsen210230-Jan-07 0:10 
GeneralRe: Use EnableVisualStyles Pin
James T. Johnson30-Jan-07 1:29
James T. Johnson30-Jan-07 1:29 

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

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