Click here to Skip to main content
15,867,756 members
Articles / Desktop Programming / WPF
Article

Immerse Yourself in WPF: A "World Clocks" Application as Literate Code

Rate me:
Please Sign up or sign in to vote.
4.88/5 (39 votes)
16 Aug 200713 min read 114.7K   5K   111   18
Literate code describing how to build a simple "world clocks" application in WPF
Screenshot - WorldClocks1.png

Introduction

I've been trying to get myself up to speed with .NET 3 and 3.5 using Orcas recently, and rather than just "play" I've set myself a little application to write: a "world time" application that sits in the system tray and displays clocks for various timezones around the world. (It's a sufficiently simple application that I've been able to complete the first version of it in my spare time, such as I have any, but one that will also be useful to me and hopefully other MicroISVs).

I'm writing this as a little taster for any MFC or WinForms developers who haven't had much of a look at WPF yet. I'm going to write this in a kind of "literate programming" style — not complete code, but snippets that can be read with the prose. This isn't designed to be a WPF tutorial either; it's more like an intensive language course where you learn by immersing yourself in the native language.

The Basic Clock Face

Each clock is a graphical object (as opposed to say a flow-layout dialog), so we use a canvas:

XML
<!-- Clock.xaml -->
<Canvas Width="100" Height="100" x:Name="_canvas">
  <!-- + Background -->
  <!-- + Markers -->
  <!-- + Hands -->
  <!-- + Highlights -->
</Canvas>

The width and height I've set don't really matter since we can scale the clock to whatever size we like, but it means that my measurements inside the clock can be in percentages.

Starting with the background, I want a circle with a graded fill from top to bottom, surrounded by a white "glow". (Eventually this is going to pop-up as a desktop widget, so I want the clocks to have a border to distinguish them from the user's desktop).

XML
<!-- Clock.xaml, * Background -->
<Ellipse Canvas.Left="0" Canvas.Top="0" Width="100" Height="100">
  <Ellipse.Fill>
    <RadialGradientBrush>
      <GradientStop Offset="0.0" Color="White" />
      <GradientStop Offset="0.95" Color="White" />

      <GradientStop Offset="1.0" Color="Transparent" />
    </RadialGradientBrush>
  </Ellipse.Fill>
</Ellipse>
<Ellipse Canvas.Left="3" Canvas.Top="3" Width="94" Height="94">
  <Ellipse.Fill>

    <LinearGradientBrush StartPoint="0.4,0.1" EndPoint="0.6,0.9">
      <LinearGradientBrush.GradientStops>
        <GradientStop Offset="0.0" Color="#888888" />
        <GradientStop Offset="1.0" Color="#111111" />
      </LinearGradientBrush.GradientStops>
    </LinearGradientBrush>

  </Ellipse.Fill>
</Ellipse>

I originally used an "outer glow" bitmap effect, but under animation it wobbled a bit. So we now have:

Screenshot - WorldClocks2.png

That's the easy bit done. Markers next. Although the idea of WPF is to include the graphical elements in the XAML, for the little markers around the edge that would be silly — dozens of nearly identical elements says "loop" to me and for that we need code. The XAML is just a placeholder:

XML
<!-- Clock.xaml, * Markers -->
    <Canvas x:Name="_markersCanvas" />

And the actual elements are added in code:

C#
// Clock.xaml.cs
protected override void OnInitialized( EventArgs e )
{
    base.OnInitialized( e );

    for( int i = 0; i < 60; ++i )
    {
        Rectangle marker = new Rectangle();
        if( ( i % 5 ) == 0 )
        {
            marker.Width = 3;
            marker.Height = 8;
            marker.Fill = new SolidColorBrush( Color.FromArgb( 0xe0, 0xff, 
                0xff, 0xff ) );
            marker.Stroke = new SolidColorBrush( Color.FromArgb( 0x80, 0x33, 
                0x33, 0x33 ) );
            marker.StrokeThickness = 0.5;
        }
        else
        {
            marker.Width = 0.5;
            marker.Height = 3;
            marker.Fill = new SolidColorBrush( Color.FromArgb( 0x80, 0xff, 
                0xff, 0xff ) );
            marker.Stroke = null;
            marker.StrokeThickness = 0;
        }
        TransformGroup transforms = new TransformGroup();

        transforms.Children.Add( new TranslateTransform(-( marker.Width/2),
             marker.Width / 2 - 40 - marker.Height ) );
        transforms.Children.Add( new RotateTransform( i * 6 ) );
        transforms.Children.Add( new TranslateTransform( 50, 50 ) );

        marker.RenderTransform = transforms;

        _markersCanvas.Children.Add( marker );
    }
    for( int i = 1; i <= 12; ++i )
    {
        TextBlock tb = new TextBlock();

        tb.Text = i.ToString();
        tb.TextAlignment = TextAlignment.Center;
        tb.RenderTransformOrigin = new Point( 1, 1 );
        tb.Foreground = Brushes.White;
        tb.FontSize = 4;

        tb.RenderTransform = new ScaleTransform( 2, 2 );

        double r = 34;
        double angle = Math.PI * i * 30.0 / 180.0;
        double x = Math.Sin( angle ) * r + 50, y = 
            -Math.Cos( angle ) * r + 50;

        Canvas.SetLeft( tb, x );
        Canvas.SetTop( tb, y );

        _markersCanvas.Children.Add( tb );
    }
}

That's a fair bit of code, but it goes to show that there's nothing magical about XAML — it's just a convenient way of creating elements, and we can do the same in code, albeit in a slightly long-winded way. The markers are just rectangles; to position them I just position them at the top-center of the canvas and rotate around the center. I couldn't find a way to exactly position centered text on a canvas, so in the end I used the following technique:

  1. Set the text size to be half what you actually want;
  2. Position the top-left of the text where you want it centered;
  3. Set the transform origin to the the bottom-right;
  4. Scale by a factor of two.

We have the basic background now.

Screenshot - WorldClocks3.png

For me the most important thing in WPF compared to WinForms or MFC is something that isn't in this article, and indeed won't be because we just don't need it: there's no WM_PAINT or OnPaint handler. Everything I've done so far is done once &mdashl the XAML just "sits there", the OnInitialized method is called once — and thereafter WPF takes over.

The Clock Hands

There are three hands to the clock; hour, minute and seconds. I wanted the second hand to have a slightly different effect, so that's in a separate canvas. I'll talk more about that later.

XML
<!-- Clock.xaml, * Hands -->
<Canvas>
  <!-- + HourAndMinuteHandsEffect -->
  <!-- + HourHand -->
  <!-- + MinuteHand -->
</Canvas>
<Canvas>
  <!-- + SecondHandEffect -->
  <!-- + SecondHand -->
</Canvas>

Since each hand of the clock is basically the same, I'll just present the hour hand.

XML
<!-- Clock.xaml, * HourHand -->
<Rectangle Width="8" Height="36" Fill="White" Stroke="#333333"
    StrokeThickness="0.6" RadiusX="2" RadiusY="2">
  <Rectangle.RenderTransform>
    <TransformGroup>
      <TranslateTransform X="-4" Y="-32" />
      <RotateTransform Angle="{Binding HourAngle}" />
      <TranslateTransform X="50" Y="50" />
    </TransformGroup>
  </Rectangle.RenderTransform>
</Rectangle>

A hand is basically a rectangle, rounded off a little on the corners. Each rectangle starts off positioned at (0, 0), so we move it so that the zero point is at the "spindle", rotate it by the correct angle, then move it again to put it in the middle of the canvas. (There are alternative methods of positioning a rectangle, notably using the Left and Top attached properties of the canvas, but this is how I prefer to deal with it).

The rotation angle for each hand needs to be defined of course; I've bound the rotation to a property that doesn't exist yet, which is an error. This is an area I found myself repeatedly stumbling on; I kept writing my properties as simple getters and setters backed by a member variable, and then had to keep changing them to be WPF dependency properties.

C#
// Don't do this!!
public double HourAngle
{
  get
  {
    return _hourAngle;
  }
  set
  {
    _hourAngle = value;
  }
}
private double _hourAngle;

It seems to be a rule of thumb that if you're going to bind to something in WPF, it needs to be a dependency property, so save yourself some time and bite the bullet and code them that way from the start.

C#
// Clock.xaml.cs
static Clock()
{
  HourAngleProperty = DependencyProperty.Register
    ( "HourAngle", typeof( double ), typeof( Clock )
    , new FrameworkPropertyMetadata( 0.0,
    FrameworkPropertyMetadataOptions.AffectsRender ) );

  // Repeat for minutes and seconds...
}

public double HourAngle
{
  get
  {
    return (double) GetValue( HourAngleProperty );
  }
  set
  {
    SetValue( HourAngleProperty, value );
  }
}

It seems like more typing, it seems like it could be inefficient, it seems type-un-safe — but it pays dividends later. To re-iterate: if you want to bind a property in your WPF element tree, you need a dependency property.

Of course, we want the hands of the clock to move, and as it stands they're all pointing to twelve noon. There's two basic ways of achieving this: via animations, or via property assignments.

WPF has a very powerful animation system, so I tried this first to make the hands move. Essentially, each hand angle had a storyboard that changed the value from 0 to 360 over the course of twelve hours, one hour, or one minute. The initial angles were set to the time of day that the control was created.

Unfortunately, while the clock hands turned nicely, when I created a row of clocks the second hands were all at slightly different positions. I couldn't control the starting positions sufficiently accurately to make this a viable method.

The second working solution was simply to update the angles in a timer. Note that this most definitely isn't the normal way of specifying animations in WPF — animations should be declarative, just like your element tree — but I think you can divide data values into two kinds:

  • Those that are the substance of the display, i.e. actual data values — these need not be animation-based, and
  • Those that are the style of the display, eye-candy or look-and-feel — these should be animation-based.

I think the time is a real substance of a clock display, so I don't feel too bad about updating the angles in a timer.

C#
// Clock.xaml.cs
private void _timer_Tick( object sender, EventArgs e )
{
  DateTime date = GetDate(); // You need to supply this function.

  double hour = date.Hour;
  double minute = date.Minute;
  double second = date.Second;

  double hourAngle = 30 * hour + minute / 2 + second / 120;
  double minuteAngle = 6 * minute + second / 10;
  double secondAngle = 6 * second;

  HourAngle = hourAngle;
  MinuteAngle = minuteAngle;
  SecondAngle = secondAngle;
}

(That's a DispatcherTimer if you're interested).

Screenshot - WorldClocks4.png

We now have a working clock. I'm aware that this part of the article wasn't half as interesting as the first bit (which probably wasn't all that hot to begin with anyway), but there's always less interesting bits of any program, and calculating the angles of the hands of a clock is dull in any language.

One thing that was pretty neat is that the presentation of the clock hands (rounded rectangles in this case) is neatly separated from the substance of the hands' angles — so if I want my clock to be one of those with Mickey Mouse hands, I can hand the XAML over to a graphic designer with a copy of Expression Blend and it will all still work. (Or more likely, when people start asking for different themes, I can switch to using WPF's styling mechanism).

A Brief Diversion Into LINQ

I'm going to take a brief diversion now and look at some LINQ. Almost all applications need to store data somewhere, even if it's just where the user left their windows the last time they used it (you do store that, don't you?). My world clocks application is sufficiently simple that a little configuration file in the user's settings directory will suffice. For this article, I want to read in a list of timezones when the application starts, and write it out again on exit, and we'll assume the user has been able to modify that list by means of some sort of options dialog.

Firstly, a little aside: what I'm going to present here is suitable for my application. You might come back and tell me it's too slow, or unsuitable for large data files. That's fine; choose your method based on profiling if you need raw speed, or switch to a database (LINQ-to-SQL might help you there...), or whatever. There's no single technique or tool that is suitable for all situations, but I'm reading and writing a tiny file, so I think this is an acceptable technique.

Secondly, another little aside: everything I've covered so far with regards WPF is available in .NET 3, which is a fully released component. LINQ is contained within .NET 3.5, which is still in alpha. That would have been a factor that decided against LINQ (since ideally I would have wanted my application to be released soon), BUT I wanted to use the TimeZoneInfo class, and that's only available in .NET 3.5, so I figured I might as well use whichever .NET 3.5 features I wanted to.

OK, on with the code. Here's the format I'm going to use for my data file. Feel free to skip this section and the next if you're reading quickly.

XML
<?xml version="1.0" encoding="utf-8"?>
<Settings>
  <TimeInfos>
    <TimeInfo Zone="...serialized gobbledygook..." />
    <TimeInfo Zone="...serialized gobbledygook..." />
    <TimeInfo Zone="...serialized gobbledygook..." />
  </TimeInfos>
</Settings>

Nice and simple, and I can add other stuff later if I need to.

I'm storing my zone information in a wrapper class called TimeInfo, the important bits for this example are two static methods:

C#
public sealed class TimeInfo
{
  public static string Write( TimeInfo ti )
  {
    // ...
  }

  public static TimeInfo Read( string s )
  {
    // ...
  }
}

If you've used XPath before, the new features in LINQ-to-XML won't be too problematic. Here's how I read the config file:

C#
XDocument xDoc = XDocument.Load( filename );

_timeInfos.AddRange( from XElement t in xDoc.Descendants( "TimeInfo" )
                   select TimeInfo.Read( t.Attribute( "Zone" ).Value ) );

The nice thing here is the lack of loops (or at least, the lack of explicit loops). C# 1.0 gave us the foreach statement, which abstracted away the workings of a collection enumeration. C# 3.0 now lets us abstract away the loop itself, letting us select and transform elements from various data sources.

Writing the config file is almost as easy, and makes use of the new XElement classes. Previously you'd either have to create an XmlDocument, which was rather unwieldy, or make use of an XmlTextWriter and forgo automatic creation of the document structure (that is, forget to write out a closing tag and you end up with invalid XML).

C#
XDocument xDoc = new XDocument(
                   new XElement( "Settings",
                     new XElement( "TimeInfos",
                       from TimeInfo ti in _timeInfos
                       select new XElement( "TimeInfo",
                           new XAttribute( "Zone",
                           TimeInfo.Write( ti ) ) ) ) ) );
xDoc.Save( filename );

Those brackets on the end are a bit ugly though. I can use a temporary variable if I want to:

C#
var timeInfos = from TimeInfo ti in _timeInfos
                select new XElement( "TimeInfo", new XAttribute( "Zone",
                TimeInfo.Write( ti ) ) );
XDocument xDoc = new XDocument(
                   new XElement( "Settings",
                     new XElement( "TimeInfos", timeInfos ) ) );
xDoc.Save( filename );

The "var" keyword is new too — and let's clear up that confusion that everyone starts with — it's not weak typing. "Var" simply means "infer the type from the following expression", and in this case that type is:

C#
System.Linq.Enumerable.SelectIterator<timeinfo,system.xml.linq.xelement />

Whew! I'm not going to explain that because I don't understand it well enough, but the basic idea is that LINQ creates expression trees, which can be used in a variety of ways. LINQ-to-SQL, for example, translates those expressions into efficient SQL commands with WHEREs and JOINs.

LINQ has certainly saved me a lot of drudge work here. The reading code feels like a query, and my writing code is even indented like the XML file I want to produce, and feels more like a "template" for that file than a process for creating it. .NET 3.5 certainly has that feel about it — the feel that I declare the structures and form of what I'm trying to achieve, and the system takes care of the processing based on that form.

Visual Gloss, Animation, and Shaped Windows

We've got the basic clock working now, and also covered a really easy was of loading and saving configuration files. Now let's make the application a bit more glossy and animated, and make a shaped, borderless window.

Firstly, despite the shading, the clock's looking pretty flat and two-dimensional. I want the clock to look like it's actually made of something, with the frame casting an inner shadow at the top, and the plastic cover reflecting a bit of light. We can do this by overlaying a couple of semi-transparent ellipses over the top of the clock (since we want to change the lightness of everything in the clock). For these effects radial fills can be used to produce all kinds of curved shading.

XML
<!-- Clock.xaml, * Highlights -->
<Ellipse Canvas.Left="3" Canvas.Top="3" Width="94" Height="94">
  <Ellipse.Fill>
    <RadialGradientBrush Center="0.51,0.52" SpreadMethod="Pad">

      <GradientStop Offset="0.0" Color="Transparent" />
      <GradientStop Offset="0.9" Color="Transparent" />
      <GradientStop Offset="1.0" Color="#a0000000" />
    </RadialGradientBrush>
  </Ellipse.Fill>
</Ellipse>

<Ellipse Canvas.Left="3" Canvas.Top="3" Width="94" Height="94">
  <Ellipse.Fill>
    <RadialGradientBrush Center="0.7,0.8" SpreadMethod="Pad"
        RadiusX="1.3" RadiusY="1.2">
      <GradientStop Offset="0.0" Color="Transparent" />
      <GradientStop Offset="0.4" Color="#30ffffff" />
      <GradientStop Offset="0.5" Color="#60ffffff" />

      <GradientStop Offset="0.6" Color="#3fffffff" />
      <GradientStop Offset="1.0" Color="Transparent" />
    </RadialGradientBrush>
  </Ellipse.Fill>
</Ellipse>

The various centers and radii I produced by experimentation — with effects like these, I use vivid primary colours to get the shape of the effect right, then change to using the faint light and dark colours I actually want.

Screenshot - WorldClocks5.png

That's an improvement, but we can go one better than that. Let's make the hands stand out from the face a little. In real life, they'd cast a shadow on the clock face, and we can achieve a similar effect in WPF using a DropShadowBitmapEffect.

XML
<!-- Clock.xaml, * HourAndMinuteHandsEffect -->

<Canvas.BitmapEffect>
  <DropShadowBitmapEffect ShadowDepth="1" Softness="0.1" Opacity="0.5" />
</Canvas.BitmapEffect>

Just as before, I'm only going to show the effects for the hour and minute hands; the second hand shadow is pretty much the same, except I wanted a slightly different shadow.

Bitmap effects are one of those areas in WPF where we need to apply a little care. As the name suggests, they are bitmaps as opposed vectors like most of the elements in WPF. There's a few things we need to take care with:

  • Bitmap effects are rendered in software and thus performance needs to be considered. Sprinkling bitmap effects liberally around your application is a recipe for slowness.
  • The bitmaps produced shouldn't be transformed if at all possible. Rotating or scaling a bitmap will cause visual oddities. For this reason you'll see I haven't applied the effects to the hands themselves, since the hands rotate. Instead I've added the rotating hands to a canvas, and then applied the effect to that.
  • Bitmap effects have degrading effect on text. Applying a bitmap effect to a container that contains text will cause the text-rendering to drop down to using normal anti-aliasing, instead of clear-type.

Having mentioned and considered those points, let's see the difference:

Screenshot - WorldClocks6.png

It's a subtle effect, but I think it's an improvement, and it will make the clocks look even better when we start to zoom them in and out in an animation.

The first animation I want is to have each clock "zoom in" as the mouse passes over it, and zoom out again as the mouse leaves. Each individual clock is displayed within a "ClockDisplay" control, which includes the clock itself, plus the time and timezone text.

WPF can animate any dependency property (we discussed those earlier), either an existing one such as "Opacity" if you want your animated control to fade in and out, or one you define yourself. In this case I've chosen to define one myself; please note, however, that there are alternate ways of achieving the following effect without defining your own property.

C#
// ClockDisplay.xaml.cs
static ClockDisplay()
{
  ZoomProperty = DependencyProperty.Register
    ( "Zoom", typeof( double ), typeof( ClockDisplay )
    , new FrameworkPropertyMetadata( 1.0,
    FrameworkPropertyMetadataOptions.AffectsRender ) );
}

Next we need to hook the size of the clock to this property:

XML
<!-- ClockDisplay.xaml -->
<cx:Clock ...snipped... RenderTransformOrigin="0.5,1.3">
  <cx:Clock.RenderTransform>
    <ScaleTransform ScaleX="{Binding Zoom}" ScaleY="{Binding Zoom}" />

  </cx:Clock.RenderTransform>
</cx:Clock>

Finally of course we need to set up an animation. Animations in WPF are declarative as opposed to procedural — we set up the "intent" of the animation, and WPF takes care of applying it and rendering it. An advantage of using the built-in animation features is that WPF will also handle incomplete animations — if the user moves the mouse off the clock before the zoom animation has completed, the clock will animate back again correctly without the animations conflicting.

XML
<!-- ClockDisplay.xaml -->
<UserControl.Triggers>
  <EventTrigger RoutedEvent="Control.MouseEnter">
    <BeginStoryboard>
      <Storyboard>
        <DoubleAnimation Storyboard.TargetProperty="Zoom" To="2"
            Duration="0:0:0.5" FillBehavior="HoldEnd" />

      </Storyboard>
    </BeginStoryboard>
  </EventTrigger>
  <EventTrigger RoutedEvent="Control.MouseLeave">
    <BeginStoryboard>
      <Storyboard TargetProperty="Zoom">

        <DoubleAnimation To="1" Duration="0:0:0.5"
           FillBehavior="HoldEnd" />
      </Storyboard>
    </BeginStoryboard>
  </EventTrigger>
</UserControl.Triggers>

Simple. Essentially we trigger the first animation on MouseEnter, and the second on MouseLeave. Each affects the "Zoom" property, takes half a second to complete, and moves the zoom value either to x2 or x1 respectively.

Here we can see one of the clock's animations triggered by the mouse entering the ClockDisplay control:

Screenshot - WorldClocks1.png

Notice that CPU indicator — the clocks typically only consume around 2% of the CPU time, even when running as a shaped window. As you can see I also applied the same zoom factor to the time text.

And finally, to get that anti-aliased, per-pixel-alpha, shaped window effect — lots of code? Not in the slightest:

XML
<Window ...snipped... Background="#00000000" WindowStyle="None"
    AllowsTransparency="True" ShowInTaskbar="False">
  <!-- ...content... -->
</Window>

So there we go, a fairly attractive, animated clocks display with very little effort. There's plenty I haven't covered in this article — in particular the options dialog — but if you'd like to explore further then grab the source.

Updates

The latest version of the source will be kept here

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


Written By
Web Developer
United Kingdom United Kingdom
I'm currently working for a small start-up company, BinaryComponents Ltd, producing the FeedGhost RSS reader.

FeedGhost RSS Reader:
http://www.feedghost.com

Bespoke Software Development
http://www.binarycomponents.com

Comments and Discussions

 
QuestionNothing Show-up Pin
Larry36913-Apr-12 6:10
Larry36913-Apr-12 6:10 
Questionis it necessary for define a class "SingleInstanceWindow"? Pin
Sparkling_ouc4-Aug-11 23:54
Sparkling_ouc4-Aug-11 23:54 
GeneralThanks Pin
zhujinlong198409138-Jul-10 20:13
zhujinlong198409138-Jul-10 20:13 
QuestionAbsolutely love it! But how do we display more than one time zone? Pin
Mike74914-Feb-10 7:51
Mike74914-Feb-10 7:51 
GeneralVery nice ... one question though Pin
yannlh7-Sep-09 6:31
yannlh7-Sep-09 6:31 
What is the licensing on this? Do you allow your code to be used in a commercial or non-commercial app? With what restrictions / rules if any?
QuestionUse at commercial product? Pin
Oleksiy Vasylyev23-Jul-09 2:48
Oleksiy Vasylyev23-Jul-09 2:48 
GeneralThrowing exception Pin
Sriman Bapatla21-May-08 10:37
Sriman Bapatla21-May-08 10:37 
GeneralVS2008 Beta 2 issues Pin
indyfromoz4-Nov-07 17:33
indyfromoz4-Nov-07 17:33 
GeneralRe: VS2008 Beta 2 issues Pin
Stu-Smith4-Nov-07 23:22
Stu-Smith4-Nov-07 23:22 
GeneralRe: VS2008 Beta 2 issues Pin
Jaime Olivares26-Nov-07 15:34
Jaime Olivares26-Nov-07 15:34 
AnswerSOLUTION TO THE INSTALLATION PROBLEMS Pin
Jaime Olivares26-Nov-07 15:36
Jaime Olivares26-Nov-07 15:36 
GeneralI like it Pin
Sacha Barber17-Aug-07 3:59
Sacha Barber17-Aug-07 3:59 
GeneralRe: I like it Pin
Stu-Smith17-Aug-07 5:17
Stu-Smith17-Aug-07 5:17 
GeneralRe: I like it Pin
Sacha Barber18-Aug-07 7:12
Sacha Barber18-Aug-07 7:12 
GeneralLooks great Pin
ESTAN16-Aug-07 12:02
ESTAN16-Aug-07 12:02 
AnswerRe: Looks great Pin
Stu-Smith16-Aug-07 23:37
Stu-Smith16-Aug-07 23:37 
NewsRe: Looks great Pin
Stu-Smith17-Aug-07 0:49
Stu-Smith17-Aug-07 0:49 
GeneralRe: Looks great Pin
ESTAN18-Aug-07 9:09
ESTAN18-Aug-07 9:09 

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.