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

Silverlight Cairngorm - Port Cairngorm to .NET

, 6 Oct 2008
Rate this:
Please Sign up or sign in to vote.
Port Cairngorm 2.2.1 to Silverlight 2 Beta 2. Includes all source code and a sample application.
SilverlightCairngorm.jpg

Introduction

Currently, there's no official MVC/MVP/MV-VM frameworks for Silverlight. When developing enterprise applications, or building a large scale LOB application in Silverlight, client side architecture becomes important for "development salability". Although there is some guidance or frameworks for WPF, none of them can be easily applied to Silverlight. Adobe's Cairngorm has been broadly used in the Flex RIA application since 2006; it has easy-to-understand concepts, well-recognized design patterns, and has proved to work well to scale large line-of-business applications' development. This article describes the efforts of porting Cairngorm to Silverlight (Beta 2) in Visual Studio 2008 SP1, provides details about which concepts/classes have been adopted and what has been dropped, and also includes a sample application to demonstrate how it works and how it's intended to use. It helped me a lot to create a Silverlight prototype at work for a potential large scale consumer-oriented financial application; wish this effort would be useful to other Silverlight developers.

Brief on Cairngorm

Cairngorm is the lightweight micro-architecture for Rich Internet Applications built in Flex or AIR developed by Adobe Consulting. Its target applications are Enterprise RIA or medium to large scale LOB RIA. As detailed in the Introducing Cairngorm document, the major benefits of Cairngorm are:

  • Adding new features or changes to existing features are easier: new features can be "plugged in" by adding a new View, Model, Event, Command, and Delegate (note: not .NET Delegate, it refers to Cairngorm Delegate) without changing/affecting other features
  • Enables agile team development process: designers (Views), front end developers (Model, Events, Commands, Delegates, Data Binding), and data-service developers (Web Services) can work in parallel
  • Easier maintenance and debugging, also easier unit-test business logic codes.

Cairngorm helps developers to identify, organize, and separate code based on its roles/responsibilities; at the highest level, it has the following primary components:

  • Model holds data objects and the state of the data via ModelLocator
  • Controller handles Cairngorm Events and executes the corresponding Command class via FrontController
  • Commands are non-UI components that process business logic, it usually implements both the ICommand and the IResponder interfaces
  • Events are custom events that trigger business objects (i.e. Commands) to start processing, normally raised by a View's event (application events, user input events, etc.) handlers
  • ServiceLocator is a repository of pre-configured client/server communication components
  • Cairngorm Delegates are classes that know how to communicate with Web Services and route Result and Fault events to a Command via the IResponder interface
  • Views renders Model's data and communicates with the Controller using Events, it also monitors Model data changes by Data Binding

More on Cairngorm can be found at Cairngorm Developer Documentation.

What's Changed in the Silverlight Cairngorm

When implementing Cairngorm for Silverlight, all primary concepts/components are preserved except ServiceLocator. Details follow:

1. No need for ServiceLocator, using WebClient in Delegate Directly

Since in Silverlight, the WebClient class and the generated service proxy are used to interact with services in an asynchronized and strong-typed way, it's more convenient for Cairngorm Delegate objects to instantiate a WebClient or a generated service proxy and use them directly, it eliminates the need for the ServiceLocator.

However, in the source code, I still include a C# definition for the IServiceLocator interface and an abstract C# class for the ServiceLocator, you can find them in the Business sub-folder ---- if you ever need them.

[Update Notes 9/14/2008] A thread-safe implementation of Silverlight Cairngorm v0.0.0.1 has removed ServiceLocator and IServiceLocator interface from the source code, it can be downloaded in another article here.

2. ModelLocator becomes abstract and implements INotifyPropertyChanged

The implementation of the INotifyPropertyChanged interface in the abstract ModelLocator frees the derived application ModelLocator (in the sample app, it's the SilverPhotoModel class) to implement the interface, and makes sure the derived Model is "bindable" to the View (XAML). I really wish future versions of Silverlight would have something like the [Bindable] attribute just as the Flex ActionScript code does, then developers don't need to worry about the INotifyPropertyChanged interface and call NotifyPropertyChanged("PropertyName") in all the setter methods, it's a job more suitable for the compiler. Then, designing the Model and making it bindable will be much easier.

public abstract class ModelLocator : INotifyPropertyChanged
{
    #region INotifyPropertyChanged Members

    public event PropertyChangedEventHandler PropertyChanged;
    protected void NotifyPropertyChanged(string propertyName)
    {
        if (PropertyChanged != null)
        {
            PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
        }
    }

    #endregion
}

The application Model class will derive from the ModelLocator listed above. The derived class will implement the Singleton pattern and will also need to call NotifyPropertyChanged for the public bindable property's setter methods. You'll see an example in the sample application.

[Update Notes 9/14/2008] A thread-safe implementation of ModelLocator and INotifyPropertyChange interface is in another article here.

3. FrontController becomes abstract and CairngormEventDispatcher becomes internal

Making CairngormEventDispatcher internal will actually simplify the application code to raise a Cairngorm event: it is forced not to access CairngormEventDispatcher; instead, just instantiate a CairngormEvent object, then call its Dispatch method. CairngormEventDispatcher is only internal in the Cairngorm assembly, the application does need to care about it.

namespace SilverlightCairngorm.Control
{
    /// <span class="code-SummaryComment"><summary></span>
    /// Used to dispatch system events, by raising an event that the
    /// controller class subscribes to every time any system event is
    /// raised.
    /// Client code has no need to use this class. (internal class)
    /// <span class="code-SummaryComment"></summary></span>
    internal class CairngormEventDispatcher
    {
        private static CairngormEventDispatcher instance;

        /// <span class="code-SummaryComment"><summary></span>
        /// Returns the single instance of the dispatcher
        /// <span class="code-SummaryComment"></summary></span>
        /// <span class="code-SummaryComment"><returns>single instance of the dispatcher</returns></span>
        public static CairngormEventDispatcher getInstance()
        {
            if ( instance == null )
                instance = new CairngormEventDispatcher();

            return instance;
        }

        /// <span class="code-SummaryComment"><summary></span>
        /// private constructor
        /// <span class="code-SummaryComment"></summary></span>
        private CairngormEventDispatcher()
        {
        }

        /// <span class="code-SummaryComment"><summary></span>
        /// The subscriber to a system event must accept as argument
        /// the CairngormEvent raised (within a CairngormEventArgs\
        /// object)
        /// <span class="code-SummaryComment"></summary></span>
        public delegate void EventDispatchDelegate
		(object sender, CairngormEventArgs args);
        /// <span class="code-SummaryComment"><summary></span>
        /// The single event raised whenever a Cairngorm system event occurs
        /// <span class="code-SummaryComment"></summary></span>
        public event EventDispatchDelegate EventDispatched;

        /// <span class="code-SummaryComment"><summary></span>
        /// dispatchEvent raises a normal .net event, containing the
        /// instance of the CairngormEvent raised - to be handled by
        /// the Controller Class
        /// <span class="code-SummaryComment"></summary></span>
        public void dispatchEvent(CairngormEvent cairngormEvent)
        {
            if (EventDispatched != null)
            {
                CairngormEventArgs args = new CairngormEventArgs(cairngormEvent);
                EventDispatched(null, args);
            }
        }
    }
}

Cairngorm FrontController is preserved in the Silverlight Cairngorm:

namespace SilverlightCairngorm.Control
{
    /// <span class="code-SummaryComment"><summary></span>
    /// The system controller's parent, implementing the event-command
    /// relationship inner-workings, using a dictionary relating
    /// event names to commands.
    ///
    /// subscribes to the EventDispatched event of the CairngormEventDispatcher
    /// to handle all system events.
    /// <span class="code-SummaryComment"></summary></span>
    public abstract class FrontController
    {
        /// <span class="code-SummaryComment"><summary></span>
        /// The dictionary of eventNames and corresponding commands to be executed
        /// <span class="code-SummaryComment"></summary></span>
        private Dictionary eventMap = new Dictionary();

        public FrontController()
        {
            CairngormEventDispatcher.getInstance().EventDispatched +=
                new CairngormEventDispatcher.EventDispatchDelegate(ExecuteCommand);
        }

        /// <span class="code-SummaryComment"><summary></span>
        /// Whenever the CairngormEventDispatcher raises an event, this
        /// method gets the CairngormEvent inside it - and calls
        /// the execute() method on the corresponding ICommand
        /// <span class="code-SummaryComment"></summary></span>
        void ExecuteCommand(object sender, CairngormEventArgs args)
        {
            if (eventMap.ContainsKey(args.raisedEvent.Name))
            {
                eventMap[args.raisedEvent.Name].execute(args.raisedEvent);
            }
        }

        /// <span class="code-SummaryComment"><summary></span>
        /// register a Cairngorm event to FrontController
        /// <span class="code-SummaryComment"></summary></span>
        public void addCommand(string cairngormEventName, ICommand command)
        {
            eventMap.Add(cairngormEventName, command);
        }
    }
}

4. Added new abstract class: CairngormDelegate

All CairngormDelegate classes will derive from this class; it requires all derived classes to have a reference to IResponder.

namespace SilverlightCairngorm.Business
{
    public abstract class CairngormDelegate
    {
        protected IResponder responder;
        /// <span class="code-SummaryComment"><summary></span>
        /// A Constructor, Receiving an IResponder instance,
        /// used as "Callback" for the delegate's results -
        /// through its OnResult and OnFault methods.
        /// <span class="code-SummaryComment"></summary></span>
        protected CairngormDelegate(IResponder responder)
        {
            this.responder = responder;
        }
    }
}

5. Classes/Interfaces that were removed from SilverlightCairngorm

The IValueObject interface and the ValueObject type have been removed. Because, most likely, those data transfer objects will be generated.

ViewHelper and ViewLocator are also not in SilverlightCairngorm, because it's not a good idea to let model access view directly in data-binding scenario;

SequenceCommand is not ported, in the case of chained events/commands, a Cairngorm Event could be raised from the onResult method in the Command.

HTTPService, WebService, and RemoteObject are all Flex specific, and not included in SilverlightCairngorm.

Silverlight Cairngorm Sample Application

This sample application demonstrates how to use Cairngorm in a Silverlight application. If you have Silverlight 2 Beta 2 installed, you can run the app from here. It's very simple: to allow the user to type in a term to search photos using FlickR REST API, bind the search result to the left-hand navigation list, then automatically display the first image in the right hand side. Of course, when the user selects an image in the list, the display image will be updated.

1. Define the View in XAML

The auto-generated Page.xaml is extended to have the application layout, it has a simple text animation for the application title. It also references three UserControls in its layout markup. The idea is Page.xaml just provides an entry point and the application layout for the View. The actual functional Views are defined by the UserControl to accommodate potential UI design changes from the designer.

All the UserControls are defined in the View sub-folder and have SilverlightCairngormDemo.View as their namespace. PhotoSearch.xaml just has a textbox for the user to input the search term and a "Go" button to trigger the search photo action. PhotoList.xaml has a ListBox and an ItemTemplate to render the search result as a text (Photo's Title) list. PhotoSearch and PhotoList are stacked vertically in Page.xaml as the left-hand side navigation list.

ContentZone.xaml just contains an Image control to display the selected photo.

2. Define the SearchPhotoDelegate

Let's start to use SilverlightCairngorm from bottom to top. By deriving from CairngormDelegate, the SearchPhotoDelegate class is the only class that understands how to communicate with the FlickR REST API ---- sending a request and routing onResult and onFault calls to IResponsder:

namespace SilverlightCairngormDemo.Business
{
    public class SearchPhotoDelegate : CairngormDelegate
    {
        public SearchPhotoDelegate(IResponder responder)
            : base(responder)
        {
        }

        public void SendRequest(string searchTerm)
        {
//            SilverPhotoService svcLocator = SilverPhotoService.getInstance();
//            WebClient flickrService =
//                svcLocator.getHTTPService(SilverPhotoService.FLICKR_SEV_NAME);

            string apiKey = "[[You can get your API key for free from FlickR]]";
            string secret = "[[Yours goes here]]";
            string url = String.Format("http://api.flickr.com/services/rest/?" +
                         "method=flickr.photos.search&api_key={1}&text={0}",
                         searchTerm, apiKey, secret);

            WebClient flickRService = new WebClient();
            flickRService.DownloadStringCompleted +=
                new DownloadStringCompletedEventHandler(
                flickRService_DownloadStringCompleted);
            flickRService.DownloadStringAsync(new Uri(url));
        }

        private void flickRService_DownloadStringCompleted(object sender,
                                   DownloadStringCompletedEventArgs e)
        {
            if (null != e.Error)
                responder.onFault("Exception! (" + e.Error.Message + ")");
            else
                responder.onResult(e.Result);
        }
    }
}

3. Define the SearchPhotoCommand

SearchPhotoCommand implements the IResponder and ICommand interfaces. ICommand.execute will be invoked when the controller processes a Cairngorm event, and IResponder.onResult will be called from SearchPhotoDelegate when no exception occurs. IResponder.onFault handles exceptions and errors from SearchPhotoDelegate or invalid data:

namespace SilverlightCairngormDemo.Command
{
    public class SearchPhotoCommand : ICommand, IResponder
    {
        private SilverPhotoModel model = SilverPhotoModel.getInstance();

        #region ICommand Members

        public void execute(CairngormEvent cairngormEvent)
        {
            //get search term from model
            string toSearch = model.SearchTerm;

            //begin talk to web service
            SearchPhotoDelegate cgDelegate = new SearchPhotoDelegate(this);
            cgDelegate.SendRequest(toSearch);
        }

        #endregion

        #region IResponder Members

        public void onResult(object result)
        {
            string resultStr = (string)result;
            if (String.IsNullOrEmpty(resultStr))
            {
                onFault("Error! (Server returns empty string)");
                return;
            }

            XDocument xmlPhotos = XDocument.Parse(resultStr);
            if ((null == xmlPhotos) ||
                xmlPhotos.Element("rsp").Attribute("stat").Value == "fail")
            {
                onFault("Error! (" + resultStr + ")");
                return;
            }

            //update the photoList data in model
            model.PhotoList = xmlPhotos.Element("rsp").Element(
               "photos").Descendants().Select( p => new FlickRPhoto
                     {
                         Id = (string)p.Attribute("id"),
                         Owner = (string)p.Attribute("owner"),
                         Secret = (string)p.Attribute("secret"),
                         Server = (string)p.Attribute("server"),
                         Farm = (string)p.Attribute("farm"),
                         Title = (string)p.Attribute("title"),
                     } ).ToList<flickrphoto />();

            if (model.PhotoList.Count > 0)
                model.SelectedIdx = 0; //display the 1st image
            else
                onFault("No such image, please search again.");
        }

        public void onFault(string errorMessage)
        {
            //display the error message in PhotoList
            model.SelectedIdx = -1;
            model.PhotoList = new List<flickrphoto />()
              { new FlickRPhoto() { Title = errorMessage } };
        }

        #endregion
    }
}

[Update Notes 9/14/2008] An updated SearchPhotoCommand is provided in another article here, it uses thread-pool thread to perform XML parsing and data transformation to object collections to test the thread-safty of ModelLocator.

4. Define the SilverPhotoController

SilverPhotoController derives from FrontController, registers a specific event name with SearchPhotoCommand, and routes the event to the corresponding Command at runtime.

namespace SilverlightCairngormDemo.Control
{
    public class SilverPhotoController : FrontController
    {
        public const string SC_EVENT_SEARCH_PHOTO = "cgEvent_SearchPhoto";

        private static SilverPhotoController instance;

        /// <span class="code-SummaryComment"><summary></span>
        /// Returns the single instance of the controller
        /// <span class="code-SummaryComment"></summary></span>
        /// <span class="code-SummaryComment"><returns>single instance of the dispatcher</returns></span>
        public static SilverPhotoController getInstance()
        {
            if ( instance == null )
                instance = new SilverPhotoController();

            return instance;
        }

        /// <span class="code-SummaryComment"><summary></span>
        /// private constructor
        /// <span class="code-SummaryComment"></summary></span>
        private SilverPhotoController()
        {
            base.addCommand(SC_EVENT_SEARCH_PHOTO, new SearchPhotoCommand());
        }

    }
}

5. Define the Model

The first type in the Model is the FlickRPhoto class; it represents a photo data object coming back from the search.

namespace SilverlightCairngormDemo.Model
{
    public class FlickRPhoto
    {
        public string Id { get; set; }
        public string Owner { get; set; }
        public string Secret { get; set; }
        public string Server { get; set; }
        public string Farm { get; set; }
        public string Title { get; set; }
        public string ImageUrl
        {
            get
            {
                if (String.IsNullOrEmpty(Farm) || String.IsNullOrEmpty(Server) ||
                    String.IsNullOrEmpty(Id) || String.IsNullOrEmpty(Secret))
                    return null;

                return string.Format
			("http://farm{0}.static.flickr.com/{1}/{2}_{3}.jpg",
                                     Farm, Server, Id, Secret);
            }
        }
    }
}

Now, it's time to define SilverPhotoModel; it derives from ModelLocator and is implemented as a Singleton. Please also notice the NotifyPropertyChanged("PropertyName") calls, those are crucial for data binding.

namespace SilverlightCairngormDemo.Model
{
    public class SilverPhotoModel : ModelLocator
    {
        private static SilverPhotoModel instance;

        /// <span class="code-SummaryComment"><summary></span>
        /// Returns the single instance of the app model
        /// <span class="code-SummaryComment"></summary></span>
        /// <span class="code-SummaryComment"><returns>single instance of the app model</returns></span>
        public static SilverPhotoModel getInstance()
        {
            if (instance == null)
                instance = new SilverPhotoModel();

            return instance;
        }

        /// <span class="code-SummaryComment"><summary></span>
        /// Private constructor for singleton object
        /// <span class="code-SummaryComment"></summary></span>
        private SilverPhotoModel()
        {
            if ( instance != null )
            {
                throw new CairngormError(CairngormMessageCodes.SINGLETON_EXCEPTION,
                     "App model (SilverPhotoModel) should be a singleton object");
            }
        }

        private List<flickrphoto> _photoList = new List<flickrphoto>();
        /// <span class="code-SummaryComment"><summary></span>
        /// Data model for search result data binding
        /// <span class="code-SummaryComment"></summary></span>
        public List<flickrphoto> PhotoList
        {
            get { return _photoList; }
            set { _photoList = value; NotifyPropertyChanged("PhotoList"); }
        }

        private int _selectedIdx = -1;

        /// <span class="code-SummaryComment"><summary></span>
        /// Data model for selected photo index
        /// <span class="code-SummaryComment"></summary></span>
        public int SelectedIdx
        {
            get { return _selectedIdx; }
            set
            {
                _selectedIdx = value;
                NotifyPropertyChanged("SelectedIdx");
                NotifyPropertyChanged("SelectedPhotoSource");
            }
        }

        /// <span class="code-SummaryComment"><summary></span>
        /// Read-only property for image displaying
        /// <span class="code-SummaryComment"></summary></span>
        public BitmapImage SelectedPhotoSource
        {
            get { return (SelectedIdx < 0 || PhotoList.Count < 2 ) ? null :
                  new BitmapImage(new Uri(PhotoList[SelectedIdx].ImageUrl)); }
        }

        private string _searchTerm = "Cairngorm";
        /// <span class="code-SummaryComment"><summary></span>
        /// Data model for search term
        /// <span class="code-SummaryComment"></summary></span>
        public string SearchTerm
        {
            get { return _searchTerm; }
            set { _searchTerm = value; }
        }
    }
}

6. Putting it all together

First, we need to create an instance for the SilverPhotoController. I put the instantiation code in the Application_startup event handler in App.xaml.cs.

private void Application_Startup(object sender, StartupEventArgs e)
{
    this.RootVisual = new Page();

    //create Cairngorm controller instance
    SilverPhotoController cntrller = SilverPhotoController.getInstance();
}

Second, we need to assign the DataContext to be the SilverPhotoModel. The DataContext is set at the Loaded event handler when the main XAML is loaded in Page.xaml.cs.

private void UserControl_Loaded(object sender, RoutedEventArgs e)
{
    InitLoadEffect.Begin();

    //create Cairngorm model instance
    SilverPhotoModel model = SilverPhotoModel.getInstance();
    //bind model to view
    LayoutRoot.DataContext = model;
}

Because of the inheritance nature of the DataContext, we don't need to set it to our View's UserControls, since they'll automatically inherit the same DataContext from the root visual in Silverlight.

Third, we need to write some data binding expression in the UserControl's XAML markup.

PhotoSearch.xaml: Two way binding to model.SearchTerm.

<TextBox x:Name="searchTermTextBox"
     Height="30" Margin="8"
     VerticalAlignment="Center"
     FontSize="16"
     Text="{Binding Path=SearchTerm, Mode=TwoWay}"/>

PhotoList.xaml: One way binding to model.PhotoList, model.selectedIdx, and FlickRPhoto.Title for ItemTemplate.

<ListBox x:Name="formListBox" Width="Auto" Height="Auto"
  ItemsSource="{Binding Path=PhotoList}"
  SelectedIndex="{Binding Path=SelectedIdx, Mode=TwoWay}"
  SelectionChanged="formListBox_SelectionChanged">
    <ListBox.ItemTemplate>
     <DataTemplate>
      <TextBlock Text="{Binding Path=Title}" Width="Auto"
         Height="Auto" FontSize="12"></TextBlock>
     </DataTemplate>
    </ListBox.ItemTemplate>
</ListBox>

ContentZone.xaml: One way binding to model.SelectedPhotoSource.

<Image x:Name="searchResultsImage"
   Source="{Binding Path=SelectedPhotoSource}"
   Stretch="UniformToFill" VerticalAlignment="Center" HorizontalAlignment="Center"
   Margin="8" />

Lastly and most importantly, wire-up the "Go" button Click event with CairngormEvent. Here is the code for the button's Click event handler in the PhotoSearch.xaml.cs file:

private void searchBtn_Click(object sender, RoutedEventArgs e)
{
    SilverPhotoModel model = SilverPhotoModel.getInstance();
    if (!String.IsNullOrEmpty(model.SearchTerm))
    {
        CairngormEvent cgEvent =
          new CairngormEvent(SilverPhotoController.SC_EVENT_SEARCH_PHOTO);
        cgEvent.dispatch();
    }
}

Acknowledgement

Thanks for the WPF MVC - Wrails project at CodePlex, it gave me a good starting point of porting to Silverlight 2 (Beta 2).

Also, thanks to Brad Abrams blog on the Silverlight FlickR Example, it definitely makes using the FlickR REST API easier.

History

  • 2008.09.01 - First post
  • 2008.09.14 - Added update notes about thread-safe changes to Silverlight Cairngorm
  • 2008.10.05 - Silverlight Cairngorm v.0.0.1.2 (Downloadable source and demo project update for Thread-Safe Silverlight Cairngorm)
    • Updated Silverlight Cairngorm FrontController ---- each registered Cairngorm Event will be handled by a new instance of the corresponding Cairngorm Command, this will make Silverlight Cairngorm FrontController work in the same way as Flex's Cairngorm 2.2.1's FrontController. Both source and demo project can be downloaded from here.
    • Also updated demo project to reflect the new addCommand signature to pass in the type of Command, rather than the instance of Command
    • Demo project is also updated to use Silverlight 2 RC0

License

This article, along with any associated source code and files, is licensed under The Common Development and Distribution License (CDDL)

About the Author

Modesty Zhang
Technical Lead
United States United States
Tech Lead of large scale consumer facing software offerings, specializing in Web and Mobile application architecting and development.
 
Specialties:
Web App/ iOS / Cocoa Touch / HTML5 / CSS3 / Ajax / jQuery / jQuery Mobile / jQuery UI / Node.js / Rich JavaScript Application / RESTful Web Services / Java EE 6 / Java 7 / PHP / Ruby on Rails / and Windows / .NET / RIA / Flex / Flash / Silverlight / Software Architecting / Front End Design and Development

Comments and Discussions

 
NewsUpdated for Silverlight 4 and Visual Studio 2010 PinmemberModesty Zhang21-Dec-09 11:12 
NewsUpdated for Silverlight 2 RTW PinmemberModesty Zhang18-Oct-08 17:10 
GeneralExcellent article PinmemberBorekB9-Oct-08 10:44 
GeneralRe: Excellent article PinmemberModesty Zhang10-Oct-08 8:06 
NewsUpdate the Demo for RC0 PinmemberModesty Zhang27-Sep-08 8:49 
GeneralDear God please not Cairngorm.. PinmemberKim Stevens10-Sep-08 22:57 
GeneralRe: Dear God please not Cairngorm.. PinmemberModesty Zhang11-Sep-08 16:38 
GeneralRe: Dear God please not Cairngorm.. Pinmembersend_spam_here_14-Jan-09 4:28 
GeneralAwesome PinmemberDavid Roh10-Sep-08 5:27 
GeneralRe: Awesome PinmemberModesty Zhang11-Sep-08 16:24 

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

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

| Advertise | Privacy | Mobile
Web01 | 2.8.140721.1 | Last Updated 6 Oct 2008
Article Copyright 2008 by Modesty Zhang
Everything else Copyright © CodeProject, 1999-2014
Terms of Service
Layout: fixed | fluid