Click here to Skip to main content
15,881,172 members
Articles / Desktop Programming / WPF

Embedding IronPython in WPF Using C#

Rate me:
Please Sign up or sign in to vote.
5.00/5 (2 votes)
30 Oct 2007CPOL13 min read 53.8K   997   24   1
In this article, we will see how to embed IronPython in our Windows Presentation Framework applications using C#.

Introduction

In this article, I'll explain the use of IronPython embedded into C# as a scripting engine. While doing this, I will also be showing the basics of WPF and how to integrate IronPython in it, so that we may use Python code to edit our application. Python is also a very easy language to pickup, especially if you know C#, because they are quite similar.

Introducing Python

The Python programming language was released in 1991, and was created by Guido van Rossum. Python's syntax is very clear and simple, taking the programmer's effort over computer effort. The language itself is a multi-paradigm language, which is similar to Perl, Ruby, and other languages. Python is an open community-based development model managed by the non-profit Python Software Foundation.

Brief differences of C# and Python

Python's dynamic typing model allows it to determine data types automatically at runtime. There is no need to declare a variable's type ahead of time, which is a very simple concept.

The difference between declaring variables in C# and Python:

C#
C#
int a = 1 
string b = "b"
Python
Python
a = 1
b = 'b'

Creating an if statement in Python is almost the same as in C#, except Python does not use curly ({}) braces to begin and end methods. Instead, a colon (:) is added at the beginning of the statement to begin executing the code. A problem arises because there is no ending indication as there is with C#'s curly braces, so you are stuck putting one statement without any indication of another. This is solved by simply putting a semi-colon (;) at the end of each statement, to indicate we are not done with the method.

The difference between creating an if statement in C# and Python:

C#
C#
if (a > b)
{
    a = 1;
    b = 2;
}
else if (a < 3 and b > 3)
{
    a = 2;
}
else
{
    b = 3;
}
Python
Python
if (a > b):
    a = 1;
    b = 2;
elif (a < 3 and b > 3):
    a = 2
else:
    b = 3

A function declared in Python is pretty much the same as the previous if statements except that it begins with "def". Python's def is executable code, therefore when you compile your code, the function does not exist until Python reaches and runs the def. Function types (like variables) do not need to be declared a type.

The difference between creating functions in C# and Python:

C#
C#
int MyFunction()
{
    return 5;
}
Python
Python
def MyFunction():
    return 5;

That was a very brief introduction to Python. Also, be aware that a lot of Python's syntax can be typed differently than shown here, but may mean the same thing.

Introduction to IronPython

IronPython was created with the implementation of the Python language, which was built for the .NET environment. The creator of IronPython is Jim Hugunnin, and the first version of IronPython was released in September 5, 2006.

Embedding IronPython

IronPython can be embedded into a WPF (Windows Presentation Framework) application in a few simple steps:

  1. Reference IronPython and IronMath.
  2. Add the namespaces:
  3. C#
    using IronPython;
    using IronMath;
  4. Declare PythonEngine:
  5. C#
    engine = new PythonEngine();

By accomplishing these three steps, you have initialized everything needed for the PythonEngine to begin.

Using IronPython in your application simply focuses on declaring variables and loading Python Script (.py extension) files.

C#
//Add Variable
PythonEngine.Globals.Add(key, value);

//Load Python File
PythonEngine.CompileFile(string path);

Example of adding variables to the PythonEngine globals:

C#
int var = 1; 
PythonEngine.Globals.Add("var", var);
PythonEngine.Globals["var"] = 3; 

Example of compiling a Python (.py) file:

Python
Python
//PythonFile.py
//
name = 'Chris'
age = 21
C#
C#
//Example.cs
PythonEngine.CompileFile("PythonFile.py");

//Retrieve Variables
string name = PythonEngine["name"].ToString();
int age = (int)PythonEngine["age"] ;

As you can see, PythonEngine.Globals plays a huge role in C# and Python communication.

Now say, if you want to execute a simple command, for instance, using Python's print command inside of C#. There is a simple function that does exactly that:

Python
//Execute code
PythonEngine.Execute(print name)

//Ouputs to the stream
PythonEngine.SetStandardOutput(Stream s)

An example of this being used in C#:

C#
//
//ExecutePython.cs
//
string name = "Bob";
MemoryStream stream = new MemoryStream();

PythonEngine.Globals.Add("name", name);
PythonEngine.SetStandardOutput(stream); 
PythonEngine.Execute("print name")

//Retrieve data from stream
byte[] data = new byte[stream.Length];
stream.Seek(0, SeekOrigin.Begin);
stream.Read(data, 0, data.Length);
string strdata = Encoding.ASCII.GetString(data);

//Output
//strdata: "Bob"

You could also hook up an Input and Error stream:

C#
PythonEngine.SetStandardError(Stream s);
PythonEngine.SetStandardInput(Stream s);

Creating the application using WPF

The application I've created here was created using the Windows Presentation Framework, and the goal of this program is to be used to experiment with IronPython and see its benefits. I am going to go over the XAML and C# code used to create the basic UI of the application.

In order to get the "Aero" look of the application, I had to follow these steps:

  1. Add the PresentationFramework.Aero reference.
  2. After that, right click on the reference PresentationFramework.Aero and select: Copy Local to true.
  3. Open up the App.xaml and add/edit:
  4. XML
    <ResourceDictionary 
      Source="/presentationframework.aero;component/themes/aero.normalcolor.xaml" />
  5. Click Build Application.

The Aero look is now on the UI.

The TreeView that is used in this application consists of a parent Scene node and the child nodes to the Scene node. The Scene node consists of three child nodes, which are Script, Actors, and Objects. The Script node has a combobox as a child, so the user may be able to select the current script to render to the scene. Actors and Objects are left blank for any child. The node display names are set with the Header attribute, and are expanded with the IsExpanded="True" property.

XML
<TreeView Name="treeScene" Background="LightGray" Width="135">
  <TreeViewItem Header="Scene" IsExpanded="True">
     <TreeViewItem Header="Script">
        <ComboBox Name="comboScript" />
     </TreeViewItem>

     <TreeViewItem Header="Actors" IsExpanded="True" />
       <TreeViewItem Header="Objects" IsExpanded="True" />
    </TreeViewItem>
</TreeView>

Once an Actor, Script, or Object is created, the TreeView creates and/or updates the new content. Updating the TreeView with a new Actor consists of these steps:

  1. Actor is created.
  2. Add TreeViewItem to the Actors node.
  3. Create another TreeViewItem under the Actor node just created.
  4. Add a ComboBox to the newest created TreeViewItem.

Here is the C# code that implements this process:

C#
public void AddTreeItem()
{ 
    TreeViewItem treeItemActor = new TreeViewItem();
    treeItemActor.IsExpanded = true;
    treeItemActor.Header = actorName;
    TreeViewItem treeItemScript = new TreeViewItem();
    treeItemScript.IsExpanded = true;
    treeItemScript.Header = "Script";
    ActorScript = new ComboBox();

    foreach (string scriptName in AIEngine.ScriptFiles.Keys)
    {
        actorScript.Items.Add(scriptName);
    }

    treeItemScript.Items.Add(ActorScript);
    int actorIndex = ((TreeViewItem)((TreeViewItem)
        AIEngine.SceneTree.Items[0]).Items[1]).Items.Add(treeItemActor);
    ((TreeViewItem)((TreeViewItem)((TreeViewItem)
      AIEngine.SceneTree.Items[0]).Items[1]).Items[actorIndex]).Items.Add(
      treeItemScript);

}

This process is implemented in the application every time an Actor is created.

Screen is made from a custom screen which is created by inheriting the DrawingCanvas class, which inherits the Canvas class. The DrawingCanvas class serves as a control where the user can draw his or her objects onto the screen. In order to do this, the class has to have a collection System.Windows.Media.Visuals (in which the drawings are saved). Also, I created a temporary visual collection so that I could make the square drawing animation without cluttering the main visual collection.

The code here shows how this was implemented:

C#
//The screen that holds and draws visuals. This is embedded
//in the XAML code of the MainWnd.

public class DrawingCanvas : Canvas
{
    //The collection of visual(drawings) the screen has
    private List<Visual>
    visuals = new List<Visual>();
    //Temprorary visuals that are deleted periodicly.
    //Such as drawing the square, in order to animate the 
    //dragging ability, we must delete visuals.
    private List<Visual>
    tempvisuals = new List<Visual>();
    //This tells the AddVisual whether or not add the visual
    //temprorary or in the visual collection.
    public bool startTempVisual = false;

    //Get the current visual count
    protected override int VisualChildrenCount
    {
        get
        {
            if (!startTempVisual)
            {
                return visuals.Count;
            }
            else
            {
                return tempvisuals.Count;
            }
        }
    }
    //Get a visual
    protected override Visual GetVisualChild(int index)
    {
        if (!startTempVisual)
        {
            return visuals[index];
        }
        else
        {
            return tempvisuals[index];
        }
    }

    //Add a temporary or normal visual
    public void AddVisual(Visual visual, bool tempVisual)
    {
        if (tempVisual)
        {
            tempvisuals.Add(visual);
        }
        else
        {
            visuals.Add(visual);
        }

        base.AddVisualChild(visual);
        base.AddLogicalChild(visual);
    }

    //Delete a temporary or normal visual
    public void DeleteVisual(Visual visual, bool tempVisual)
    {
        if (tempVisual)
        {
            tempvisuals.Clear();
        }
        else
        {
            visuals.Remove(visual);
        }
        
        base.RemoveVisualChild(visual);
        base.RemoveLogicalChild(visual);
    } 
}

The DrawingCanvas class is then embedded into the XAML of MainWindow:

XML
<local:DrawingCanvas Name="drawingScreen" Background="DimGray"></local:DrawingCanvas>

The "local:" tag is used to reference DrawingCanvas from the namespace:

XML
xmlns:local="clr-namespace:AIEditor.Core;assembly=AIEditor.Core"

The grid shown in the background of Screen is drawn using System.Windows.Media.DrawLine and added to the visual collection of the DrawingCanvas.

Upon window loading, this event is fired to draw the background grid:

C#
private void Window_Loaded(object sender, RoutedEventArgs e)
{
    visual = new DrawingVisual(); 
    int heightIncrements = (int)drawScreen.RenderSize.Height / 10; 
    int widthIncrements = (int)drawScreen.RenderSize.Width / 10; 
    int horizontalLength = (int)drawScreen.RenderSize.Width; 
    int verticalLength = (int)drawScreen.RenderSize.Height; 
    float verticalIncrement = 0; 
    float horizontalIncrement = 0; 
    float largerIndicator = 0; 

    using (DrawingContext dc = visual.RenderOpen()) 
    {
        //Draw Horizontal Lines 
        for (int h = 0; h < heightIncrements + 1; h++) 
        { 
            if (largerIndicator == 5) 
            { 
                Point fromPoint = new Point(0, verticalIncrement); 
                Point toPoint = new Point(horizontalLength, verticalIncrement); 
                HorizontalLines.Add(new Point[] { fromPoint, toPoint }); 
                Pen pen = new Pen(Brushes.DarkKhaki, .5); 
                dc.DrawLine(pen, fromPoint, toPoint); 
                largerIndicator = 0; 
            }
            else 
            { 
                Point fromPoint = new Point(0, verticalIncrement); 
                Point toPoint = new Point(horizontalLength, verticalIncrement); 
                HorizontalLines.Add(new Point[] { fromPoint, toPoint }); 
                Pen pen = new Pen(Brushes.Gray, .5); 
                dc.DrawLine(pen, fromPoint, toPoint); 
            } 
            largerIndicator += 1; 
            verticalIncrement += 10; 
        } 
        largerIndicator = 0; 

        //Draw Vertical Lines 
        for (int w = 0; w < widthIncrements + 1; w++) 
        { 
            if (largerIndicator == 5) 
            { 
                Point fromPoint = new Point(horizontalIncrement, verticalLength); 
                Point toPoint = new Point(horizontalIncrement, 0); 
                VerticalLines.Add(new Point[] { fromPoint, toPoint }); 
                Pen pen = new Pen(Brushes.DarkKhaki, .5); 
                dc.DrawLine(pen, fromPoint, toPoint); 
                largerIndicator = 0; 
            } 
            else 
            { 
                Point fromPoint = new Point(horizontalIncrement, verticalLength); 
                Point toPoint = new Point(horizontalIncrement, 0); 
                VerticalLines.Add(new Point[] { fromPoint, toPoint }); 
                Pen pen = new Pen(Brushes.Gray, .5); 
                dc.DrawLine(pen, fromPoint, toPoint); 
            } 
            largerIndicator += 1; 
            horizontalIncrement += 10; 
        } 
    } 
    drawScreen.AddVisual(visual, false); 
}

This drawing starts off by dividing the size of the screen by ten to get the spacing between each grid line. We increment through each of the spacing using a for loop and set the fromPoint and toPoint draw locations that are required to draw a line from point A to point B. horizontalIncrement and verticalIncrement hold the current incremented position to draw the next line, and when we draw the line, we want the line to extend to the end of the screen, so we use horizontalLength and vertialLength for drawing to the points. The pen color is changed to Dark Khaki when we increment through 5 lines. After we loop through the entire vertical and horizontal increments, we add the final visual to the DrawingCanvas.

Creating an Actor is a pretty straightforward process in this application. The application follows these steps to create and draw an Actor:

  1. Click the Create Actor menu item.
  2. Click on the DrawingCanvas screen.
  3. DrawActor is invoked and a visual is added to the DrawingCanvas.

Here is the Create Actor menu item XAML:

XML
<ToolBar Height="25"
     Margin="0,18,2,0"
     Name="toolBarMain" VerticalAlignment="Top"
     Grid.Column="1">
  <Button Name="btnCreateActor" Content="Create Actor" />
</ToolBar>

Set Actor Tool:

C#
private void btnCreateActor_Click(object sender, EventArgs e)
{
    Engine.Draw.CurrentTool = AIDraw.DrawingTools.Actor;
}

Invoke DrawActor to add the Actor to the DrawingCanvas visuals:

C#
//Create an Actor
public void DrawActor(){
    visual = new DrawingVisual();

    using (DrawingContext dc = visual.RenderOpen())
    {
        dc.DrawEllipse(drawingBrush, drawingPen,
        fromMousePoint, 4, 4);
    }
    drawScreen.AddVisual(visual, true);
}

At the end of this process, your actor will be added to the visuals of the DrawingCanvas and added to the Screen.

The square drawing was the most complicated out of the two because of the animation effect of dragging the edge of the square to any size we feel that is needed, all the while keeping the square shape. This drawing also uses the temporary visuals collection to get its animation effect. The process that is followed when creating a square:

  1. Click the Create Square menu item.
  2. Click and drag your mouse on the Screen to draw the square.
  3. Exit the drawing process and have the Square visual added by letting go of the left mouse button.

Here is the CreateSquare menu item in XAML:

XML
<ToolBar Height="25"
      Margin="0,18,2,0"
      Name="toolBarMain" VerticalAlignment="Top"
      Grid.Column="1">
   <Button Name="btnCreateSquare" Content="Create Square" />
</ToolBar>

Set Square Tool:

C#
private void btnCreateSquare_Click(object sender, EventArgs e)<
{
    Engine.Draw.CurrentTool = AIDraw.DrawingTools.Square;
}

Invoking DrawSquare:

C#
//Create a Square
public void DrawSquare(Point ToPoint)
{
    if (cleanupFirstVisual)
        drawScreen.DeleteVisual(visual, true);

    visual = new DrawingVisual(); 

    using (DrawingContext dc = visual.RenderOpen())
    {
        Brush brush = drawingBrush;

        dc.DrawRectangle(null, drawingPen, new Rect(fromMousePoint, ToPoint));
    }

    drawScreen.AddVisual(visual, true);
    grabLastVisual = visual;

    if (!cleanupFirstVisual)
        cleanupFirstVisual = true;

    toMousePoint = ToPoint;
}

DrawSquare is very different compared to DrawActor. This is because now we have to add temporary visuals so that when we are dragging the square, we delete the previous visual in the collection. If we do not delete the previous visual, we will see countless squares being drawn every time we move the mouse.

While the mouse left button is down and we are moving the mouse (to drag the square size), this event is activated:

C#
private void drawingScreen_MouseMove(object sender, EventArgs e)
{
    Point position = MousePosition;
    if (drawsquare) 
    { 
        DrawSquare(MousePosition); 
    } 
}

Now while this is all happening, we are adding to the DrawingCanvas temporaryvisual collection and deleting them periodically. However, now we need an event for when the left mouse button is up, so we can now add the final visual to the main visual collection, and the temporary visual collection is completely cleared:

C#
private void drawingScreen_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{ 
    cleanupFirstVisual = false; 

    if (drawsquare) 
    { 
        drawsquare = false; 
        drawScreen.startTempVisual = false; 
        drawScreen.DeleteVisual(visual, true); 
        drawScreen.AddVisual(grabLastVisual, false); 
        CurrentTool = DrawingTools.Arrow; 
    } 
}

What if we want to cancel our drawing of the square while dragging it? We simply check the right mouse button click event to delete the current visual being drawn:

C#
private void drawingScreen_MouseRightButtonDown(object sender, MouseButtonEventArgs e)
{ 
    drawScreen.DeleteVisual(visual, true); 
}

Now, that was a basic overview of some of the WPF controls in this application.

Using the application (experimenting with IronPython in WPF)

The application presented here is mainly based on experimenting with IronPython in the Windows Presentation Framework. It's set up so you can import an Actor Python script and a Scene Python script. The difference between the two is that the Actor script is initialized once and the Scene script is initialized in every frame. In the game loop, the Scene script is rendered in every frame which invokes the DispatcherTimer. The DispatcherTimer allows you to control the intervals of how fast you want your Scene to render, like Frames Per Second.

I've also included some simple samples of Python code in the PythonSamples folder of the zip file. I'll briefly explain the use of these samples.

Drawing Visual sample

The DrawingVisual sample in the DrawingVisual folder is a very easy, straightforward way of drawing using System.Windows.Media.Visuals and System.Windows.Media.DrawingContexts in Python.

For starters, let's go into the C# code and see what is needed in order for Python to complete the drawing procedure.

We need to set our DrawingCanvas class in the IronPython globals:

C#
DrawingCanvas drawScreen; 

ScriptEngine.Globals.Add("drawScreen", drawScreen);

And, that is it for the C# code, now onto the Python code.

We need to declare our Actor.py code. This code will be initialized only once, unlike the Scene.py file:

Python
//Actor.py
visual = System.Windows.Media.DrawingVisual()
context = System.Windows.Media.DrawingContext
pen = System.Windows.Media.Pen(System.Windows.Media.Brushes.Purple, 3)
brush = System.Windows.Media.Brush

brush = System.Windows.Media.Brushes.Blue

context = visual.RenderOpen()
context.DrawRectangle(brush, pen, System.Windows.Rect(Point(5,50), Point(400,400)))
context.Close()

drawScreen.AddVisual(visual, False)

The pens and brushes are simple properties provided by the System.Windows.Media namespace. The visual and context are the defining parts of drawing an object into our DrawingCanvas. After our declarations, we start visual.RenderOpen() to say we are going to begin creating our visual. We then use context.DrawRectangle to initialize our new Rectangle by inputting our properties that were declared. After the DrawRectangle declaration, we close the context, so we can stop drawing the new visual. The visual is then added to our drawScreen, which is the DrawingCanvas of our Screen. We also set false as our second parameter in drawScreen.AddVisual because we do not want the visual to be drawn as a temporary visual.

The Scene.py is left blank, because we do not need the visual to be constantly rendering in the game loop in order to see it on screen.

Now, to see the drawing visual in action. Start up the application and click the Create Actor toolbar item, and select where you want your Actor to be created. Then, click on the Script menu item and select Import. Select Scene.py and Actor.py from the PythonSamples/DrawingVisuals folder. After doing so, go into the TreeView, and under Scene, you should see the Script node, and under that should be a combo box. Select Scene.py in the combo box, and do the same for the Actor with Actor.py. After the Scene and Actor have a script selected, click Play in the toolbar, and you should see a box drawn.

Input sample

The Input folder of PythonSamples includes an example of adding key input communication between IronPython and C#. In order to pass a key event to IronPython, I will have to first create a Key variable in the IronPython globals:

C#
Key input = new Key();
ScriptEngine.Globals.Add("key", input);

After that has been set, we need to create a key event in the MainWindow to set the key variable in the ScriptEngine whenever a key is down:

C#
private void MainWnd_KeyDown(object sender, KeyEventArgs e)
{
    //Pass the key input to the ScriptEngine so it may be used
    AIEngine.ScriptEngine.Globals["key"] = e.Key;
}

Also, we need to keep track of whenever a key is up, so we know when to send a null key:

C#
private void MainWnd_KeyUp(object sender, KeyEventArgs e)
{
    //If no keys are being hit, send a null value to the PythonEngine
    AIEngine.ScriptEngine.Globals["key"] = null;
}

When you run the application, click Create Actor and create an Actor on the screen.

Now, since we have all of your C# code updating the key variable inside the ScriptEngine, all that's left is the Python code.

First off, let's declare our movement variable for our Actor in our Actor.py file:

Python
#Actor.py
actorPosX = 250
actorPosY = 250

These variables will be the default position of our Actor.

Lets create our Scene.py which will contain the key input checks. All we have to have is an if statement check for each key we want, and then increment the Actor's position if the key is clicked:

Python
#Scene.py

if (key == System.Windows.Input.Key.W):
    actorPosY -= 1

if (key == System.Windows.Input.Key.S):
    actorPosY += 1

if (key == System.Windows.Input.Key.D):
    actorPosX += 1

if (key == System.Windows.Input.Key.A):
    actorPosX -= 1

Actor9.ActorPosition = System.Windows.Point(actorPosX, actorPosY)

The Actor9.ActorPosition variable is a property in AIEditor.Core.AIActor that sets the Actor's position and redraws the visual to reflect the new position. As you can see, the Actor9 variable was not declared in the AIActor class or set at all. When we create an Actor in our scene, our Scene TreeView updates with the newly created Actor name, this name will be like Actor9, Actor14, etc. All of these Actors are already added to ScriptEngine.Globals through our normal Actor creation process. So, you may need to update the Actor9 name according to what your actor is named in your application.

You also might be wondering how we were able to use some of these System.Windows namespaces in our Python script. There is a function in PythonEngine that allows you to load assemblies:

C#
PythonEngine.LoadAssembly(Assembly assembly);

This allowed us to load System.Windows.Point:

C#
ScriptEngine.LoadAssembly(Assembly.Load("WindowsBase"));

Adding assemblies to your Python script is a huge benefit that will allow you create very well structured IronPython applications.

There is also an import namespace function:

Python
PythonEngine.Import(string namespace)

Now back to the main topic. When you run the application, you should create an actor first from the Create Actor menu item, and then you should import the Actor.py and Scene.py scripts from the PythonSamples folder by clicking the Script menu item and then Import. After both are imported, you should then go to your Actors node, and under it, there should be a TreeViewItem with a header much like Actor# (# being the number that was assigned to the actor). Expand the nodes until you get to the combo box for Script of the Actor#, then select Actor.py. After doing so, you must also select the Scene.py script in the Script node right below the Scene parent node. After all the Python files are selected, click the Play menu item. This will begin the game loop and compile the script. It will also pop up a message box if you have an error within your script. Now, clicking any of the W, S, A, D keys on your keyboard, you should see your Actor move through the Scene.

To see the drawing visual in action, start up the application and click the Create Actor toolbar item and select where you want your Actor to be created. Then, click on the Script menu item and select Import. Select Scene.py and Actor.py from the PythonSamples/DrawingVisuals folder. After doing so, go into the TreeView and under Scene, you should see the Script node, and under that should be a combo box. Select Scene.py in the combobox and do the same for the Actor with Actor.py. After the Scene and Actor have a script selected, click Play in the toolbar, and you should see a box drawn.

License

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


Written By
Unknown
This member has not yet provided a Biography. Assume it's interesting and varied, and probably something to do with programming.

Comments and Discussions

 
Generalinteresting article... Pin
Colin Grealy5-Nov-07 9:24
Colin Grealy5-Nov-07 9:24 
however it seriously needs formatting. The code samples are badly spaced and there are rogue html tags in them.

Also this line is very misleading:
A problem arises because there is no ending indication as there is with C#'s curly braces, so you are stuck putting one statement without any indication of another.
Python uses indentation to indicate scope. It is considered one of the languages strengths. It's easy to see any scoping in Python because of this. Adding a semi-colon at the end of each line is bad form.

<br />
<br />
if (a > b):<br />
    # indented: in the if block<br />
    a = 1;<br />
    b = 2;<br />
# back to last indentation level: end of if block<br />
elif (a < 3 and b > 3):<br />
    a = 2<br />
else:<br />
    b = 3<br />




Other than that, good stuff.

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.