Click here to Skip to main content
11,496,146 members (625 online)
Click here to Skip to main content
Add your own
alternative version

Romeo and Juliet

, 3 Dec 2011 CPOL 24.7K 347 66
Making relationships first class citizens.
The site is currently in read-only mode for maintenance. Posting of new items will be available again shortly.
XTreePropertyGrid.zip
XTreePropertyGrid
Clifton.Tools.Strings
bin
Debug
Clifton.Tools.Strings.dll
Release
Xml Documentation
obj
Debug
Refactor
TempPE
Xml Documentation
TempPE
UnitTests
Clifton.Windows.Forms
bin
Debug
Clifton.Tools.Strings.dll
Clifton.Windows.Forms.dll
Release
obj
Debug
Refactor
TempPE
Properties
Resources
tv_minus.bmp
tv_plus.bmp
XmlTree
.svn
all-wcprops
entries
prop-base
props
text-base
INode.cs.svn-base
IXtreeNode.cs.svn-base
Node.cs.svn-base
NodeDef.cs.svn-base
NodeInstance.cs.svn-base
NodeMenuItem.cs.svn-base
PlaceholderInstance.cs.svn-base
Popup.cs.svn-base
RootNode.cs.svn-base
XtreeNodeController.cs.svn-base
tmp
prop-base
props
text-base
Myxaml 2.0
MyXaml.Core
bin
Debug
Clifton.Tools.Strings.dll
MyXaml.Core.dll
UnitTest.dll
Release
Xml Documentation
doc
MyXamlCoreUnitTests
obj
Debug
Refactor
TempPE
Release
Xml Documentation
TempPE
MyXaml.WinForms
bin
Debug
Clifton.Tools.Strings.dll
MyXaml.Core.dll
MyXaml.WinForms.dll
UnitTest.dll
Release
Xml Documentation
obj
Debug
Refactor
TempPE
Xml Documentation
TempPE
ROPLib
bin
Debug
ROPLib.dll
XTreeInterfaces.dll
Release
obj
Debug
Refactor
TempPE
Properties
ROPLib.csproj.user
UnitTest
bin
Debug
UnitTest.dll
Release
Xml Documentation
obj
Debug
Refactor
TempPE
Xml Documentation
TempPE
UnitTest.csproj.user
XTreeIIDemo
bin
Debug
Clifton.Tools.Strings.dll
Clifton.Windows.Forms.dll
MyXaml.Core.dll
MyXaml.WinForms.dll
ROPLib.dll
schemaEditor.myxaml
UnitTest.dll
XTreeIIDemo.exe
XTreeIIDemo.vshost.exe
XTreeIIDemo.vshost.exe.manifest
XTreeInterfaces.dll
Properties
XTreeInterfaces
bin
Debug
XTreeInterfaces.dll
Release
obj
Debug
TempPE
Properties
/*
Copyright (c) 2005, Marc Clifton
All rights reserved.

Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:

* Redistributions of source code must retain the above copyright notice, this list
  of conditions and the following disclaimer. 

* Redistributions in binary form must reproduce the above copyright notice, this 
  list of conditions and the following disclaimer in the documentation and/or other
  materials provided with the distribution. 
 
* Neither the name of MyXaml nor the names of its contributors may be
  used to endorse or promote products derived from this software without specific
  prior written permission. 

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

*/

using System;
using System.Collections;
using System.ComponentModel;
using System.Diagnostics;
using System.Reflection;
using System.Xml;

using Clifton.Tools.Strings;

namespace Clifton.Tools.Xml
{
	public delegate void InstantiateClassDlgt(object sender, ClassEventArgs cea);
	public delegate void AssignPropertyDlgt(object sender, PropertyEventArgs pea);
	public delegate void AssignEventDlgt(object sender, EventEventArgs eea);
	public delegate void SupportInitializeDlgt(object sender, SupportInitializeEventArgs siea);
	public delegate void AddToCollectionDlgt(object sender, CollectionEventArgs cea);
	public delegate void UseReferenceDlgt(object sender, UseReferenceEventArgs urea);
	public delegate void AssignReferenceDlgt(object sender, AssignReferenceEventArgs area);
	public delegate void CommentDlgt(object sender, CommentEventArgs cea);

	public class HandledEventArgs : EventArgs
	{
		protected bool handled;

		public bool Handled
		{
			get {return handled;}
			set {handled=value;}
		}
	}

	public class ClassEventArgs : HandledEventArgs
	{
		protected Type t;
		protected XmlNode node;
		protected object result;

		public Type Type
		{
			get {return t;}
		}

		public XmlNode Node
		{
			get {return node;}
		}

		public object Result
		{
			get {return result;}
			set {result=value;}
		}

		public ClassEventArgs(Type t, XmlNode node)
		{
			this.t=t;
			this.node=node;
			result=null;
			handled=false;
		}
	}

	public class PropertyEventArgs : HandledEventArgs
	{
		protected PropertyInfo pi;
		protected object src;
		protected object val;
		protected string valStr;

		public PropertyInfo PropertyInfo
		{
			get {return pi;}
		}

		public object Source
		{
			get {return src;}
		}

		public object Value
		{
			get {return val;}
		}

		public string AsString
		{
			get {return valStr;}
		}

		public PropertyEventArgs(PropertyInfo pi, object src, object val, string valStr)
		{
			this.pi=pi;
			this.src=src;
			this.val=val;
			this.valStr=valStr;
			handled=false;
		}
	}

	public class EventEventArgs : HandledEventArgs
	{
		protected EventInfo ei;
		protected object ret;
		protected object sink;
		protected string srcName;
		protected string methodName;

		public EventInfo EventInfo
		{
			get {return ei;}
		}

		public object Return
		{
			get {return ret;}
		}

		public object Sink
		{
			get {return sink;}
		}

		public string SourceName
		{
			get {return srcName;}
		}

		public string MethodName
		{
			get {return methodName;}
		}

		public EventEventArgs(EventInfo ei, object ret, object sink, string srcName, string methodName)
		{
			this.ei=ei;
			this.ret=ret;
			this.sink=sink;
			this.srcName=srcName;
			this.methodName=methodName;
		}
	}

	public class SupportInitializeEventArgs : HandledEventArgs
	{
		protected Type t;
		protected object obj;

		public object Object
		{
			get {return obj;}
		}

		public Type Type
		{
			get {return t;}
		}

		public SupportInitializeEventArgs(Type t, object obj)
		{
			this.t=t;
			this.obj=obj;
		}
	}

	public class CollectionEventArgs : HandledEventArgs
	{
		protected PropertyInfo pi;
		protected Type instanceType;
		protected Type parentType;

		public PropertyInfo PropertyInfo
		{
			get {return pi;}
		}

		public Type InstanceType
		{
			get {return instanceType;}
		}

		public Type ParentType
		{
			get {return parentType;}
		}

		public CollectionEventArgs(PropertyInfo pi, Type instanceType, Type parentType)
		{
			this.pi=pi;
			this.instanceType=instanceType;
			this.parentType=parentType;
		}
	}

	public class UseReferenceEventArgs : HandledEventArgs
	{
		protected Type t;
		protected string refName;
		protected object ret;

		public Type Type
		{
			get {return t;}
		}

		public string RefName
		{
			get {return refName;}
		}

		public object Return
		{
			get {return ret;}
			set {ret=value;}
		}

		public UseReferenceEventArgs(Type t, string refName)
		{
			this.t=t;
			this.refName=refName;
			ret=null;
		}
	}

	public class AssignReferenceEventArgs : HandledEventArgs
	{
		protected PropertyInfo pi;
		protected string refName;
		protected object obj;

		public PropertyInfo PropertyInfo
		{
			get {return pi;}
		}

		public string RefName
		{
			get {return refName;}
		}

		public object Object
		{
			get {return obj;}
		}

		public AssignReferenceEventArgs(PropertyInfo pi, string refName, object obj)
		{
			this.pi=pi;
			this.refName=refName;
			this.obj=obj;
		}
	}

	public class CommentEventArgs
	{
		protected string comment;

		public string Comment
		{
			get {return comment;}
		}

		public CommentEventArgs(string comment)
		{
			this.comment=comment;
		}
	}

//	public interface IMycroXaml
//	{
//		void Initialize(object parent);
//		object ReturnedObject
//		{
//			get;
//		}
//	}

	public class MycroParser
	{
		protected Hashtable nsMap;
		protected Hashtable objectCollection;
		protected object eventSink;
		protected XmlNode baseNode;

		public event InstantiateClassDlgt InstantiateClass;
		public event AssignPropertyDlgt AssignProperty;
		public event AssignEventDlgt AssignEvent;
		public event SupportInitializeDlgt BeginInitCheck;
		public event SupportInitializeDlgt EndInitCheck;
		public event EventHandler EndChildProcessing;
		public event AddToCollectionDlgt AddToCollection;
		public event UseReferenceDlgt UseReference;
		public event AssignReferenceDlgt AssignReference;
		public event CommentDlgt Comment;

		public Hashtable NamespaceMap
		{
			get {return nsMap;}
		}

		public void Load(XmlDocument doc, string objectName, object eventSink)
		{
			this.eventSink=eventSink;
			objectCollection=new Hashtable();

			XmlNode node;

			if (objectName != null)
			{
				node = doc.SelectSingleNode("//Declara[@Name='" + objectName + "']");
				Trace.Assert(node != null, "Couldn't find Declara element " + objectName);
				Trace.Assert(node.ChildNodes.Count <= 1, "Only one child of the root is allowed.");
				// The last valid node instantiated is returned.
				// The xml root should only have one child.
				ProcessNamespaces(node);

				if (node.ChildNodes.Count == 1)
				{
					baseNode = node.ChildNodes[0];
				}
			}
			else
			{
				node = doc.DocumentElement;
				baseNode = node;
				ProcessNamespaces(node);
			}
		}

		public object Process()
		{
			object ret=null;
			
			if (baseNode != null)
			{
				Type t;
				ret=ProcessNode(baseNode, null, out t);
			}

			return ret;
		}

		public bool HasInstance(string name)
		{
			return objectCollection.Contains(name);
		}

		public object GetInstance(string name)
		{
			Trace.Assert(objectCollection.Contains(name), "The object collection does not have an entry for "+name);
			return objectCollection[name];
		}

		public void AddInstance(string name, object obj)
		{
			// We don't care if we overwrite an existing object.
			objectCollection[name]=obj;
		}

		protected void ProcessNamespaces(XmlNode node)
		{
			nsMap=new Hashtable();
			foreach(XmlAttribute attr in node.Attributes)
			{
				if (attr.Prefix=="xmlns")
				{
					nsMap[attr.LocalName]=attr.Value;
				}
			}
		}

		protected virtual object OnInstantiateClass(Type t, XmlNode node)
		{
			object ret=null;
			ClassEventArgs args=new ClassEventArgs(t, node);

			if (InstantiateClass != null)
			{
				InstantiateClass(this, args);
				ret=args.Result;
			}

			if (!args.Handled)
			{
				ret=Activator.CreateInstance(t);
			}

			return ret;
		}

		protected virtual void OnAssignProperty(PropertyInfo pi, object ret, object val, string origVal)
		{
			PropertyEventArgs args=new PropertyEventArgs(pi, ret, val, origVal);

			if (AssignProperty != null)
			{
				AssignProperty(this, args);
			}
			
			if (!args.Handled)
			{
				pi.SetValue(ret, val, null);
			}
		}

		protected virtual void OnAssignEvent(EventInfo ei, object ret, object sink, string srcName, string methodName)
		{
			EventEventArgs args=new EventEventArgs(ei, ret, sink, srcName, methodName);

			if (AssignEvent != null)
			{
				AssignEvent(this, args);
			}

			if (!args.Handled)
			{
				Delegate dlgt=null;

				try
				{
					MethodInfo mi=sink.GetType().GetMethod(methodName, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static);
					dlgt=Delegate.CreateDelegate(ei.EventHandlerType, sink, mi.Name);
				}
				catch(Exception e)
				{
					Trace.Fail("Couldn't create a delegate for the event "+srcName+"."+methodName+":\r\n"+e.Message);
				}

				try
				{
					ei.AddEventHandler(ret, dlgt);
				}
				catch(Exception e)
				{
					Trace.Fail("Binding to event "+ei.Name+" failed: "+e.Message);
				}
			}
		}

		protected virtual void OnBeginInitCheck(Type t, object obj)
		{
			SupportInitializeEventArgs args=new SupportInitializeEventArgs(t, obj);
			
			if (BeginInitCheck != null)
			{
				BeginInitCheck(this, args);
			}

			if (!args.Handled)
			{
				// support the ISupportInitialize interface
				if (obj is ISupportInitialize)
				{
					((ISupportInitialize)obj).BeginInit();
				}
			}
		}

		protected virtual void OnEndInitCheck(Type t, object obj)
		{
			SupportInitializeEventArgs args=new SupportInitializeEventArgs(t, obj);
			
			if (EndInitCheck != null)
			{
				EndInitCheck(this, args);
			}

			if (!args.Handled)
			{
				// support the ISupportInitialize interface
				if (obj is ISupportInitialize)
				{
					((ISupportInitialize)obj).EndInit();
				}
			}
		}

		protected virtual void OnEndChildProcessing()
		{
			if (EndChildProcessing != null)
			{
				EndChildProcessing(this, EventArgs.Empty);
			}
		}

		protected virtual object OnUseReference(Type t, string refVar)
		{
			object ret=null;

			UseReferenceEventArgs args=new UseReferenceEventArgs(t, refVar);

			if (UseReference != null)
			{
				UseReference(this, args);
			}

			if (!args.Handled)
			{
				if (HasInstance(refVar))
				{
					ret=GetInstance(refVar);
				}
			}
			else
			{
				ret=args.Return;
			}

			return ret;
		}

		protected virtual void OnAssignReference(PropertyInfo pi, string refName, object obj)
		{
			AssignReferenceEventArgs args=new AssignReferenceEventArgs(pi, refName, obj);

			if (AssignReference != null)
			{
				AssignReference(this, args);
			}

			if (!args.Handled)
			{
				object val=GetInstance(refName);
				try
				{
					pi.SetValue(obj, val, null);
				}
				catch(Exception e)
				{
					Trace.Fail("Couldn't set property "+pi.Name+" to an instance of "+refName+":\r\n"+e.Message);
				}
			}
		}

		/// <summary>
		/// 
		/// </summary>
		/// <param name="pi">The PropertyInfo of the collection property.</param>
		/// <param name="propObject">The instance of the collection.</param>
		/// <param name="obj">The instance to add to the collection.</param>
		/// <param name="t">The instance type (being added to the collection).</param>
		/// <param name="parentType">The parent type.</param>
		/// <param name="parent">The parent instance.</param>
		protected virtual void OnAddToCollection(PropertyInfo pi, object propObject, object obj, Type t, Type parentType, object parent)
		{
			CollectionEventArgs args=new CollectionEventArgs(pi, t, parentType);

			if (AddToCollection != null)
			{
				AddToCollection(this, args);
			}

			if (!args.Handled)
			{
				// A null return is valid in cases where a class implementing the IMicroXaml interface
				// might want to take care of managing the instance it creates itself.  See DataBinding
				if (obj != null)
				{

					// support for ICollection objects
					if (!pi.CanWrite)
					{
						if (propObject is ICollection)
						{
							MethodInfo mi=parentType.GetMethod("Add", new Type[] {obj.GetType()});
							if (mi != null)
							{
								try
								{
									mi.Invoke(obj, new object[] {obj});
								}
								catch(Exception e)
								{
									Trace.Fail("Adding to collection failed:\r\n"+e.Message);
								}
							}
							else if (propObject is IList)
							{
								try
								{
									((IList)propObject).Add(obj);
								}
								catch(Exception e)
								{
									Trace.Fail("List/Collection add failed:\r\n"+e.Message);
								}
							}
						}
						else
						{
							Trace.Fail("Unsupported read-only property: "+pi.Name);
						}
					}
					else
					{
						// direct assignment if not a collection
						try
						{
							pi.SetValue(parent, obj, null);
						}
						catch(Exception e)
						{
							Trace.Fail("Property setter for "+pi.Name+" failed:\r\n"+e.Message);
						}
					}
				}
			}
		}

		protected virtual void OnComment(string text)
		{
			if (Comment != null)
			{
				CommentEventArgs args=new CommentEventArgs(text);
				Comment(this, args);
			}
		}

		protected object ProcessNode(XmlNode node, object parent, out Type t)
		{
			t=null;
			object ret=null;

			// Special case for String objects
			if (node.LocalName=="String")
			{
				return node.InnerText;
			}

			bool useRef=false;
			int nodeCount=0;

			string ns=node.Prefix;
			string cname=node.LocalName;
			Trace.Assert(nsMap.Contains(ns), "Namespace '"+ns+"' has not been declared.");
			string asyName=(string)nsMap[ns];
			string qname=StringHelpers.LeftOf(asyName, ',')+"."+cname+", "+StringHelpers.RightOf(asyName, ',');
			t=Type.GetType(qname, false);
			Trace.Assert(t != null, "Type "+qname+" could not be determined.");
			
			// Do ref:Name check here and call OnReferenceInstance if appropriate.
			if (node.Attributes != null)
			{
				nodeCount=node.Attributes.Count;

				if (node.Attributes.Count > 0)
				{
					// We're making a blatant assumption that the ref:Name is going to be
					// the first attribute in the node.
					if (node.Attributes[0].Name == "ref:Name")
					{
						string refVar=node.Attributes[0].Value;
						ret=OnUseReference(t, refVar);
						useRef=true;
					}
				}
			}
			
			if (!useRef)
			{
				// instantiate the class
				try
				{
					ret=OnInstantiateClass(t, node);
				}
				catch(Exception e)
				{
					Trace.Fail("Type "+qname+" could not be instantiated:\r\n"+e.Message);
				}
			}

			// Optimization, to remove SuspendLayout followed by ResumeLayout when no 
			// properties are being set (the ref only has a Name attribute).
			if ( (!useRef) || (nodeCount > 1) )
			{
				OnBeginInitCheck(t, ret);
			}

			// If the instance implements the IMicroXaml interface, then it may need 
			// access to the parser.
//				if (ret is IMycroXaml)
//				{
//					((IMycroXaml)ret).Initialize(parent);
//				}

			// implements the class-property-class model
			ProcessChildProperties(node, ret, t);

			OnEndChildProcessing();

			string refName=ProcessAttributes(node, ret, t);

			// Optimization, to remove SuspendLayout followed by ResumeLayout when no 
			// properties are being set (the ref only has a Name attribute).
			if ( (!useRef) || (nodeCount > 1) )
			{
				OnEndInitCheck(t, ret);
			}

			// If the instance implements the IMicroXaml interface, then it has the option
			// to return an object that replaces the instance created by the parser.
//				if (ret is IMycroXaml)
//				{
//					ret=((IMycroXaml)ret).ReturnedObject;
//					
//					if ( (ret != null) && (refName != String.Empty) )
//					{
//						AddInstance(refName, ret);
//					}
//				}

			return ret;
		}

		protected void ProcessChildProperties(XmlNode node, object parent, Type parentType)
		{
			// children of a class must always be properties
			foreach(XmlNode child in node.ChildNodes)
			{
				if (child is XmlComment)
				{
					OnComment(child.Value);
				}
				else
				if (child is XmlElement)
				{
					string pname=child.LocalName;
					PropertyInfo pi=parentType.GetProperty(pname);

					if ( (pi==null) || (node.Prefix != child.Prefix) )
					{
						// Special case--we're going to assume that the child is a class instance
						// not associated with the parent object
						Type t;
						ProcessNode(child, null, out t);
						continue;
					}

					// a property can only have one child node unless it's a collection
					foreach(XmlNode grandChild in child.ChildNodes)
					{
						if (grandChild is XmlComment)
						{
							OnComment(grandChild.Value);
						}
						else
						if (grandChild is XmlElement)
						{
							object propObject=null;

							if (parent != null)
							{
								propObject=pi.GetValue(parent, null);
							}

							Type t;
							object obj=ProcessNode(grandChild, propObject, out t);
							OnAddToCollection(pi, propObject, obj, t, parentType, parent);
						}
					}
				}
			}
		}

		protected string ProcessAttributes(XmlNode node, object ret, Type t)
		{
			string refName=String.Empty;

			// process attributes
			foreach(XmlAttribute attr in node.Attributes)
			{
				string pname=attr.Name;
				string pvalue=attr.Value;

				// it's either a property or an event
				PropertyInfo pi=t.GetProperty(pname);
				EventInfo ei=t.GetEvent(pname);

				if (pi != null)
				{
					// it's a property!
					if ( pvalue.StartsWith("{") && pvalue.EndsWith("}") )
					{
						// And the value is a reference to an instance!
						// Get the referenced object.  Late binding is not supported!
						OnAssignReference(pi, StringHelpers.Between(pvalue, '{', '}'), ret);
					}
					else
					{
						// it's string, so use a type converter.
						if (pi.PropertyType.FullName == "System.Object")
						{
							OnAssignProperty(pi, ret, pvalue, pvalue);
						}
						else
						{
							TypeConverter tc=TypeDescriptor.GetConverter(pi.PropertyType);
							if (tc.CanConvertFrom(typeof(string)))
							{
								object val=tc.ConvertFrom(pvalue);
								try
								{
									OnAssignProperty(pi, ret, val, pvalue);
								}
								catch(Exception e)
								{
									Trace.Fail("Property setter for "+pname+" failed:\r\n"+e.Message);
								}
							}
							else
							{
								Trace.Fail("Property setter for "+pname+" cannot be converted to property type "+pi.PropertyType.FullName+".");
							}
						}
					}

					// auto-add to our object collection
					if (pname=="Name")
					{
						refName=pvalue;
						AddInstance(pvalue, ret);
					}
				}
				else if (ei != null)
				{
					// it's an event!
					string src=pvalue;
					string methodName=String.Empty;
					object sink=eventSink;

					if ( (StringHelpers.BeginsWith(src, '{')) && (StringHelpers.EndsWith(src, '}')) )
					{
						src=StringHelpers.Between(src, '{', '}');
					}
						
					if (src.IndexOf('.') != -1)
					{
						string[] handler=src.Split('.');
						src=handler[0];
						methodName=handler[1];
						sink=GetInstance(src);
					}
					else
					{
						methodName=src;
					}

					OnAssignEvent(ei, ret, sink, src, methodName);
				}
				else
				{
					// auto-add to our object collection
					if ( (pname == "Name") || (pname == "def:Name") )
					{
						refName=pvalue;
						AddInstance(pvalue, ret);
					}
					else if (pname == "ref:Name")
					{
						// Do nothing.
					}
					else
					{
						// who knows what it is???
						Trace.Fail("Failed acquiring property information for "+pname);
					}
				}
			}
			return refName;
		}
	}
}

By viewing downloads associated with this article you agree to the Terms of Service and the article's licence.

If a file you wish to view isn't highlighted, and is a text file (not binary), please let us know and we'll add colourisation support for it.

License

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

Share

About the Author

Marc Clifton

United States United States
Marc is the creator of two open source projects, MyXaml, a declarative (XML) instantiation engine and the Advanced Unit Testing framework, and Interacx, a commercial n-tier RAD application suite.  Visit his website, www.marcclifton.com, where you will find many of his articles and his blog.

Marc lives in Philmont, NY.

| Advertise | Privacy | Terms of Use | Mobile
Web04 | 2.8.150520.1 | Last Updated 3 Dec 2011
Article Copyright 2011 by Marc Clifton
Everything else Copyright © CodeProject, 1999-2015
Layout: fixed | fluid