Peter - Programmers Extensive Text Editor
A robust text editor that supports plug-ins, code parsing, projects, and more.
Introduction
During my quest for that perfect text editor, I found many potential possibilities, but none that quite hit the mark. Some text editors would do this, but not that, and so on... Thus, Peter was conceived, in hope to eliminate some of the downfalls found in other text editors.
Some of the main items that I wanted in Peter are:
- Code editor with highlighting
- Superior docking control
- Plug-in interface
- Code analysis
- And many others...
Code Explained
Editor
When thinking about the actual code editor, I thought of many different solutions:
- Create my own - which would probably be too long and difficult for my project scope.
- Use the
RichTextBox
- this was my first attempt, but theRichTextBox
could just not handle what I needed it to. - Use ScintillaNET (located here) - this was a very good possibility, but I noticed that the project had not been updated since 2004, and I wanted a more recent solution.
So, I decided to use the ICSharpCode.TextEditor
found in the #Develop project located here.
Peter uses the ICSharpCode.TextEditor
from version 2.0, because version 3.0 has some errors that I did not have time to track down.
To use the editor in your project, download the source code for #Develop (I know it's a big download for some of us =P), extract the files to a folder, then go to src\Libraries\ICSharpCode.TextEditor, and open the solution file found there. Build the solution, then go back to the folder you extracted the files to, and you will find the DLL in the bin folder (same directory as the src folder). You can add this DLL via the Toolbox in Design mode, or add it as a Reference in the Solution Explorer.
When using the ICSharpCode.TextEditor
, a good thing to remember is that the TextEditor
is a house for several different underlying controls.
So, if you are looking for a particular property/method, chances are pretty good that you will have to do a little digging before you find it. Some of the important
properties/methods are explained below:
// We need this for the editor...
using ICSharpCode.TextEditor;
namespace Editor101
{
public class MyEditor
{
private TextEditorControl m_Editor;
#region -= Constructor =-
public MyEditor(string pathToFile) : UserControl
{
// Create and add the Editor...
this.m_Editor = new TextEditorControl();
this.m_Editor.Dock = System.Windows.Forms.DockStyle.Fill;
this.Controls.Add(this.m_Editor);
// Setup basic configuration...
this.SetupEditor();
// Load the given file...
// Arguments are:
// 1 - Path to file
// 2 - Auto Load Highlighting
// 3 - Auto Dected Encoding
this.m_Editor.LoadFile(pathToFile, true, true);
// If you want drag and drop support for the editor
// (these methods have not been implemented in this code)...
this.m_Editor.ActiveTextAreaControl.TextArea.AllowDrop = true;
this.m_Editor.ActiveTextAreaControl.TextArea.DragEnter +=
new System.Windows.Forms.DragEventHandler(TextArea_DragEnter);
this.m_Editor.ActiveTextAreaControl.TextArea.DragDrop +=
new System.Windows.Forms.DragEventHandler(TextArea_DragDrop);
// Caret Change notifications
// (these methods have not been implemented in this code)...
this.m_Editor.ActiveTextAreaControl.Caret.PositionChanged +=
new EventHandler(Caret_Change);
this.m_Editor.ActiveTextAreaControl.Caret.CaretModeChanged +=
new EventHandler(Caret_CaretModeChanged);
// Document Change notification...
this.m_Editor.Document.DocumentChanged +=
new DocumentEventHandler(Document_DocumentChanged);
// I had to implement these methods, because in version 2 (I don't
// know about version 3) the editor would not redraw itself after an
// undo or redo...
this.m_Editor.Document.UndoStack.ActionRedone +=
new EventHandler(UndoStack_ActionRedone);
this.m_Editor.Document.UndoStack.ActionUndone +=
new EventHandler(UndoStack_ActionRedone);
}
#endregion
#region -= Set up the Editor =-
private void SetupEditor()
{
// Setup the Highlighting for the editor (if we did not auto detect)...
// This will look in a folder for all available highlighting files
string path = SCHEME_FOLDER;
HighlightingManager.Manager.AddSyntaxModeFileProvider(
new FileSyntaxModeProvider(path));
// Now we can set the Highlighting scheme...
this.m_Editor.Document.HighlightingStrategy =
HighlightingManager.Manager.FindHighlighter("HTML");
// Show or Hide the End of Line Markers...
this.m_Editor.ShowEOLMarkers = false;
// Show or Hide Invalid Line Markers...
this.m_Editor.ShowInvalidLines = false;
// Show or Hide a little dot where spaces are...
this.m_Editor.ShowSpaces = false;
// Show or Hide ">>" where tabs are...
this.m_Editor.ShowTabs = true;
// Highlight the matching bracket or not...
this.m_Editor.ShowMatchingBracket = true;
// When should we highlight the matching bracket...
switch (BracketMatchingStyle.ToLower())
{
case "before":
this.m_Editor.BracketMatchingStyle =
BracketMatchingStyle.Before;
break;
case "after":
this.m_Editor.BracketMatchingStyle =
BracketMatchingStyle.After;
break;
}
// Show or Hide Line Numbers...
this.m_Editor.ShowLineNumbers = true;
// Show or Hide a Ruler at the top of the editor...
this.m_Editor.ShowHRuler = false;
// Show or Hide the vertical line in the text editor...
this.m_Editor.ShowVRuler = true;
// Enable Code Folding, if enabled, you must set the folding strategy
this.m_Editor.EnableFolding = false;
// The Folding Strategy is a class using the IFoldingStrategy Interface
// You must create your own folding strategies...
// this.m_Editor.Document.FoldingManager.FoldingStrategy =
// new CustomFoldingStrategy();
// Editor's font...
this.m_Editor.Font = this.Font;
// If you want to convert tabs to spaces or not...
this.m_Editor.ConvertTabsToSpaces = false;
// How many spaces should make up a tab...
this.m_Editor.TabIndent = 4;
// What column to place the vertical ruler at...
this.m_Editor.VRulerRow = 80;
// Allow the caret "|" beyong the end of the line or not...
this.m_Editor.AllowCaretBeyondEOL = false;
// Automatically instert a curly bracket when one is typed or not...
this.m_Editor.TextEditorProperties.AutoInsertCurlyBracket = false;
// Highlight the current line, or not..
this.m_Editor.LineViewerStyle = (HighlightCurrentLine) ?
LineViewerStyle.FullRow : LineViewerStyle.None;
// Using Anti-Alias Font or not...
this.m_Editor.UseAntiAliasFont = true; // #develop 2
/* // #develop 3
if (UseAntiAlias)
{
this.m_Editor.TextRenderingHint =
System.Drawing.Text.TextRenderingHint.AntiAliasGridFit;
}*/
// Set the Automatic indentation...
switch (IndentStyle.ToLower())
{
case "auto":
this.m_Editor.IndentStyle = IndentStyle.Auto;
break;
case "none":
this.m_Editor.IndentStyle = IndentStyle.None;
break;
case "smart":
this.m_Editor.IndentStyle = IndentStyle.Smart;
break;
}
}
#endregion
#region -= Misc =-
/// <summary>
/// Invalidate after Undo or Redo...
/// </summary>
private void UndoStack_ActionRedone(object sender, EventArgs e)
{
this.m_Editor.ActiveTextAreaControl.TextArea.Invalidate();
}
/// <summary>
/// Save the Document to a File...
/// </summary>
/// <param name="filePath">Path to File.</param>
public void Save(string filePath)
{
this.m_Editor.SaveFile(filePath);
}
/// <summary>
/// Print the Document...
/// </summary>
public void Print()
{
PrintPreviewDialog dlg = new PrintPreviewDialog();
dlg.Document = this.m_Editor.PrintDocument;
dlg.ShowDialog();
}
/// <summary>
/// This will show you how to get the selected text, and insert text...
/// </summary>
public void Duplicate()
{
if (this.m_Editor.ActiveTextAreaControl.SelectionManager.HasSomethingSelected)
{
string selection = this.m_Editor.ActiveTextAreaControl.
SelectionManager.SelectedText;
int pos = this.m_Editor.ActiveTextAreaControl.
SelectionManager.SelectionCollection[0].EndOffset;
this.m_Editor.Document.Insert(pos, selection);
this.m_Editor.ActiveTextAreaControl.TextArea.Invalidate();
}
}
/// <summary>
/// Scroll to a particular offset in the document...
/// </summary>
public void ScrollTo(int offset)
{
if (offset > this.m_Editor.Document.TextLength)
{
return;
}
int line = this.m_Editor.Document.GetLineNumberForOffset(offset);
this.m_Editor.ActiveTextAreaControl.Caret.Position =
this.m_Editor.Document.OffsetToPosition(offset);
this.m_Editor.ActiveTextAreaControl.ScrollTo(line);//.CenterViewOn(line, 0);
}
#endregion
}
}
For more information about how to use the TextEditor
, you can always go to the #Develop forums.
Docking
To accomplish Docking, I first created a tab control that allowed tabbed groups. My tab control was not as sophisticated as I wanted, but a good tab control was found in another article. As I was doing some research for the control, I stumbled across Weifen Luo's DockPanel Suite. This dock suite was exactly what I was looking for. The project can be found here.
To use the dock suite, you need to first set your main form as a MDI Parent. Next, in the Toolbox for the form designer, right click, and select Choose Items...
In the .NET Framework Components tab, click Browse, and find the dock suite DLL that you downloaded. Select it and press OK. Now, back in the Toolbox, you should find
a DockPanel
component, add this to your form (it has to be a control of the form, not a panel), and the rest is in the code.
Once the DockPanel
has been added to your form, you need to create content (tabs) for it, called DockContent
. To do this, create a new class,
call it whatever you want, and then extend it with the DockContent
object.
// We need this for the docking...
using WeifenLuo.WinFormsUI.Docking;
namespace Docking101
{
public class MyDockContentTab : DockContent
{
}
}
Once you extend the DockContent
object, your class will be converted to a form, and you can add/remove items via code, or in Design mode.
Some of the important properties of the DockContent
are explained below:
ToolTipText
- This is the text that will be displayed when you hover your mouse over the tab.TabText
- This is the text that will be displayed on the tab.TabPageContextMenu/TabPageContextMenuStrip
- This is theContextMenu
that will be displayed when someone right clicks on the tab of theDockContent
.DockAreas
- This determines where the tab can be docked.HideOnClose
- When the user closes the tab via the "X" button, this will make theDockPanel
either Hide or Close (Dispose) theDockContent
orForm
.
To add the DockContent
, you can follow the code below:
MyDockContentTab tabToAdd = new MyDockContentTab();
// Add Content to Main Panel in Default Way...
tabToAdd.Show(this.DockPanel);
// Add Contetn to Main Panel in a certain State...
DockState state = DockState.DockLeftAutoHide;
tabToAdd.Show(this.DockPanel, state);
// Add Content to a Floating Window...
tabToAdd.Show(this.DockPanel, new Rectangle(left, top, width, height));
// Add Content to a particular group...
DockPane pane = anotherTab.Pane;
tabToAdd.Show(pane, anotherTab);
This short intro to the DockPanel
should get you up and running.
Plug-in Interface
To use plug-ins in your application, you basically need to think of every thing someone would want to do via a plug-in, and put it into an interface. This can be hard at times, so I would like to say that the Plug-in Interface for Peter is still a work in progress.
There are three interfaces for plug-in support in Peter: IPeterPlugin
, IPeterPluginHost
, and IPeterPluginTab
. Plug-ins are
loaded out of the Plugins folder when Peter is starting up, so no Plug-in manager is needed.
/// <summary>
/// Loads the Plugins in the Plugin Directory...
/// </summary>
private void LoadPlugins()
{
string[] files = Directory.GetFiles(PLUGIN_FOLDER, "*.dll");
foreach (string file in files)
{
this.LoadPlugin(file);
}
}
/// <summary>
/// Loads a Plugin...
/// </summary>
/// <param name="pluginPath">Full Path to Plugin</param>
/// <returns>True if Plugin Loaded, otherwise false</returns>
public bool LoadPlugin(string pluginPath)
{
Assembly asm;
if (!File.Exists(pluginPath))
{
return false;
}
asm = Assembly.LoadFile(pluginPath);
if (asm != null)
{
foreach (Type type in asm.GetTypes())
{
if (type.IsAbstract)
continue;
object[] attrs = type.GetCustomAttributes(typeof(PeterPluginAttribute), true);
if (attrs.Length > 0)
{
IPeterPlugin plugin = Activator.CreateInstance(type) as IPeterPlugin;
plugin.Host = this;
if (plugin.HasMenu)
{
this.mnuPlugins.DropDownItems.Add(plugin.GetMenu());
}
if (plugin.HasTabMenu)
{
this.ctxTab.Items.Add(new ToolStripSeparator());
foreach (ToolStripMenuItem tsmi in plugin.GetTabMenu())
{
this.ctxTab.Items.Add(tsmi);
}
}
if (plugin.HasContextMenu)
{
this.ctxEditor.Items.Add(new ToolStripSeparator());
foreach (ToolStripMenuItem tsmi in plugin.GetContextMenu())
{
this.ctxEditor.Items.Add(tsmi);
}
}
this.m_Plugins.Add(plugin);
plugin.Start();
}
}
return true;
}
else
{
return false;
}
}
We first need to start off with the PeterPluginType
. This can be DockWindow
or UserDefined
. If PeterPluginType
is a DockWindow
, it will be displayed as a tab in Peter; otherwise, the user can control how the plug-in will be displayed, if needed at all. To specify
the PeterPluginType
, you need to add the PeterPlugin
attribute to your class (see below) and specify which PeterPluginType
you are using.
If this is not done, Peter will not recognize your plug-in.
The first interface is the IPeterPlugin
. This is the interface that your actual plug-in will need to use in order to work with Peter. For a plug-in example,
we will use the InternetBrowser
plug-in (located at Sourceforge).
using PeterInterface;
namespace InternetBrowser
{
// This is needed, for peter to determine
// if we can support the plugin or not...
[PeterPlugin(PeterPluginType.DockWindow)]
public class InternetBrowser : IPeterPlugin
// This is a Peter Plugin...
{
// Link to the Host Application (PETER)...
private IPeterPluginHost m_Host;
// Active Tab in PETER...
private IDockContent m_ActiveTab;
public InternetBrowser()
{
this.m_ActiveTab = null;
}
#region -= IPeterPlugin Members =-
...
#endregion
}
}
Here are the IPeterPlugin
properties/methods:
/// <summary>
/// Interface for a Peter Plugin...
/// </summary>
public interface IPeterPlugin
{
/// <summary>
/// This method is called when the plugin is loaded...
/// </summary>
void Start();
/// <summary>
/// This method is called when Peter Closes...
/// </summary>
void Close();
/// <summary>
/// This is the Name of the plugin...
/// </summary>
string Name { get; }
/// <summary>
/// Gets if the Plugin can load files or not...
/// </summary>
bool AbleToLoadFiles { get; }
/// <summary>
/// Loads the Given File...
/// </summary>
/// <param name="filePath">Path to file to load.</param>
/// <returns />True if Loaded, otherwise false</returns />
bool LoadFile(string filePath);
/// <summary>
/// Does this plugin have a menu item for the plugin menu...
/// </summary>
bool HasMenu { get; }
/// <summary>
/// Does this plugin have a menu item for the tab menu...
/// </summary>
bool HasTabMenu { get; }
/// <summary>
/// Does this plugin have a menu item for the context menu...
/// </summary>
bool HasContextMenu { get; }
/// <summary>
/// The Author of the Plugin...
/// </summary>
string Author { get; }
/// <summary>
/// The Version of the Plugin...
/// </summary>
string Version { get; }
/// <summary>
/// The Image for the Plugin...
/// </summary>
Image PluginImage { get; }
/// <summary>
/// Gets the Plugin Menu Item...
/// </summary>
/// <returns />Tool Strip Menu Item</returns />
ToolStripMenuItem GetMenu();
/// <summary>
/// Gets the Tab Menu Items...
/// </summary>
/// <returns />Tool Strip Menu Item Array</returns />
ToolStripMenuItem[] GetTabMenu();
/// <summary>
/// Gets the Context Menu Items...
/// </summary>
/// <returns />Tool Strip Menu Item Array</returns />
ToolStripMenuItem[] GetContextMenu();
/// <summary>
/// Gets the Type of the Plugin...
/// </summary>
PeterPluginType Type { get; }
/// <summary>
/// Gets or Sets the Host Appliction Interface...
/// </summary>
IPeterPluginHost Host { get; set; }
/// <summary>
/// Occurs when the Active Content has changed...
/// </summary>
/// <param name="tab">Active Content</param>
void ActiveContentChanged(IDockContent tab);
/// <summary>
/// Checks the content string when loading Application...
/// </summary>
/// <param name="contentString">Content String</param>
/// <returns />True if Match, other wise false</returns />
bool CheckContentString(string contentString);
/// <summary>
/// Gets the Starting Content...
/// </summary>
/// <param name="contentString">Content String</param>
/// <returns />The Newly created content</returns />
IDockContent GetContent(string contentString);
/// <summary>
/// Gets the Option Panel to add to the Main Option Dialog...
/// </summary>
/// <returns />Option Panel Control.</returns />
Control OptionPanel { get; }
/// <summary>
/// Occurs when the Apply button on the options dialog is pressed...
/// </summary>
void ApplyOptions();
}
The IPeterPluginHost
is the interface that is used for your plug-in to talk with Peter. I kept the interface between the plug-in and Peter very
short and sweet so as to minimize on potential threats. The host is set in the IPeterPlugin
interface above. Its properties/methods are shown below:
/// <summary>
/// Interface for the Host Peter Application...
/// </summary>
public interface IPeterPluginHost
{
/// <summary>
/// Gets the Type for a Editor in string format (typeof(Editor))...
/// </summary>
string EditorType { get; }
/// <summary>
/// Gets the path the Application started in...
/// </summary>
string ApplicationExeStartPath { get; }
/// <summary>
/// Creates a new blank editor...
/// </summary>
void NewDocument();
/// <summary>
/// Writes the given text in the status bar...
/// </summary>
/// <param name="text">Text to Write.</param>
void Trace(string text);
/// <summary>
/// Saves the Given Content As...
/// </summary>
/// <param name="tab">Content to Save</param>
void SaveAs(IPeterPluginTab tab);
/// <summary>
/// Creates a new Editor with the given file...
/// </summary>
/// <param name="fileName">File to load in Editor.</param>
/// <param name="tabName">Name of Tab.</param>
void CreateEditor(string path, string tabName);
/// <summary>
/// Creates a new Editor with the given file...
/// </summary>
/// <param name="fileName">File to load in Editor.</param>
/// <param name="tabName">Name of Tab.</param>
/// <param name="image">Icon for Tab.</param>
void CreateEditor(string path, string tabName, Icon image);
/// <summary>
/// Creates a new Editor with the given file...
/// </summary>
/// <param name="fileName">File to load in Editor.</param>
/// <param name="tabName">Name of Tab.</param>
/// <param name="image">Icon for Tab.</param>
/// <param name="addToContent">Content to add to group.</param>
void CreateEditor(string path, string tabName, Icon image, IDockContent addToContent);
/// <summary>
/// Gets the Shell Icon for the given file...
/// </summary>
/// <param name="filePath">Path to File.</param>
/// <param name="linkOverlay">Link Overlay or not.</param>
/// <returns />Shell Icon for File.</returns />
Icon GetFileIcon(string path, bool linkOverlay);
/// <summary>
/// Adds the given Dock Content to the form...
/// </summary>
/// <param name="content">Content to Add.</param>
void AddDockContent(DockContent content);
/// <summary>
/// Adds the given Dock Content to the form...
/// </summary>
/// <param name="content">Content to Add.</param>
/// <param name="state">State of Content</param>
void AddDockContent(DockContent content, DockState state);
/// <summary>
/// Adds the given Dock Content to the form...
/// </summary>
/// <param name="content">Content to Add.</param>
/// <param name="floatingRec">Floating Rectangle</param>
void AddDockContent(DockContent content, Rectangle floatingRec);
}
The last interface is the IPeterPluginTab
. This interface is needed if you plan on implementing a tab in Peter. If you open the solution for
the InternetBrowser
plug-in, you will see that there is a InternetBrowser
class and a ctrlInternetBrowser
DockContent
.
The ctrlInternetBrowser
implements the IPeterPluginTab
interface because it will be the tab that hosts the web browser. You can think
of the IPeterPlugin
as behind the scene, and IPeterPluginTab
as what everyone sees. This way, we can use the menu items like Find, Copy, Save...
in your plug-in's tab. Your tab will need to implement the properties/methods below (they are all pretty self-explanatory):
public interface IPeterPluginTab
{
void Save();
void SaveAs(string filePath);
void Cut();
void Copy();
void Paste();
void Undo();
void Redo();
void Delete();
void Print();
void SelectAll();
void Duplicate();
bool CloseTab();
IPeterPluginHost Host { get; set; }
string FileName { get; }
string Selection { get; }
bool AbleToUndo { get; }
bool AbleToRedo { get; }
bool AbleToPaste { get; }
bool AbleToCut { get; }
bool AbleToCopy { get; }
bool AbleToSelectAll { get; }
bool AbleToSave { get; }
bool AbleToDelete { get; }
bool NeedsSaving { get; }
string TabText { get; set; }
void MarkAll(Regex reg);
bool FindNext(Regex reg, bool searchUp);
void ReplaceNext(Regex reg, string replaceWith, bool searchUp);
void ReplaceAll(Regex reg, string replaceWith);
void SelectWord(int line, int offset, int wordLeng);
}
For more info on plug-ins, open the InternetBrowser plug-in and take a look at its code. Suggestions for improving the plug-in interface are welcome.
Code Analysis
One of the last major feats of Peter was the code analysis. This became very tricky, and will never be 100 percent complete. Many options were explored, but I finally decided to use Coco/R. Coco/R is a compiler generator that will create a scanner and parser for you. The only problem is that you have to implement the parser yourself. To create a parser for a certain style of code, you need to create an ATG (attributed grammar - don't quote me on this) file. This file will tell Coco how it needs to create the parser's code. Once you have your ATG file, you give it to Coco/R, and out will pop a scanner and parser written in the version of code you downloaded Coco/R in (C#, C++, Java, etc.). More info on this can be found at the Coco/R website. The code analyzer is actually an internal plug-in. I made it internal so I could have more control over it. So, when the active document is changed (see Plug-in Interface), we send the parser plug-in the document we want to parse. It will then determine the file extension and parse the code accordingly. So far, I have implemented XML, C#, CSS, Java, and .C/.H file parsers.
An important item to note is that when the parser that Coco/R creates parses the file, it does nothing to save the information that was parsed. So, you have to go in
and create a way to save the parsed info. I did this by just adding some ArrayList
s that will hold the info I need, like an ArrayList
to hold the methods.
So, how the whole thing works is:
- Download Coco/R (I downloaded Coco/R for C#, so it will give me C# parsers)
- Create an ATG file for the code style of your choice
/* A small sample of the ATG File for C#... */
CS2
=
{IF (IsExternAliasDirective()) ExternAliasDirective}
{UsingDirective}
{IF (IsGlobalAttrTarget()) GlobalAttributes}
{NamespaceMemberDeclaration}
.
ExternAliasDirective
=
"extern" ident (.
if (t.val != "alias") {
Error("alias expected");
}
.)
ident ";"
.
UsingDirective
=
"using" [ IF (IsAssignment()) ident "=" ]
TypeName ";"
coco CSharp.ATG
// This in parser.cs
// When the using keyword is found in a C# file,
// this method will be called...
void UsingDirective()
{
Expect(78);
if (IsAssignment())
{
Expect(1);
Expect(85);
}
// A TokenMatch is a class I created to save
// info about a find we are interested in...
TokenMatch tm = new TokenMatch();
// Set position we found it at...
tm.Position = la.pos;
this.m_Current = la.val;
TypeName();
// Set the value we found...
tm.Value = this.m_Current;
// Add the Match to the saved info...
this.m_CodeInfo.Usings.Add(tm);
Expect(114);
}
// You could modify this to parse a stream instead of a file...
Peter.CSParser.Scanner scanner = new Peter.CSParser.Scanner(pathToFile);
// The parser uses the scanner to find tokens,
// Tokens are things such as Keywords, comments, and so forth...
Peter.CSParser.Parser parser = new Peter.CSParser.Parser(scanner);
parser.Parse();
// Using...
TreeNode nUsing = new TreeNode("Usings");
foreach (TokenMatch tm in parser.CodeInfo.Usings)
{
TreeNode n = new TreeNode(tm.Value);
n.Tag = tm.Position;
nUsing.Nodes.Add(n);
}
if (nUsing.Nodes.Count > 0)
{
myTree.Nodes.Add(nUsing);
}
Conclusion
Well, that's all I have for Peter right now. There are still some other features that I did not explain, maybe I will at another date, but I did not think they were too important. These features include: the file explorer, project support, the Find dialog, command prompt, file difference, and folding strategies. If you need help with these, please let me know, and I will publish some info on it.
History
- 27 May 2008: Article created.
- 8 Feb 2012: Download links updated.