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

The Spectre Framework

, 11 Dec 2012
Rate this:
Please Sign up or sign in to vote.
The Spectre Framework is an attempt to introduce HTML5 as a first class citizen UI language for CLR based applications.

Introduction

The Spectre Framework aims to combine all attributes of web and desktop applications into a single program. Based upon the .NET Framework and the Chromium Browser it enables us to create our UI using plain HTML5 in its native environment, just like creating a web page, while simultanous allowing us to extend the HTML/JavaScript runtime using managed code, thus harvesting all positive aspects of the CLR Runtime.

With this article I wanted to share the current state and bring my findings to the public, hoping for some feedback. At this point the entire project is merely a product of my curiosity, there are still many open questions and issues to be resolved, it is however stable enough on the Windows platform to give it a try.

Download source and dependencies from GitHub 

Background & Motivation

As it is with most industries, competition and rivalry is not a stranger in the IT business.
Large corporations have spent fortunes mocking each other and each other's products and technologies.
It is therefor very refreshing to observe different corporations competing by means of reaching a neutral, but common goal. I am of course talking about HTML, specifically in its latest draft #5.

Major corporations, such as Google, Apple, Microsoft and many more are constantly trying to raise the bar of their products in order to conform to the guidelines set up by the HTML5 standard.
Of course there are also points of confrontation and debate, such as the video tag, I am however confident, that these issues will be eventually resolved.

With almost the entire IT industry embracing a single technology, I dare to say, it will eventually trump any competing product on the market, if it hasn't already done so. Further more, it may even move into unexpected segments as it grows and matures, which is one aspect of this article and the underlying program.

Just a couple of years ago, before the HTML5 draft, as Flash was still the shining star of the internet, probably no one would have dared to even conceive the notion of using html as a front end for a desktop application, the capabilities were limited at best. Today, however, despite the draft being even finished, tools and technologies emerged, which allow us to take the next step.

I am not the first to make this remark, probably not the last and perhaps even a little late, since we have recently witnessed the official release of an operating system allowing for the creation of HTML5 based desktop applications, namely Windows 8. Most people, I know not all, will probably agree with me if I state that UI development using HTML and CSS is a pleasent experience compared to most desktop widget or control libraries. An application, however, consists of more than just a nice UI, there is usually a non trivial degree of complex business logic attached to it. Unfortunately the only real "HTML scripting language" in town is Javascript and while Javascipt performs a marvelous job interacting with HTML, it is a poor choice, when creating robust, maintainable, complex, mid to large scale applications, which brings me to the second aspect of this article.

Since I am very fond of the CLR, I decided to exchange Javascript as the driver for the business application layer for the .NET Framework itself.

So, on the one hand we have HTML5 being a great technology for developing user interfaces and on the other we have the CLR, which, regardless of implementation, be it .NET or Mono, is fully qualified for being the base of a highly complex, robust and maintainable application design.

This project is about bringing these two worlds together, not by creating some kind of incompatible hybrid, but by creating a bridge, thus preserving all aspects of each side to the fullest.

Overview

Before actually diving into the code I'd like to present a small cross section of the application, for it will make things easier to understand and perhaps even answer some important questions in advance, which may arise later.

Since I am merely a single person with an average day length of 24 hours, I didn't have the time to reinvent the wheel. Although the Spectre Framework has one or two classes of its one, it is mainly comprised of external applications and libraries.

Chromium

The probably largest and most important component is the actual HTML renderer, which is the Chromium browser itself. The choice was simple, for it is open source, extremely fast, reliable, cross platform with a huge community and second to none in supporting HTML5.

This choice shaped the application more than any other, because we therefor inherit many of the browsers traits, some by choice and some by choicelessness. The property with the most severe impact however, which by the way falls under the choicelessness section, is the browser's intrinsic multi process architecture.

Figure 1

As can be seen in figure 1, the basic chromium architecture consists of exactly one browser process and one or more render processes. A detailed description of the architecture can be found in the article about Multi Process Architecture. Although it is a complex construct, there is only one detail you need to keep in mind. Your code will run in two different processes and if you wish to communicate across these boundaries you will need to use the IPC channels provided by chromium. We will later look at an example illustrating the process.

Whether this counts as a blessing or a curse is for you to decide, for it does add complexity to the application as a price for stability and performance.

Additionally we receive the following features, almost for free:

I said almost for free, because we do pay a price in terms of size. Without your project contributing even a single line of code, Chromium and all of its dependencies will make it start at 50 MB flat with all features intact.
It is possible to reduce this size by waiving some features, such as debugging tools and codecs, but not by much, 40 MB is the best you'll get.

Chromium Embedded Framework

Despite all the positive attributes that come along with Chromium, there is one very important piece missing in the grand design. Chromium was never meant to be embedded, ... it has no API.

Fortunately the Chromium Embedded Framework takes care of that problem by exposing Chromium's internals by means of a C-based API. I'm not going to dwell on this, because, despite CEF being a marvelous and semi complex project, it's purpose requires no further explanation.

The CLR Runtime

The last external ingredient we need to complete our dish is a CLR Runtime, be it in form of the .NET Framework or the Mono Project makes no difference, both will work and I'll leave it at that.

The Spectre Framework

On top of it all sits the Spectre Framework. It's purpose is to expose access to all native components in a way, which adheres to the design guidelines for managed class libraries, thus creating the impression of coding against a fully managed framework. The second purpose is to hide most of the duties that come with native code, such as P/Invoke or manual reference counting.
The framework currently has a total of three output libraries:

  • Crystalbyte.Spectre.Framework.dll
  • Crystalbyte.Spectre.Projections.dll
  • Crystalbyte.Spectre.Razor.dll
While the Projections library contains the raw P/Invoke binding declarations, such as structs, delegates and factory classes, the Framework assembly organizes these into object orientated, .NET affine structures.
The razor assembly is optional, it simply adds Razor support to the application and is thus dependent on the System.Web.Razor.dll located in the libs directory, if you need or want it.

Using the framework

The entire purpose of this project was to create a framework which allows the rapid and easy development of desktop application using common, efficient and state of the art tools.
The first thing to do is to reference the Crystalbyte.Spectre.Framework.dll assembly. The razor assembly will be included later and the Projections assembly will be pulled along as a dependency, a direct reference is not necessary.

Getting started

In order to get started, we will need two classes and an interface, we will begin with the Bootstrapper located in the Crystalbyte.Spectre namespace.

If you open the samples directory for the windows plaform, you'll find several projects, illustrating different capabilities of the framework.

The Bootstrapper class

Figure 2

The Bootstrapper is an abstract class with the only purpose to arrange all bootstrap calls into the proper sequence. As can be seen in figure 2, the bootstrapper provides several overridable methods to configure and extend the startup routine.
There is a single abstract method, namely CreateViewports, that needs implementation.
Although the Viewport itself is not important here, its constructor arguments are. These arguments have the types IRenderTarget and BrowserDelegate. The following snippet shows the minimal implementation for a Bootstrapper.

namespace Crystalbyte.Spectre.Samples {
    public sealed class WinformsBootstrapper : Bootstrapper {
        protected override IEnumerable<Viewport> CreateViewports() {
            yield return new Viewport(
                new Window {StartupUri = new Uri("spectre://localhost/Views/index.html")},
                new BrowserDelegate());
        }
    } 
}  

You might have noticed, that there's something special about the startup uri.
The framework is capable of using all common schemes, such as file, http, https and many more, however, these schemes come with all their regular security restrictions in place. To avoid meddling with established implementations, I created a new scheme, which behaves similar to the file scheme, but lifts some restrictions in order to accomplish the goal. Every base uri for a desktop application page will therefor need to start with "spectre://localhost/".

The IRenderTarget interface

The IRenderTarget interface must be implemented by any control, you want to render to.
It is an interface, so it can be applied to any control, window or widget, regardless of technology, be it Winforms, Gtk or WPF. It exposes a handle and several window events, see figure 3.

Interface: IRenderTarget

Figure 3

The following snippet shows the IRenderTarget implementation for a WinForms form used in all the samples. The Handle property is missing for it is already exposed by the base class, apart from that, there's nothing mysterious about it.

namespace Crystalbyte.Spectre.Samples {
    public partial class Window : Form, IRenderTarget {
        public Window() {
            InitializeComponent();
        }
 
        #region IRenderTarget Members
 
        public Uri StartupUri { get; set; }
 
        public event EventHandler<SizeChangedEventArgs> TargetSizeChanged;
 
        public void NotifySizeChanged(Size size) {
            var handler = TargetSizeChanged;
            if (handler != null) {
                handler(this, new SizeChangedEventArgs(size));
            }
        }
 
        public event EventHandler TargetClosed;
 
        public void NotifyTargetClosed() {
            var handler = TargetClosed;
            if (handler != null) {
                handler(this, EventArgs.Empty);
            }
        }
 
        public event EventHandler TargetClosing;
 
        public void NotifyTargetClosing() {
            var handler = TargetClosing;
            if (handler != null) {
                handler(this, EventArgs.Empty);
            }
        }
 
        public new Size Size {
            get { return new Size(ClientRectangle.Width, ClientRectangle.Height); }
        }
 
        #endregion
 
        protected override void OnClosing(CancelEventArgs e) {
            NotifyTargetClosing();
            base.OnClosing(e);
        }
 
        protected override void OnClosed(EventArgs e) {
            NotifyTargetClosed();
            base.OnClosed(e);
        }
 
        protected override void OnSizeChanged(EventArgs e) {
            NotifySizeChanged(Size);
            base.OnSizeChanged(e);
        }
    }
}  

The BrowserDelegate class

The browser delegate is an abstract class, where all browser events will be delegated to.

Figure 4

As can be seen in figure 4, the class serves a similar purpose as a code behind class in WinForms. Although the purpose is trivial, there is an important fact to know about it. Any code executed inside this instance will be executed on the browser process, hence the name.

The final step is to run the bootstrapper, the framework will take it from there.

 namespace Crystalbyte.Spectre.Samples {
    internal static class Program {
        /// <summary>
        ///   The main entry point for the application.
        /// </summary>
        [STAThread]
        private static void Main() {
            var bootstrapper = new WinformsBootstrapper();
            bootstrapper.Run();
        }
    }
} 

Depending on the startup file, we will see something similar to this. The screenshot shows the video example playing the "Big Bug Bunny" trailer.

As mentioned above, we are not bound to render to a window, we can, as is demonstrated in the MultiView sample, render to a usercontrol instead. See the following screenshot.

Extending the Javascript Runtime

Although we are now able to render arbitrary HTML code inside a native window, the interesting part is still to come. In order to create a bridge, we need an easy way to make mutual calls between the JSR and the CLR. Most of the examples provided make use of this functionality, however, the Extensions project offers a more detailed implementation using both a synchronous and an asynchronous approach.

The Extension class

Since JavaScript is a classless language, the only way to implement new functionalty is to extend an already existing object, in our case the window object. In this tutorial, we are going to extend the runtime by a simple multiplication method, called mult, which takes two integers and returns their product. Since it is bad practice to directly add methods to the window object, we will store the function inside a container, named extensions.

To do that, the framework offers an Extension class, which we need to inherit from and implement.
The following code shows a possible implementation:

namespace Crystalbyte.Spectre.Samples.Extensions {
    public sealed class MultExtension : Extension {
        public override string RegistrationCode {
            get { return RegistrationCodes.Synthesize("extensions", "mult", "first", "second"); }
        }
 
        protected override void OnExecuted(ExecutedEventArgs e) {
            var first = e.Arguments[0].ToInteger();
            var second = e.Arguments[1].ToInteger();
            e.Result = new JavascriptObject(first * second);
            e.IsHandled = true;
        }
    }
}  

The RegistrationCode method returns a piece of code which tells the runtime, we want to add an extension. The RegistrationCodes.Synthesize method takes the names for the container, the function, the arguments and synthesizes a viable registration code from it, the details are not important. The OnExecuted method should be self explanatory, everytime we call extensions.mult(x, y) from JS it will be invoked. Obviously, a asynchronous version involves a little more code, but not more or less, than in any other environment. The sample project "Extensions" shows a viable implementation. 

Registering the Extension

The next step is to register this extension with the runtime, the bootstrapper has an overridable method to do just that. The following snippet illustrates the usage.

namespace Crystalbyte.Spectre.Samples {
    public sealed class WinformsBootstrapper : Bootstrapper {

        //...

        protected override IList<Extension> RegisterScriptingExtensions() {
            var extensions = base.RegisterScriptingExtensions();
            extensions.Add(new MultExtension());
            return extensions;
        }

        //...

    }
} 
We have successfully added a new function to the DOM, which can now be called from any Javascript function.
        function() {
            var product = window.extensions.mult(4, 5);
        }   

There is one additional thing I need to mention. All code executed in an extension is done so on the rendering process, which means, we can't communicate directly between an extension and code running on the browser process, such as the browser delegate. The next tutorial will show us, how to overcome this obstacle.

Using the multi process architecture

We have now been able to start a simple application and create an extension in order to call managed code from Javascript. The problem is, that the extension is running on a different process than the application itself, in order to communicate, we need to cross a process boundary. Luckily, this functionality is already implemented, however, before we can get started, we need to take a look at the Browser class first.

The Browser class

Figure 5

The browser class grants access to the current HTML/Javascript environment, which is split into Frames.
Although the Frame is a very important class, for it allows for most interactions with the environment, such as navigating, searching, executing javascript, etc ... for this example it is of no interest to us.

Sending IPC messages

What is of interest, however, is the SendIpcMessage method, which we will use to send a data stream from the render process to the browser process.

The usage is straight forward, the following snippet illustrates its usage from inside an extension's execute method.

namespace Crystalbyte.Spectre.Samples.Extensions {
    internal class ChangeWindowTitleExtension : Extension {
        public override string RegistrationCode {
            get { return RegistrationCodes.Synthesize("commands", "changeWindowTitle", "title"); }
        }
        protected override void OnExecuted(ExecutedEventArgs e) {
            var title = e.Arguments.First().ToString();
            if (string.IsNullOrWhiteSpace(title)) {
                // Chromium does not allow to send nothing over the wire, so we send the termination symbol instead.
                title = "\0";
            }
            var browser = ScriptingContext.Current.Browser;
            browser.SendIpcMessage(ProcessType.Browser, new IpcMessage("change-window-title") {
                Payload = title.ToUtf8Stream()
            });
            e.IsHandled = true;
        }
    }
}
 

The first argument sets the desired target, since we are running inside the Renderer, we want to send the data to the Browser. The second argument is the Message itself, it takes an arbitrary name for identification and a payload in form of a serialized stream. Since we are now able to send messages, we need to know where they are being sent to. As any call to the browser process it is routed to the BrowserDelegate. If you scroll up and take a look at figure 4, you'll find a small event handler called OnIpcMessageReceived, which will be invoked on the browser thread.

From inside the message handler, we can now safely access any code on the browser process. The sample included with this project implements a way to alter the window title by typing into a html input control.

namespace Crystalbyte.Spectre.Samples {
    internal class IpcBrowserDelegate : BrowserDelegate {
        private readonly Window _window;
        public IpcBrowserDelegate(Window window) {
            _window = window;
        }
        protected override void OnIpcMessageReceived(IpcMessageReceivedEventArgs e) {
            base.OnIpcMessageReceived(e);
            if (!e.Message.IsValid) {
                return;
            }
            var title = e.Message.Payload.ToUtf8String();
            if (_window.InvokeRequired) {
                _window.BeginInvoke(new Action(() => _window.Text = title));
            }
            else {
                _window.Text = title;
            }
        }
    }
}  

If we need to change direction by sending a message from the browser to the renderer, we can do it the same way, by accessing the current browser from the application running inside the browser process. It should come to no surprise to anyone, that there is a counterpart to the BrowserDelegate on the renderer's side, namely the RenderDelegate. Any IPC messages sent from the browser to the renderer will be delegated to it. We can feed the spawned processes with a custom RenderDelegate by overriding the CreateRenderDelegate method inside the Bootstrapper.

namespace Crystalbyte.Spectre.Samples {
    public sealed class WinformsBootstrapper : Bootstrapper {
        //...
 
        protected override RenderDelegate CreateRenderDelegate() {
            return new MyCustomRenderDelegate();
        }
 
        //...
    }
}   

As you might have noticed, there is no way to send a message directly to a single renderer, it is essentially a broadcast to all, additionally, renderers cannot communicate with each other directly, all communication must go through the browser process.

Razor

By assuming the roles of both the client and the server on the desktop, the burden of hosting the page falls onto us. Today it is no longer common to host static HTML, most pages on the web are generated dynamically, so I wanted to retain this property for the desktop.

After stumbling across a blog entry from Rick Strahl, you can read the full article here, I chose to include a simple Razor parser to the project. Based upon Rick's project and with his blessings I incorporated his code into the framework, enabling us to create our HTML pages dynamically using the Razor syntax.

Since I chose to make this an optional feature, it requires us to reference the previously mentioned Crystalbyte.Spectre.Razor.dll assembly to out code.

At this point I believe it is important to note, that I chose not to include the entire MVC stack into the project. I wanted to keep the application as simple as possible, therefor only a basic version of the MVC workflow has been implemented.

If you are familiar with an MVC application, you'll note the following differences.

  • You must manually register controllers.
  • Every controller has only a single entry point and route.
Now, in order to get started we need to register the razor data provider at the framework, this can be done inside the Bootstrappers RegisterSchemeHandlerFactories override.
namespace Crystalbyte.Spectre.Samples {
    public sealed class WinformsBootstrapper : Bootstrapper {
        //...
        protected override IList<ISchemeHandlerFactoryDescriptor> RegisterSchemeHandlerFactories() {
            var descriptors = base.RegisterSchemeHandlerFactories();
            var spectre = (SpectreSchemeHandlerFactoryDescriptor)
                          descriptors.First(x => x is SpectreSchemeHandlerFactoryDescriptor);
            spectre.Register(typeof (RazorDataProvider));
            return descriptors;
        }
        //...
    }
}  

This will enable the framework to extend the search if a resource file couldn't be resolved using the given path, which brings us to the point of registering a Controller and its route.

namespace Crystalbyte.Spectre.Samples {
    public sealed class WinformsBootstrapper : Bootstrapper {
    //...
    protected override OnStarting(object sender, EventArgs e) {
            ControllerRegistrar.Register(typeof (HomeController));
            base.OnStarting(sender, e);
    
        }
    //... 
    }
}  

The Controller itself is trivial to implement, for it lacks most features of its MVC counterpart.

namespace Crystalbyte.Spectre.Samples.Controllers {
    public sealed class HomeController : Controller {
        //...

        public override ActionResult Execute() {
            return View(new HomeModel());
        }

        //...
    }
} 

Since I only wanted a quick implementation, there are several hard coded aspects to the usage.

  1. All controllers must be inside a Controllers directory.
  2. All Routes have the form, and only the form: "spectre://localhost/Controllers/<name>"("spectre://localhost/Controllers/Home")
  3. All Views must have the same name as the Controller (HomeController => HomeView)
  4. All Views must be inside the Views directory.
  5. There is currently no support for partial views.
All these restrictions can be attributed to my lack of time, there is nothing preventing anyone, extending or completely rewriting the code to lift them.

The view from the sample is fairly trivial, but it is enough to see how it works.

@using Crystalbyte.Spectre.Samples.Support
@inherits  Crystalbyte.Spectre.Razor.Templates.RazorFolderHostTemplate
               
@{
    var model = (Crystalbyte.Spectre.Samples.Models.HomeModel) Context;
}
<html lang="en">
    <head>
        <meta charset="utf-8" />
        <title>Spectre - Razor Sample</title>
        @foreach (var style in model.Styles) {
            @Style.Link(style)
        }
    </head>
    <body>
        <div class="content" >
            <div>
                @Html.Header(model.Header)
            </div>
            <div>
                @Html.Span(model.Description)
            </div>
            <footer>
                <span>created on: @model.CreationDate.ToShortDateString()</span>
            </footer>
        </div>
        @foreach (var script in model.Scripts) {
            @Script.Reference(script)
        }
    </body>
</html>  

If we now navigate to the controller, we will, hopefully, see the Razor generated page.

Cross platform support

The choice of libraries and tools was not entirely arbitrary. It was always the intention to make the framework available on all important platforms, namely Windows, Linux and OS X. While there is still no support for OS X, there is already a prototype running on Linux using Mono instead of the .NET Framework, it is however, highly unstable. Unfortunately my time is limited and to be frank, so is my knowledge of the UNIX platform. If someone has some useful suggestions, feel free to speak up.

Deploying the project

While the managed part is easy to build, it depends on several native libraries. Although compiling those from scratch is the recommended way, I realize that compiling Chromium itself is not something, that can be done in 5 minutes. For all of you, who do not wish to build Chromium/CEF from scratch, a zip file with the name spectre_redist_x86_windows_release.zip containing a binary build of all dependencies is located inside the lib directory.

Unfortunately the redistributable package is too large to be hosted here, so keep in mind, that the source you are downloading from codeproject is incomplete, it will compile but not run, you will need to get the missing binaries from GitHub directly or build Chromium yourself. 

If you build Chromium, you will need to create an environmental variable, called CHROMIUM_SRC pointing to the chromium source directory, Visual Studio post build scripts from the sample projects  will automagically fetch all necessary files from the chromium output directory.

If you decide to merely deploy the binaries you need to copy them into the output directory manually and comment out the copy scripts.

In the end your output directory should look something like this, the highlighted items make up the native dependencies.

The deployment process can be a little tedious for the first time user, if something does not work upfront, please let me know. 

Conclusions

Although it was pretty tough, getting all that seemingly incompatible stuff to work together, I believe it shows, that it is quite possible utilizing the capabilities of HTML in order to create desktop based applications, that feel and behave as such. I am not sure how long it will take or if it will happen at all, but I believe that we will see the web and the desktop world merging at somepoint in some kind of hybrid OS, half way between Windows and Chromium-OS.

I hope it was more interesting to read, than punching pixels to make it look good Wink | <img src=. Looking forward to any comments you might have.

License

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

About the Author

Alexander Wieser
Software Developer Crystalbyte
Germany Germany
I took my first C++ class when I was 12, unfortunately pointer arithmetics don't go hand in hand with small children.
While studying for my bachelor in informatics, I'm currently freelancing at a small software company with focus on the .NET Framework.
 
A Bro must always post bail for another Bro, unless it's out of state or, like, crazy expensive.
 
Crazy Expensive Bail > (Years You've been Bros) * $100
 
Alexander Wieser
Germany

Comments and Discussions

 
GeneralJust Awesome! PinmemberKaveh Shahbazian19-Jan-14 9:12 
GeneralMy vote of 5 PinmvpFlorian Rappl13-Sep-13 8:01 
GeneralMy vote of 5 PinmemberAnand Ranjan Pandey19-Jan-13 21:14 
GeneralRe: My vote of 5 PinmemberAlexander Wieser21-Jan-13 6:45 
QuestionIncredibly Interesting PinmvpDave Kerr16-Jan-13 11:31 
What you've done with this project is fascinating, as in many organisations we're currently thinking hard about the future and how we're going to develop our applications. Most of us are of the opinion that we want to invest more in HTML5 for our front-end stuff, hoping that it's going to be the most future-proof and widely compatible, but are loath to loose the codebases we've got and functionality offered by the CLR.
 
This is a very interesting way of potentially solving this problem - I can see a genuinely compelling argument (at least with some of the products I'm working with at the moment) to look into a system like this to allow us to transition some of our advanced UI stuff (typically charts and a load of animation with them) to HTML5 (which could then be re-used by our new iPad and tablet apps) but keep our core business logic in C#.
 
With all of the fuss that is being made over things like MVVM, in theory many apps should be decoupled enough from their user interface to allow a transition such as this to happen. I think it'd be really interesting to take a look at the framework from the point of view of an existing app (WPF/Silverlight or WinForms) and how you can replace at least segments of the UI experience with HTML5.
 
Anyway, brilliant article and I'll keep following the project with great interest!

AnswerRe: Incredibly Interesting PinmemberAlexander Wieser21-Jan-13 4:11 
QuestionAwesome! PinmemberZac Greve11-Dec-12 6:43 
AnswerRe: Awesome! PinmemberAlexander Wieser12-Dec-12 6:45 
QuestionWhere can we download the source code for this article, could you put a link to it within the article PinmvpSacha Barber11-Dec-12 4:25 
AnswerRe: Where can we download the source code for this article, could you put a link to it within the article PinmemberAlexander Wieser11-Dec-12 5:03 
GeneralRe: Where can we download the source code for this article, could you put a link to it within the article PinmvpSacha Barber11-Dec-12 23:32 

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
Web02 | 2.8.140709.1 | Last Updated 11 Dec 2012
Article Copyright 2012 by Alexander Wieser
Everything else Copyright © CodeProject, 1999-2014
Terms of Service
Layout: fixed | fluid