From bcfc495843facf7a4bf066bb8317f14dc8190fbe Mon Sep 17 00:00:00 2001
From: Patrik Svensson <patrik@patriksvensson.se>
Date: Tue, 11 Aug 2020 07:54:00 +0200
Subject: [PATCH] Add support for hex colors

Closes #33
---
 src/Spectre.Console.Tests/Unit/StyleTests.cs  | 118 +++++-------------
 .../Internal/Text/StyleParser.cs              |  53 +++++++-
 2 files changed, 80 insertions(+), 91 deletions(-)

diff --git a/src/Spectre.Console.Tests/Unit/StyleTests.cs b/src/Spectre.Console.Tests/Unit/StyleTests.cs
index 0efa9dd..3280373 100644
--- a/src/Spectre.Console.Tests/Unit/StyleTests.cs
+++ b/src/Spectre.Console.Tests/Unit/StyleTests.cs
@@ -126,108 +126,54 @@ namespace Spectre.Console.Tests.Unit
                 result.ShouldBeOfType<InvalidOperationException>();
                 result.Message.ShouldBe("Could not find color 'lol'.");
             }
+
+            [Theory]
+            [InlineData("#FF0000 on #0000FF")]
+            [InlineData("#F00 on #00F")]
+            public void Should_Parse_Hex_Colors_Correctly(string style)
+            {
+                // Given, When
+                var result = Style.Parse(style);
+
+                // Then
+                result.Foreground.ShouldBe(Color.Red);
+                result.Background.ShouldBe(Color.Blue);
+            }
+
+            [Theory]
+            [InlineData("#", "Invalid hex color '#'.")]
+            [InlineData("#FF00FF00FF", "Invalid hex color '#FF00FF00FF'.")]
+            [InlineData("#FOO", "Invalid hex color '#FOO'. Could not find any recognizable digits.")]
+            public void Should_Return_Error_If_Hex_Color_Is_Invalid(string style, string expected)
+            {
+                // Given, When
+                var result = Record.Exception(() => Style.Parse(style));
+
+                // Then
+                result.ShouldNotBeNull();
+                result.Message.ShouldBe(expected);
+            }
         }
 
         public sealed class TheTryParseMethod
         {
             [Fact]
-            public void Default_Keyword_Should_Return_Default_Style()
+            public void Should_Return_True_If_Parsing_Succeeded()
             {
                 // Given, When
-                var result = Style.TryParse("default", out var style);
+                var result = Style.TryParse("bold", out var style);
 
                 // Then
                 result.ShouldBeTrue();
                 style.ShouldNotBeNull();
-                style.Foreground.ShouldBe(Color.Default);
-                style.Background.ShouldBe(Color.Default);
-                style.Decoration.ShouldBe(Decoration.None);
-            }
-
-            [Theory]
-            [InlineData("bold", Decoration.Bold)]
-            [InlineData("dim", Decoration.Dim)]
-            [InlineData("italic", Decoration.Italic)]
-            [InlineData("underline", Decoration.Underline)]
-            [InlineData("invert", Decoration.Invert)]
-            [InlineData("conceal", Decoration.Conceal)]
-            [InlineData("slowblink", Decoration.SlowBlink)]
-            [InlineData("rapidblink", Decoration.RapidBlink)]
-            [InlineData("strikethrough", Decoration.Strikethrough)]
-            public void Should_Parse_Decoration(string text, Decoration decoration)
-            {
-                // Given, When
-                var result = Style.TryParse(text, out var style);
-
-                // Then
-                result.ShouldBeTrue();
-                style.ShouldNotBeNull();
-                style.Decoration.ShouldBe(decoration);
+                style.Decoration.ShouldBe(Decoration.Bold);
             }
 
             [Fact]
-            public void Should_Parse_Text_And_Decoration()
+            public void Should_Return_False_If_Parsing_Failed()
             {
                 // Given, When
-                var result = Style.TryParse("bold underline blue on green", out var style);
-
-                // Then
-                result.ShouldBeTrue();
-                style.ShouldNotBeNull();
-                style.Decoration.ShouldBe(Decoration.Bold | Decoration.Underline);
-                style.Foreground.ShouldBe(Color.Blue);
-                style.Background.ShouldBe(Color.Green);
-            }
-
-            [Fact]
-            public void Should_Parse_Background_If_Foreground_Is_Set_To_Default()
-            {
-                // Given, When
-                var result = Style.TryParse("default on green", out var style);
-
-                // Then
-                result.ShouldBeTrue();
-                style.ShouldNotBeNull();
-                style.Decoration.ShouldBe(Decoration.None);
-                style.Foreground.ShouldBe(Color.Default);
-                style.Background.ShouldBe(Color.Green);
-            }
-
-            [Fact]
-            public void Should_Throw_If_Foreground_Is_Set_Twice()
-            {
-                // Given, When
-                var result = Style.TryParse("green yellow", out _);
-
-                // Then
-                result.ShouldBeFalse();
-            }
-
-            [Fact]
-            public void Should_Throw_If_Background_Is_Set_Twice()
-            {
-                // Given, When
-                var result = Style.TryParse("green on blue yellow", out _);
-
-                // Then
-                result.ShouldBeFalse();
-            }
-
-            [Fact]
-            public void Should_Throw_If_Color_Name_Could_Not_Be_Found()
-            {
-                // Given, When
-                var result = Style.TryParse("bold lol", out _);
-
-                // Then
-                result.ShouldBeFalse();
-            }
-
-            [Fact]
-            public void Should_Throw_If_Background_Color_Name_Could_Not_Be_Found()
-            {
-                // Given, When
-                var result = Style.TryParse("blue on lol", out _);
+                var result = Style.TryParse("lol", out _);
 
                 // Then
                 result.ShouldBeFalse();
diff --git a/src/Spectre.Console/Internal/Text/StyleParser.cs b/src/Spectre.Console/Internal/Text/StyleParser.cs
index efcdf4a..d432e91 100644
--- a/src/Spectre.Console/Internal/Text/StyleParser.cs
+++ b/src/Spectre.Console/Internal/Text/StyleParser.cs
@@ -57,16 +57,22 @@ namespace Spectre.Console.Internal
                     var color = ColorTable.GetColor(part);
                     if (color == null)
                     {
-                        if (!foreground)
+                        if (part.StartsWith("#", StringComparison.OrdinalIgnoreCase))
                         {
-                            error = $"Could not find color '{part}'.";
+                            color = ParseHexColor(part, out error);
+                            if (!string.IsNullOrWhiteSpace(error))
+                            {
+                                return null;
+                            }
                         }
                         else
                         {
-                            error = $"Could not find color or style '{part}'.";
-                        }
+                            error = !foreground
+                                ? $"Could not find color '{part}'."
+                                : $"Could not find color or style '{part}'.";
 
-                        return null;
+                            return null;
+                        }
                     }
 
                     if (foreground)
@@ -95,5 +101,42 @@ namespace Spectre.Console.Internal
             error = null;
             return new Style(effectiveForeground, effectiveBackground, effectiveDecoration);
         }
+
+        private static Color? ParseHexColor(string hex, out string error)
+        {
+            error = null;
+
+            hex = hex ?? string.Empty;
+            hex = hex.Replace("#", string.Empty).Trim();
+
+            try
+            {
+                if (!string.IsNullOrWhiteSpace(hex))
+                {
+                    if (hex.Length == 6)
+                    {
+                        return new Color(
+                            (byte)Convert.ToUInt32(hex.Substring(0, 2), 16),
+                            (byte)Convert.ToUInt32(hex.Substring(2, 2), 16),
+                            (byte)Convert.ToUInt32(hex.Substring(4, 2), 16));
+                    }
+                    else if (hex.Length == 3)
+                    {
+                        return new Color(
+                            (byte)Convert.ToUInt32(new string(hex[0], 2), 16),
+                            (byte)Convert.ToUInt32(new string(hex[1], 2), 16),
+                            (byte)Convert.ToUInt32(new string(hex[2], 2), 16));
+                    }
+                }
+            }
+            catch (Exception ex)
+            {
+                error = $"Invalid hex color '#{hex}'. {ex.Message}";
+                return null;
+            }
+
+            error = $"Invalid hex color '#{hex}'.";
+            return null;
+        }
     }
 }