Click here to Skip to main content
15,881,248 members
Articles / Programming Languages / C#

SketchIt, A Small .NET Based Development Environment

Rate me:
Please Sign up or sign in to vote.
4.97/5 (22 votes)
2 Aug 2018CPOL13 min read 22.4K   19   6
SketchIt is a small, .NET based development environment, created to have fun while learning to code, or simply to sketch together a visual idea using code.

Image 1

Introduction

After watching many of Daniel Shiffman's videos on YouTube (The Coding Train), I was inspired to create a tool similar to a Java based tool he uses, but for .NET developers. I started with the project early in 2017, and after several iterations, recently "launched" the project. A very basic introductory video is also available for more information.

I hope that SketchIt will serve two main purposes:

  • that it will be used as a tool to learn coding
  • that it will be used to create fun and creative projects with

Please note, this is a first of many for me:

  • first article of any kind on any platform
  • first open source project
  • first YouTube video
  • first GitHub repository
  • possibly some others that I can think of now...

I'll appreciate your feedback.

IMPORTANT: A lot of functionality, improvements and optimizations are still outstanding, please get involved on GitHub.

Background

The whole idea behind SketchIt started back in 2016. I was watching a documentary series on Netflix (Marcus du Sautoy, The Secret Rules of Modern Living: Algorithms), and was again fascinated by the Traveling Salesman Problem (TSP). I wrote some code to see how I could solve a TSP route using brute force, and played with some other ideas - all visually in a WinForms application. This was already a trigger for me - why do I need to create a whole new solution in Visual Studio every time I want to quickly test something or play around with an idea.

Then, one day, YouTube suggested a video by Daniel Shiffman about the Travelling Salesman Problem - this was the first time I watched any of his videos, the first of many. After watching some of his videos, I noticed the tool mentioned at the beginning of this article, called Processing, a Java based development environment. And so, SketchIt was born.

The IDE

One of my goals was to keep things plain and simple, and hopefully that is what you'll notice when you open SketchIt:

Image 2

The main components are:

  1. The Menu and Toolbar
  2. The Code Editor
  3. An Error List
  4. A Live Preview Pane
    1. At the top, the canvas
    2. At the bottom, the console window
  5. The Statusbar

SIDE NOTE: You can modify the color scheme of the IDE by editing the settings.json file in the %APPDATA%\SketchIt folder. The above screen is a modified color scheme, below is the default scheme:

Image 3

Some IDE issues to be completed/improved include:

  • Intellisense (the current implementation is not very intelligent)
  • Code auto-completion
  • Code auto-formatting (indentation, etc.)
  • Member tooltips

A Simple Example using SketchIt

When opening SketchIt, you are presented with a code editor. SketchIt can be used in "static" mode, or "animation" mode. In static mode, a block of code is executed sequentially from top to bottom, and the program (or "sketch") comes to an end. Here is a basic example that can be copied and pasted directly into the SketchIt editor:

C#
float value = Random() * 100;
PrintLine("The value between 0 and 100 is", value);

From the code snippet above, you can see that no methods are declared. You simply enter a block of code and it executes (you can enable a "Live Preview" of your code using the Live Preview toolbar button).

Using animation mode is just as simple. The following code can again be pasted into the SketchIt editor:

C#
void Setup()
{
    SetSize(800, 400);
}

void Draw()
{
    DrawBackground(0);
    DrawEllipse(Random(Width), Random(Height), Random(10, 50));
}

To make use of the animation loop, a Draw() method is required - this is the animation loop. An optional Setup() method can be used to initialize variables, etc., and also to set up the canvas. The above code sets the canvas size to 800 x 400 pixels. Then in the animation loop, the background is cleared using the DrawBackground() function, and an ellipse is drawn at a random location (based on the canvas size) with a random diameter of between 10 and 50 pixels.

Challenges

While developing SketchIt, I wanted to have some features and functions available from the get go, some proved more challenging than others (and some are, of course, still outstanding):

  • An API to host SketchIt functionality in other applications
  • How will I compile the code entered into the text editor? How can functions be invoked without a qualified name?
  • How will I extract variable/member information from the user's code?
  • A live preview of the sketch as the code is modified
  • A proper text editor, with all the bells and whistles expected from a modern editor (this point needs some attention)
  • A 3D renderer (this point is still outstanding)
  • Available on Mac/Linux (this point is still outstanding)
  • And some other, minor challenges

An API to Host SketchIt Functionality in Other Applications

I wanted to make sure that a sketch could be implemented in an external application, without modifying too much of the code as used in the SketchIt IDE. To accomplish this, I created an API which can be referenced externally. Here's a truncated version of the splash screen animation of SketchIt. The animation was created using the SketchIt IDE, then incorporated into a form:

C#
public class SplashAnimation : Sketch
{
    void ClearBackground()
    {
        bg.DrawBackground(GetColor(ControlPaint.Dark(AppearanceSettings.BaseBackColor, .9f)));
    }
 
    void Draw()
    {    
        foreach (Dot pt in points)
        {
            pt.Update();
        }
 
        DrawBackground(bg);
 
        SetTextAlignment(Constants.LEFT, Constants.TOP);
        SetStroke(GetColor(AppearanceSettings.ApplicationBackColor));
        SetFont("Segoe UI Light", 40, false, false);
        SetFont("Segoe UI Light", 10, true, false);
        SetStroke(GetColor(ControlPaint.Light(AppearanceSettings.ApplicationTextColor, .9f)));
        SetFont("Segoe UI Light", 40, false, false);
        DrawText("SketchIt", 5, 10, Width - 10, Height - 20);
    }
 
    void KeyPress()
    {
        if (!IsLooping)
        {
            ClearBackground();
            a = 10;
            Loop();
        }
    }
}

And here is the (truncated) code of the splash screen animation window:

C#
public partial class SplashScreenForm : BaseForm
{
    private SplashAnimation _animation;
 
    private void SplashScreenForm_Load(object sender, System.EventArgs e)
    {
        _animation = new SplashAnimation();
        _animation.Start(ctlCanvas);

        Size = new Drawing.Size(_animation.Width, _animation.Height);
        CenterToScreen();
        ActiveControl = ctlCanvas;
    }
 
    protected override void OnPaint(PaintEventArgs e)
    {
        using (Drawing.Pen pen = new Drawing.Pen(AppearanceSettings.ActiveCaptionBackColor, 4))
        {
            e.Graphics.DrawRectangle(pen, 0, 0, Width, Height);
        }
    }
}

The first thing to note is the Sketch inheritance of the SplashAnimation class. The Sketch class contains (almost) all functions available from the SketchIt IDE. Looking at the SplashAnimation class above, you'll note that constants are prefixed with "Constants.". This is typically something I wanted the user to not care about while using the SketchIt IDE, especially when someone is new to coding. As little prefixing as possible. But, now that the code is used outside the IDE, we prefix it (though avoidable using the "using static" directive).

The SketchIt API currently consists of the following namespaces:

  • SketchIt.Api
  • SketchIt.Api.Interfaces
  • SketchIt.Api.Renderers
  • SketchIt.Api.Static

The Sketch class found in the SketchIt.Api namespace is an important class and takes care of a lot of things, such as:

  • Invoking the Setup() and Draw() methods - if they do exist
  • Keeping track of the draw timer to try and maintain the required frame rate
  • Notifying the sketch container to update after an animation loop has completed

The Sketch class also implements a number of interfaces found in the SketchIt.Api.Interfaces namespace. For example, drawing to the canvas is not handled by the Sketch class, but by the Renderer class. But to allow the user to use something like DrawLine(0, 0, Width, Height) instead of Renderer.DrawLine(0, 0, Width, Height), the Sketch class implements the IRenderer interface, and then calls the corresponding function of the instantiated renderer. The same goes for style information (such as ColorMode) which is handled by the Style class. To allow the user to use SetColorMode(HSB) instead of CurrentLayer.Style.SetColorMode(HSB), the IStyle interface is implemented and mapped to the corresponding Canvas.Style member.

Although not part of the SketchIt API, there is another interesting class found in the SketchIt.Windows assembly. The SketchIt.Windows.Application class is basically a mapping to the Sketch class, but with static versions of all applicable members. The SketchIt.Windows.Application class is in essence the Sketch class implementation for the SketchIt IDE.

How Will I Compile the Code Entered into the Text Editor? How Can Functions be Invoked Without a Qualified Name?

Now this point was pretty interresting. I originally used the standard CSharpCodeProvider class to compile code, and all went well, until I realized I was unable to compile code using the using static directive (which was only introduced in C# 6). From what I can gather, the standard CSharpCodeProvider cannot compile C# 6 code, and that is when I switched to Roslyn (Microsoft.CodeDom.Providers.DotNetCompilerPlatform). This might sound a bit melodramatic, but it was quite a game changer for me. The using static directive solved a big requirement I had: using the SketchIt IDE, the user should be able to create multiple classes in multiple files, and never have to use qualified names for SketchIt functions. The only way I was able to accomplish this was with the using static directive.

The IDE compiles the code by doing the following:

  • Concatenate all source files into a single string
  • Check if the user's code is a sketch running in "static" mode or "animation" mode
  • Pull the concatenated string into a template which is used to compile the code

The template looks like this:

C#
using System;
using System.Collections;
using System.Collections.Generic;
using SketchIt.Api;
using static SketchIt.Windows.Application;
using static SketchIt.Api.Static.Functions;
using static SketchIt.Api.Static.Constants;
//#using-place-holder#

public partial class App : Applet
{
    //#code-place-holder#
}

Note the use of the using static directive. This allows us to reference SketchIt.Windows.Application, SketchIt.Api.Static.Functions and SketchIt.Api.Static.Constants members without any qualified names. Problem: Solved. From the template above, the //#code-place-holder# and //#using-place-holder strings are replaced with the user's code. Let's have a look at how this is done:

C#
public bool Compile()
{
    Status status = IsBackgroundCompiler ? null : Status.Set("Compiling...");
 
    CompilerErrors = null;
    Output = null;
    Exception = null;
 
    try
    {
        string outputFolder = Settings.GetUserFolder() + "\\temp";
        string startup = Properties.Resources.AppStartup;
        SourceCode[] sourceCode = GetCodeFiles();
        List<string> sourceFiles = new List<string>();
        bool isStatic = Program.Parser.IsStatic();
 
        if (!Directory.Exists(outputFolder))
        {
            Directory.CreateDirectory(outputFolder);
        }
 
        sourceFiles.Add(startup);

This above code is pretty straightforward:

  • Change the status of the application
  • Reset and initialize some variables
  • Get all the source code
  • Determine if this is a "static mode" sketch (the Parser.IsStatic() function simply checks if any methods are declared in the user's code. If not, we assume static mode)
  • Create the output folder if it does not exist

C#
int usingOffset = Properties.Resources.AppTemplate.IndexOf("//#using-place-holder#");
int codeOffset = Properties.Resources.AppTemplate.IndexOf("//#code-place-holder#");
 
usingOffset = Properties.Resources.AppTemplate.Substring(0, usingOffset).Split(new string[] 
{ Environment.NewLine }, StringSplitOptions.None).Length - 1;
codeOffset = Properties.Resources.AppTemplate.Substring(0, codeOffset).Split(new string[] 
{ Environment.NewLine }, StringSplitOptions.None).Length - 1;
 
foreach (SourceCode sc in sourceCode)
{
    sourceFiles.Add(Properties.Resources.AppTemplate
        .Replace("//#using-place-holder#", sc.Using)
        .Replace("//#code-place-holder#", (isStatic ? "void RunStatic() {" : "") + 
                 sc.Code + (isStatic ? "\r\n}" : ""))
        );
}

Then, from the code above, we do the following:

  • Get the location of the two place holders within the code template (the "//#....-place-holder#" strings)
  • For each code file, we replace the place holders with the applicable code (using directives must always appear at the top of the user's code - we then move it to the appropriate spot in the template)

C#
List<string> assemblyList = new List<string>(new string[] {
    "System.dll",
    "System.Windows.Forms.dll",
    "System.Drawing.dll"
});
 
assemblyList.AddRange(GetAssemblyList());
 
Dictionary<string, string> options = new Dictionary<string, string>();
CSharpCodeProvider provider = new CSharpCodeProvider();
CompilerParameters parameters = new CompilerParameters(assemblyList.ToArray())
{
    GenerateInMemory = IsBackgroundCompiler,
    GenerateExecutable = !IsBackgroundCompiler,
    OutputAssembly = outputFolder + (IsBackgroundCompiler ? "\\sketch.tmp" : "\\sketch.exe"),
    CompilerOptions = "/target:winexe /optimize /win32icon:\"" + 
                       Application.StartupPath + "\\SketchIt.ico\"",
};

Next, we add the reference assemblies, and initialize the compiler parameters.


C#
int retries = 0;
while (File.Exists(parameters.OutputAssembly) && retries < 10)
{
    try
    {
        File.Delete(parameters.OutputAssembly);
    }
    catch
    {
        retries++;
    }
}

We then try to delete an existing output if it already exists (we allow 10 retries since it might be in use).


C#
if (!IsBackgroundCompiler)
{
    if (!File.Exists(Application.StartupPath + "\\SketchIt.ico"))
    {
        using (FileStream stream = new FileStream
              (Application.StartupPath + "\\SketchIt.ico", FileMode.Create))
            Program.MainForm.Icon.Save(stream);
    }
 
    parameters.EmbeddedResources.Add(Application.StartupPath + "\\SketchIt.ico");
 
    foreach (string filename in GetAssemblyList(true))
    {
        parameters.EmbeddedResources.Add(filename);
    }
 
    foreach (ILibrary library in Program.Libraries)
    {
        FileInfo libraryFile = new FileInfo(library.GetType().Assembly.Location);
        List<string> dependancies = new List<string>(library.AdditionalDependancies);
 
        if (!library.Embeddable)
        {
            dependancies.Add(libraryFile.Name);
        }
 
        foreach (string dependancy in dependancies)
        {
            FileInfo destinationFile = new FileInfo(outputFolder + "\\" + dependancy);
            string sourceFile = libraryFile.DirectoryName + "\\" + dependancy;
 
            if (!Directory.Exists(destinationFile.DirectoryName))
            {
                Directory.CreateDirectory(destinationFile.DirectoryName);
            }
 
            if (File.Exists(destinationFile.FullName))
            {
                try
                {
                    File.Delete(destinationFile.FullName);
                }
                catch
                {
                }
            }
 
            if (!File.Exists(destinationFile.FullName))
            {
                File.Copy(sourceFile, destinationFile.FullName);
            }
        }
    }
}

If this is not a background compiler, we do a couple of things:

  • Make sure the application icon exists to embed into the assembly
  • Check if referenced libraries can be embedded into the assembly (this includes the SketchIt API)
  • For non-embeddable libraries, we copy the dependancies to the output folder

C#
    CompilerResults results = provider.CompileAssemblyFromSource(parameters, sourceFiles.ToArray());
 
    if (results.Errors.Count == 0)
    {
        Output = results.CompiledAssembly;
    }
    else
    {
        foreach (CompilerError error in results.Errors)
        {
            try
            {
                FileInfo fileInfo = string.IsNullOrEmpty(error.FileName) ? 
                  null : new FileInfo(Path.GetFileNameWithoutExtension(error.FileName));
                int fileIndex = fileInfo != null ? int.Parse(fileInfo.Extension.Substring(1)) - 1 : -1;
 
                if (fileIndex < 0)
                {
                }
                else if (error.Line < codeOffset + sourceCode[fileIndex].UsingLineCount)
                {
                    error.Line -= usingOffset;
                }
                else if (error.Line - codeOffset > sourceCode[fileIndex].CodeLineCount)
                {
                    error.Line = sourceCode[fileIndex].CodeLineCount;
                }
                else
                {
                    error.Line -= codeOffset;
                }
            }
            catch (Exception ex)
            {
                throw ex;
            }
        }
 
        CompilerErrors = results.Errors;
    }
}
catch (Exception ex)
{
    Exception = ex;
}
finally
{
    if (status != null)
    {
        status.Dispose();
    }
}
 
return Output != null;

Then, we compile the assembly:

  • If an exception is thrown during compilation, the catch block stores the thrown exception
  • If no exception is thrown, and the compilation is successful, we set the Output of the compiler to the CompiledAssembly
  • If the compiler encountered errors, we update the line number to correspond with the IDE line numbers
  • Finally, we return true if the compilation was successful, false otherwise

How Will I Extract Variable/Member Information from the User's Code?

I started off with a small little parser of my own, but after incorporating Roslyn, made use of the CSharpSyntaxTree class to parse the code. This is also something that needs more attention as it goes hand-in-hand with the autocompletion functionality.

C#
CSharpParseOptions options = new CSharpParseOptions
(LanguageVersion.Default, DocumentationMode.None, SourceCodeKind.Script);
 
SyntaxTree = CSharpSyntaxTree.ParseText(input.ToString(), options);
_compilation = CSharpCompilation.Create("Sketch", new SyntaxTree[] { SyntaxTree });
_semanticModel = _compilation.GetSemanticModel(SyntaxTree, true);
 
ParsedTypes = "";
ParsedKeywords = "";
 
foreach (SyntaxNode node in SyntaxTree.GetCompilationUnitRoot().DescendantNodes())
{
    if (node is MethodDeclarationSyntax method)
    {
        ParsedKeywords += method.Identifier.ValueText + " ";
    }
    else if (node is FieldDeclarationSyntax field)
    {
        //ParsedKeywords += field.Declaration.Variables[0].Identifier.ValueText + " ";
    }
    else if (node is ClassDeclarationSyntax cls)
    {
        ParsedTypes += cls.Identifier.ValueText + " ";
    }
    else
    {
        node.ToString();
    }
}

After calling CSharpSyntaxTree.ParseText(), the SyntaxNodes are enumerated, and an updated list of keywords and types created. The UpdateEditor() method of all open EditorForm windows is then invoked, which in turn updates the Scintilla editor:

C#
for (int i = 0; i < Application.OpenForms.Count; i++)
{
    if (!(Application.OpenForms[i] is BaseForm form) || form.Type != WindowType.SourceFile) continue;
    form.Invoke(new MethodInvoker(((EditorForm)form).UpdateEditor));
}
C#
internal void UpdateEditor()
{
    Editor.SetKeywords(1, Program.Parser.KnownKeywords + " " + Program.Parser.ParsedKeywords);
    Editor.SetKeywords(3, Program.Parser.KnownTypes + " " + Program.Parser.ParsedTypes);
}

A Live Preview of the Sketch as the Code is Modified

Image 4

I was very excited to implement this feature. While developing SketchIt, and testing functions available (from within the IDE), it quickly became a boring task to make a change in the API, then launch the IDE, write or paste some code to test a function, then compile to see the resulting behaviour (Edit and Continue is not applicable in this case, since changes during Edit and Continue will not be included in the compiled output sketch). So implementing the Live Preview function removed the tediousness of testing new functions. Implementing it wasn't too difficult, but it had a couple of challenges:

  • Compile the code in the background without blocking the IDE
  • Once the code is compiled, update the preview pane
  • Stretch/Scale the canvas to fit inside the preview pane (a fullscreen stretch introduced some issues)
  • Cross-thread issue while updating the preview pane
  • Making the preview pane interactive (respond to mouse/keyboard events)

The EditorForm checks when text inside the code editor changes, and if so, notifies the MainForm:

C#
private void EditorTextChanged(object sender, EventArgs e)
{
    EditorText = Editor.Text;
    Program.MainForm.CodeChanged(this);
}

The MainForm then resets a timer which starts the background compiler (we do not compile every time the text changes, we do allow a few milliseconds before doing so):

C#
private void PreviewTimerElapsed(object sender, EventArgs e)
{
    _previewTimer.Stop();
    BuildPreview();
}

And the BuildPreview method kicks off a BackgroundWorker to compile the code:

C#
private void BuildPreview()
{
    BackgroundWorker worker = new BackgroundWorker();
    worker.DoWork += Worker_DoWork;
    worker.RunWorkerCompleted += Worker_RunWorkerCompleted;
    worker.RunWorkerAsync();
}

Code is compiled in the background using the BackgrounWorker class by invoking the Compile function mentioned in the previous point. For the live preview pane, once compilation has completed, the existing (if any) live preview is disposed, and a new instance of the compiled assembly is instantiated and the live preview is updated. Many cross-thread exceptions were resolved implementing a ThreadLocker class within the API. Inside the API, a number of operations are wrapped inside a using (ThreadLocker...) code block, though, I think this approach needs some revisiting. The interactive preview pane also introduced cross-thread issues, but the ThreadLocker class helped solve these issue. Scaling of the sketch also had to be accounted for in the interactive preview pane.

Apart from the minor obstacles implementing this feature, I found it very useful once implemented. I one day sat with my youngest son (12 year old), who isn't into programming (at the moment), and once he saw that when he changes something in the code editor, it immediately reflects in the preview pane, he was more excited about trying out some code.

A Proper Text Editor, with All the Bells and Whistles Expected from a Modern Editor

Image 5

Some improvements are in order, but for the time being, what we've got is workable. Thanks to ScintillaNET, this wasn't too difficult to implement. Looking up the Scintilla documentation is pretty simple. The autocomplete functionality in Scintilla is very basic, so an alternative solution needs to be implemented for this. The SketchIt editor inherits the Scintilla class:

C#
public class EditorControl : Scintilla
{
    private string _keywordList = "class extends implements import ...(removed for article purposes)";
 
    public EditorControl()
    {
        string list = "";
 
        foreach (string word in _keywordList.Split(new char[] { ' ' }))
        {
            if (list.IndexOf(word + " ") > -1) continue;
            list += word + " ";
        }
 
        _keywordList = list.Trim();
 
        Lexer = Lexer.Cpp;
        BorderStyle = System.Windows.Forms.BorderStyle.None;
        UsePopup(PopupMode.All);
        AutoCIgnoreCase = true;
        AutoCMaxHeight = 15;
        AutoCOrder = Order.PerformSort;
 
        UpdateAppearance();

The editor checks keystrokes, and tries to show autocomplete popups when applicable based on the current context in the code, e.g.: when the period character is added, a list of members should appear:

C#
case '.':
    string[] members = Program.Parser.GetMembers(CurrentPosition - 1);
 
    if (members.Length > 0)
    {
        string list = " ";
 
        foreach (string member in members)
        {
            if (list.IndexOf(" " + member + " ") == -1)
            {
                list += member + " ";
            }
        }
 
        AutoCShow(0, list.Trim());
    }

A 3D Renderer

This feature is still outstanding. I have started playing with an OpenGL implementation using the SharpGL library, but it is incomplete - my knowledge on OpenGL is just too limited at the moment.

Available on Mac/Linux

Completely outstanding. I hope that some developers will get involved and help to make this possible.

And Some Other, Minor Challenges

I wanted to give the IDE a bit more of a modern look and feel, instead of just a plain form with a text editor. For the menus and toolbars, it was easily done implementing a custom ToolStripRenderer. Other controls such as TextBox or ComboBox controls are wrapped with a class I created called VisualWrapper. This class does nothing more really than changing borders and colors, depending on which control is active. A very basic TabButtonsControl class has been created for the "tabbed windows". Overall, I think the results are satisfying for now.

I'm also making use of the FileSysemWatcher class to monitor changes to the settings.json file. When this file changes, the appearance settings are reapplied (the application does not need to be restarted). Something small, but it makes things a little more user friendly.

Finally

This article is purely a brief introduction to SketchIt, and hopefully enough to get some of you interested in using it, or even getting involved with the development. I simply do not have enough time to take this project to the next level all by myself. Apart from the IDE issues mentioned above, I would also like to:

  1. Implement a 3D renderer. I have played around with an OpenGL implementation using the SharpGL library, but my OpenGL knowledge is close to non-existent.
  2. Make SketchIt available on platforms other than Windows.
  3. Many, many more...

Here is a list of libraries/tools/icons used to create SketchIt:

  • ScintillaNET for the text editor
  • Roslyn for code compilation and analysis
  • Emgu.CV for the video capture library
  • Newtonsoft.Json to read/write JSON data
  • Sandcastle Help File Builder GUI to build the help file
  • Icons8 for the majority of the icons
  • Microsoft Visual Studio Community 2017
  • And a couple of functions/code snippets from the web which is noted in the source code

Download and Links

History

  • 2018-08-03: Initial article

License

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


Written By
Software Developer
South Africa South Africa
This member has not yet provided a Biography. Assume it's interesting and varied, and probably something to do with programming.

Comments and Discussions

 
GeneralMy vote of 5 Pin
Degryse Kris7-Aug-18 0:13
Degryse Kris7-Aug-18 0:13 
GeneralRe: My vote of 5 Pin
Ian Kloppers7-Aug-18 8:53
Ian Kloppers7-Aug-18 8:53 
QuestionVery nice Article Pin
kin3tik5-Aug-18 23:39
kin3tik5-Aug-18 23:39 
AnswerRe: Very nice Article Pin
Ian Kloppers7-Aug-18 8:52
Ian Kloppers7-Aug-18 8:52 
QuestionMuch better Pin
Pete O'Hanlon3-Aug-18 4:34
mvePete O'Hanlon3-Aug-18 4:34 
AnswerRe: Much better Pin
Ian Kloppers3-Aug-18 20:01
Ian Kloppers3-Aug-18 20:01 

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

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