Friday, February 17, 2012

What does #deadbeef; look like?

I've been working with WPF themes a lot this week. My task has been to take a theme from one application and put it into another, otherwise unrelated, application. This is not as easy as it sounds. The themes from the original application do not transplant to other applications without judicious use of a hacksaw.

While going through the theme's various XAML files I noticed things like color="#FF123456", and I looked and I couldn't figure out what colour I was looking at. There are a lot of these hex notation colours and they all seemed opaque to me.

It struck me that it would be nice if I could just hover my mouse over the hex and get the colour to pop up. Sounded like an easy enough task. So I set out to write an extension for Visual Studio to do just that.

My first attempt to write an extension to Firefox met with disappointment - I couldn't figure out how to get started - so I was a little apprehensive about writing an extension for Visual Studio. Luckily extensions for Visual Studio are easy to create (so long as you have Visual Studio).

  1. Download the Visual Studio 2010 SDK (or this one for Service Pack 1)
  2. Install the SDK
  3. Start a new project by selecting from the C#/Extensibility templates - I chose Editor Text Adornment, because I wanted to adorn the editor text with something.
The project comes with code already in place, so you can just hit F5 and you'll be able to see the extension at work right away. Then read the code to see how it works! It's pretty obvious, and with the Intellisense of Visual Studio you can discover all the bits you'll need with ease.

So my task, now that I have the ability to write an extension, was to write an extension that does what I want - i.e. show a colour swatch of the hex notation I'm hovering over.

Step 1: create a regex that picks out the hex. I tried one or two and settled on this one: #(([0-9A-F]{6})|([0-9A-F]{8})|([0-9A-F]{3}))["<;]. There might be ways to write it shorter, and I'm willing to hear them, but I'm not a regex guru, so I'll stick with simple. You'll notice that I've constrained the hex to start with a # and end with ", <, or ;. This way the regex will only pick up hex that is the right length, and not any old length, and is most likely meant to be a colour. All the colour hexes I could see ended in ", < or ;. I could have missed an edge case, but not so far!

Step 2: turn that string into a colour. There might be a library function for doing this, but I couldn't find it (would be glad if someone were to tell me about it!). I wrote my own:

private Tuple<byte, byte, byte, byte> BytesFromColourString(string colour)
{
    string alpha;
    string red;
    string green;
    string blue;

    if (colour.Length == 8)
    {
        alpha = colour.Substring(0, 2);
        red = colour.Substring(2, 2);
        green = colour.Substring(4, 2);
        blue = colour.Substring(6, 2);
    }
    else if (colour.Length == 6)
    {
        red = colour.Substring(0, 2);
        green = colour.Substring(2, 2);
        blue = colour.Substring(4, 2);
        alpha = "FF";
    }
    else if (colour.Length == 3)
    {
        red = colour.Substring(0, 1) + colour.Substring(0, 1);
        green = colour.Substring(1, 1) + colour.Substring(1, 1);
        blue = colour.Substring(2, 1) + colour.Substring(2, 1);
        alpha = "FF";
    }
    else
    {
    throw new ArgumentException(String.Format("The colour string may be 8, 6 or 3 characters long, the one passed in is {0}", colour.Length));
    }
    return new Tuple<byte, byte, byte, byte>( Convert.ToByte(alpha, 16)
                                            , Convert.ToByte(red, 16)
                                            , Convert.ToByte(green, 16)
                                            , Convert.ToByte(blue, 16));
}

OK, so this actually returns a Tuple<byte, byte, byte, byte>. I'm not entirely sure why I chose that over returning an actual colour. I might refactor that later. Anyway, turning the tuple into a System.Windows.Media.Color is a trivial call to the static method Color.FromArgb(byte, byte, byte, byte). Also, the above method is a brute force approach to breaking down the colour string into bytes, there could well be a better way. I'm sticking with what works until I'm shown something better.

My next hurdle was figuring out how to place the colour swatch where I wanted it. I was able to return the position in text the mouse was hovering over, which would give me a single character, but I couldn't think of how to use that position and character to get the hex colour string.

In the end I opted for a two stage approach. Stage one: when the layout updates, find the start and end positions for any colours in the view. Stage two: when the mouse is hovering somewhere, see if it's position is in any of the ranges previously stored.

Stage one looks like this:
private void OnLayoutChanged(object sender, TextViewLayoutChangedEventArgs e)
{
    _colourPositions = new List<Tuple<int, int, Color>>();
    var matches = Regex.Matches(_view.TextSnapshot.GetText(), "#(([0-9A-F]{6})|([0-9A-F]{8})|([0-9A-F]{3}))[\"<;]", RegexOptions.IgnoreCase);
    foreach(var m in matches)
    {
        var match = m as Match;
        var mgrp = match.Groups[1] as Group;
        var colourbytes = BytesFromColourString(mgrp.Value);
        var colour = Color.FromArgb(colourbytes.Item1, colourbytes.Item2, colourbytes.Item3, colourbytes.Item4);
        _colourPositions.Add(new Tuple<int,int,Color>(mgrp.Index, mgrp.Index + mgrp.Length, colour));
    }
}
I went with a list to store the position of the colours because I think it makes cleaner code than a dictionary would.
Stage two's like this:
private void ShowColourSwatch(int position, IMappingPoint textPosition, ITextView textView)
{
    _layer.RemoveAllAdornments();
    SnapshotPoint? snapPoint = textPosition.GetPoint(textPosition.AnchorBuffer, PositionAffinity.Predecessor);
    if (snapPoint.HasValue)
    {
        SnapshotSpan charSpan = textView.GetTextElementSpan(snapPoint.Value);
        var colourPos = _colourPositions.Find(cp => (cp.Item1 <= charSpan.Start) && (cp.Item2 >= charSpan.Start));
        if(colourPos != null)
        {
            Image image = CreateSwatchImage(colourPos, charSpan);

            _layer.AddAdornment(AdornmentPositioningBehavior.TextRelative, charSpan, null, image, null);
            Thread t = new Thread(p =>
            {
                Thread.Sleep(3500);
                lock (lockObject)
                {
                    Application.Current.Dispatcher.Invoke(new Action(() =>
                    {
                        _layer.RemoveAdornmentsByVisualSpan(charSpan);
                    }), new object[]{});
                }
            });
            t.Start();
        }
    }
}

The Thread in there just makes sure that the colour swatch disappears after three and a half seconds. CreateSwatchImage uses a lot of the code from the example project that Visual Studio gives you to start with, and just draws the colour swatch on a black and white background for contrast.

That is pretty much all the important code that I wrote in constructing the extension. There is one last snippet, I had to modify a single line in the auto-generated factory class so that the swatch would be above the text: [Order(After = PredefinedAdornmentLayers.Text, Before = PredefinedAdornmentLayers.Caret)]. Before that the property made the adornment go behind the text, which looked silly for my purposes.

The last thing that tripped me up was installing the extension. Obviously I can't sign my extension because I'm too cheap to pay for a certificate to do that with, so I can't get it put on the online extensions thing. However I was sure I could find a way. My first attempt was to double click on the .vsix file that Visual Studio had generated for me. This looked promising - it ran me through an install process and told me it had been successful, so I loaded up Visual Studio but my extension was no where to be found. I tried rebooting my computer, just in case, but to no avail. So I sought out where the extension had been placed and deleted it - which is how you are meant to uninstall extension, by the way - and went online to find out The Right Way™. A few places told me to put the extension in a folder under %appdata%, but that didn't seem to work. Eventually I found an MSDN page that explained I should be putting it under %localappdata%, which sorted me right out. Essentially the path should go something like %localappdata%Microsoft\VisualStudio\10.0\Extensions\[company]\[extensionName]\[version]\ although you can probably leave out [company] and [version] and it will still work. Once I put the extension there and loaded up Visual Studio, I checked the Extensions Manager in the tools menu and it was there, but needed enabling. After being enabled, and restarting Visual Studio, the extension was working like a charm! No more wondering about what a hex colour string means for me.

what #deadbeef; looks like


To view all the code for my extension, and download it for yourself, visit my Github repository.

No comments: