diff --git a/samples/ControlCatalog/Pages/CanvasPage.xaml b/samples/ControlCatalog/Pages/CanvasPage.xaml index 747df593b3..416164f391 100644 --- a/samples/ControlCatalog/Pages/CanvasPage.xaml +++ b/samples/ControlCatalog/Pages/CanvasPage.xaml @@ -14,7 +14,7 @@ - + diff --git a/src/Avalonia.Build.Tasks/Avalonia.Build.Tasks.csproj b/src/Avalonia.Build.Tasks/Avalonia.Build.Tasks.csproj index 5a7daa6d12..9629324c8d 100644 --- a/src/Avalonia.Build.Tasks/Avalonia.Build.Tasks.csproj +++ b/src/Avalonia.Build.Tasks/Avalonia.Build.Tasks.csproj @@ -83,6 +83,9 @@ Markup/%(RecursiveDir)%(FileName)%(Extension) + + Markup/%(RecursiveDir)%(FileName)%(Extension) + Markup/%(RecursiveDir)%(FileName)%(Extension) diff --git a/src/Avalonia.Visuals/Media/Color.cs b/src/Avalonia.Visuals/Media/Color.cs index aa730b3219..eaa886ccbd 100644 --- a/src/Avalonia.Visuals/Media/Color.cs +++ b/src/Avalonia.Visuals/Media/Color.cs @@ -1,3 +1,10 @@ +// Color conversion portions of this source file are adapted from the WinUI project +// (https://github.com/microsoft/microsoft-ui-xaml) +// and the Windows Community Toolkit project. +// (https://github.com/CommunityToolkit/WindowsCommunityToolkit) +// +// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. + using System; using System.Globalization; #if !BUILDTASK @@ -14,6 +21,8 @@ namespace Avalonia.Media #endif readonly struct Color : IEquatable { + private const double byteToDouble = 1.0 / 255; + static Color() { #if !BUILDTASK @@ -41,6 +50,13 @@ namespace Avalonia.Media /// public byte B { get; } + /// + /// Initializes a new instance of the struct. + /// + /// The alpha component. + /// The red component. + /// The green component. + /// The blue component. public Color(byte a, byte r, byte g, byte b) { A = a; @@ -144,17 +160,46 @@ namespace Avalonia.Media return false; } - if (s[0] == '#' && TryParseInternal(s.AsSpan(), out color)) + if (s[0] == '#' && + TryParseHexFormat(s.AsSpan(), out color)) { return true; } + if (s.Length > 5 && + (s[0] == 'r' || s[0] == 'R') && + (s[1] == 'g' || s[1] == 'G') && + (s[2] == 'b' || s[2] == 'B') && + TryParseCssFormat(s, out color)) + { + return true; + } + + if (s.Length > 5 && + (s[0] == 'h' || s[0] == 'H') && + (s[1] == 's' || s[1] == 'S') && + (s[2] == 'l' || s[2] == 'L') && + HslColor.TryParse(s, out HslColor hslColor)) + { + color = hslColor.ToRgb(); + return true; + } + + if (s.Length > 5 && + (s[0] == 'h' || s[0] == 'H') && + (s[1] == 's' || s[1] == 'S') && + (s[2] == 'v' || s[2] == 'V') && + HsvColor.TryParse(s, out HsvColor hsvColor)) + { + color = hsvColor.ToRgb(); + return true; + } + var knownColor = KnownColors.GetKnownColor(s); if (knownColor != KnownColor.None) { color = knownColor.ToColor(); - return true; } @@ -172,21 +217,52 @@ namespace Avalonia.Media if (s.Length == 0) { color = default; - return false; } - if (s[0] == '#') + if (s[0] == '#' && + TryParseHexFormat(s, out color)) + { + return true; + } + + // At this point all parsing uses strings + var str = s.ToString(); + + if (s.Length > 5 && + (s[0] == 'r' || s[0] == 'R') && + (s[1] == 'g' || s[1] == 'G') && + (s[2] == 'b' || s[2] == 'B') && + TryParseCssFormat(str, out color)) + { + return true; + } + + if (s.Length > 5 && + (s[0] == 'h' || s[0] == 'H') && + (s[1] == 's' || s[1] == 'S') && + (s[2] == 'l' || s[2] == 'L') && + HslColor.TryParse(str, out HslColor hslColor)) { - return TryParseInternal(s, out color); + color = hslColor.ToRgb(); + return true; } - var knownColor = KnownColors.GetKnownColor(s.ToString()); + if (s.Length > 5 && + (s[0] == 'h' || s[0] == 'H') && + (s[1] == 's' || s[1] == 'S') && + (s[2] == 'v' || s[2] == 'V') && + HsvColor.TryParse(str, out HsvColor hsvColor)) + { + color = hsvColor.ToRgb(); + return true; + } + + var knownColor = KnownColors.GetKnownColor(str); if (knownColor != KnownColor.None) { color = knownColor.ToColor(); - return true; } @@ -195,7 +271,7 @@ namespace Avalonia.Media return false; } - private static bool TryParseInternal(ReadOnlySpan s, out Color color) + private static bool TryParseHexFormat(ReadOnlySpan s, out Color color) { static bool TryParseCore(ReadOnlySpan input, ref Color color) { @@ -249,6 +325,91 @@ namespace Avalonia.Media return TryParseCore(input, ref color); } + private static bool TryParseCssFormat(string s, out Color color) + { + color = default; + + if (s is null) + { + return false; + } + + string workingString = s.Trim(); + + if (workingString.Length == 0 || + workingString.IndexOf(",", StringComparison.Ordinal) < 0) + { + return false; + } + + if (workingString.Length > 6 && + workingString.StartsWith("rgba(", StringComparison.OrdinalIgnoreCase) && + workingString.EndsWith(")", StringComparison.Ordinal)) + { + workingString = workingString.Substring(5, workingString.Length - 6); + } + + if (workingString.Length > 5 && + workingString.StartsWith("rgb(", StringComparison.OrdinalIgnoreCase) && + workingString.EndsWith(")", StringComparison.Ordinal)) + { + workingString = workingString.Substring(4, workingString.Length - 5); + } + + string[] components = workingString.Split(','); + + if (components.Length == 3) // RGB + { + if (byte.TryParse(components[0], NumberStyles.Number, CultureInfo.InvariantCulture, out byte red) && + byte.TryParse(components[1], NumberStyles.Number, CultureInfo.InvariantCulture, out byte green) && + byte.TryParse(components[2], NumberStyles.Number, CultureInfo.InvariantCulture, out byte blue)) + { + color = new Color(0xFF, red, green, blue); + return true; + } + } + else if (components.Length == 4) // RGBA + { + if (byte.TryParse(components[0], NumberStyles.Number, CultureInfo.InvariantCulture, out byte red) && + byte.TryParse(components[1], NumberStyles.Number, CultureInfo.InvariantCulture, out byte green) && + byte.TryParse(components[2], NumberStyles.Number, CultureInfo.InvariantCulture, out byte blue) && + TryInternalParse(components[3], out double alpha)) + { + color = new Color((byte)(alpha * 255), red, green, blue); + return true; + } + } + + // Local function to specially parse a double value with an optional percentage sign + bool TryInternalParse(string inString, out double outDouble) + { + // The percent sign, if it exists, must be at the end of the number + int percentIndex = inString.IndexOf("%", StringComparison.Ordinal); + + if (percentIndex >= 0) + { + var result = double.TryParse( + inString.Substring(0, percentIndex), + NumberStyles.Number, + CultureInfo.InvariantCulture, + out double percentage); + + outDouble = percentage / 100.0; + return result; + } + else + { + return double.TryParse( + inString, + NumberStyles.Number, + CultureInfo.InvariantCulture, + out outDouble); + } + } + + return false; + } + /// /// Returns the string representation of the color. /// @@ -272,15 +433,24 @@ namespace Avalonia.Media return ((uint)A << 24) | ((uint)R << 16) | ((uint)G << 8) | (uint)B; } + /// + /// Returns the HSL color model equivalent of this RGB color. + /// + /// The HSL equivalent color. + public HslColor ToHsl() + { + // Don't use the HslColor(Color) constructor to avoid an extra HslColor + return Color.ToHsl(R, G, B, A); + } + /// /// Returns the HSV color model equivalent of this RGB color. /// /// The HSV equivalent color. public HsvColor ToHsv() { - // Use the by-channel conversion method directly for performance // Don't use the HsvColor(Color) constructor to avoid an extra HsvColor - return HsvColor.FromRgb(R, G, B, A); + return Color.ToHsv(R, G, B, A); } /// @@ -289,11 +459,13 @@ namespace Avalonia.Media return A == other.A && R == other.R && G == other.G && B == other.B; } + /// public override bool Equals(object? obj) { return obj is Color other && Equals(other); } + /// public override int GetHashCode() { unchecked @@ -306,11 +478,261 @@ namespace Avalonia.Media } } + /// + /// Converts the given RGB color to its HSL color equivalent. + /// + /// The color in the RGB color model. + /// A new equivalent to the given RGBA values. + public static HslColor ToHsl(Color color) + { + // Normalize RGBA components into the 0..1 range + return Color.ToHsl( + (byteToDouble * color.R), + (byteToDouble * color.G), + (byteToDouble * color.B), + (byteToDouble * color.A)); + } + + /// + /// Converts the given RGBA color component values to their HSL color equivalent. + /// + /// The Red component in the RGB color model. + /// The Green component in the RGB color model. + /// The Blue component in the RGB color model. + /// The Alpha component. + /// A new equivalent to the given RGBA values. + public static HslColor ToHsl( + byte red, + byte green, + byte blue, + byte alpha = 0xFF) + { + // Normalize RGBA components into the 0..1 range + return Color.ToHsl( + (byteToDouble * red), + (byteToDouble * green), + (byteToDouble * blue), + (byteToDouble * alpha)); + } + + /// + /// Converts the given RGBA color component values to their HSL color equivalent. + /// + /// + /// Warning: No bounds checks or clamping is done on the input component values. + /// This method is for internal-use only and the caller must ensure bounds. + /// + /// The Red component in the RGB color model within the range 0..1. + /// The Green component in the RGB color model within the range 0..1. + /// The Blue component in the RGB color model within the range 0..1. + /// The Alpha component in the RGB color model within the range 0..1. + /// A new equivalent to the given RGBA values. + internal static HslColor ToHsl( + double r, + double g, + double b, + double a = 1.0) + { + // Note: Conversion code is originally based on ColorHelper in the Windows Community Toolkit (licensed MIT) + // https://github.com/CommunityToolkit/WindowsCommunityToolkit/blob/main/Microsoft.Toolkit.Uwp/Helpers/ColorHelper.cs + // It has been modified. + + double max = r >= g ? (r >= b ? r : b) : (g >= b ? g : b); + double min = r <= g ? (r <= b ? r : b) : (g <= b ? g : b); + double chroma = max - min; + double h1; + + if (chroma == 0) + { + h1 = 0; + } + else if (max == r) + { + // The % operator doesn't do proper modulo on negative + // numbers, so we'll add 6 before using it + h1 = (((g - b) / chroma) + 6) % 6; + } + else if (max == g) + { + h1 = 2 + ((b - r) / chroma); + } + else + { + h1 = 4 + ((r - g) / chroma); + } + + double lightness = 0.5 * (max + min); + double saturation = chroma == 0 ? 0 : chroma / (1 - Math.Abs((2 * lightness) - 1)); + + return new HslColor(a, 60 * h1, saturation, lightness, clampValues: false); + } + + /// + /// Converts the given RGB color to its HSV color equivalent. + /// + /// The color in the RGB color model. + /// A new equivalent to the given RGBA values. + public static HsvColor ToHsv(Color color) + { + // Normalize RGBA components into the 0..1 range + return Color.ToHsv( + (byteToDouble * color.R), + (byteToDouble * color.G), + (byteToDouble * color.B), + (byteToDouble * color.A)); + } + + /// + /// Converts the given RGBA color component values to their HSV color equivalent. + /// + /// The Red component in the RGB color model. + /// The Green component in the RGB color model. + /// The Blue component in the RGB color model. + /// The Alpha component. + /// A new equivalent to the given RGBA values. + public static HsvColor ToHsv( + byte red, + byte green, + byte blue, + byte alpha = 0xFF) + { + // Normalize RGBA components into the 0..1 range + return Color.ToHsv( + (byteToDouble * red), + (byteToDouble * green), + (byteToDouble * blue), + (byteToDouble * alpha)); + } + + /// + /// Converts the given RGBA color component values to their HSV color equivalent. + /// + /// + /// Warning: No bounds checks or clamping is done on the input component values. + /// This method is for internal-use only and the caller must ensure bounds. + /// + /// The Red component in the RGB color model within the range 0..1. + /// The Green component in the RGB color model within the range 0..1. + /// The Blue component in the RGB color model within the range 0..1. + /// The Alpha component in the RGB color model within the range 0..1. + /// A new equivalent to the given RGBA values. + internal static HsvColor ToHsv( + double r, + double g, + double b, + double a = 1.0) + { + // Note: Conversion code is originally based on the C++ in WinUI (licensed MIT) + // https://github.com/microsoft/microsoft-ui-xaml/blob/main/dev/Common/ColorConversion.cpp + // This was used because it is the best documented and likely most optimized for performance + // Alpha support was added + + double hue; + double saturation; + double value; + + double max = r >= g ? (r >= b ? r : b) : (g >= b ? g : b); + double min = r <= g ? (r <= b ? r : b) : (g <= b ? g : b); + + // The value, a number between 0 and 1, is the largest of R, G, and B (divided by 255). + // Conceptually speaking, it represents how much color is present. + // If at least one of R, G, B is 255, then there exists as much color as there can be. + // If RGB = (0, 0, 0), then there exists no color at all - a value of zero corresponds + // to black (i.e., the absence of any color). + value = max; + + // The "chroma" of the color is a value directly proportional to the extent to which + // the color diverges from greyscale. If, for example, we have RGB = (255, 255, 0), + // then the chroma is maximized - this is a pure yellow, no gray of any kind. + // On the other hand, if we have RGB = (128, 128, 128), then the chroma being zero + // implies that this color is pure greyscale, with no actual hue to be found. + var chroma = max - min; + + // If the chrome is zero, then hue is technically undefined - a greyscale color + // has no hue. For the sake of convenience, we'll just set hue to zero, since + // it will be unused in this circumstance. Since the color is purely gray, + // saturation is also equal to zero - you can think of saturation as basically + // a measure of hue intensity, such that no hue at all corresponds to a + // nonexistent intensity. + if (chroma == 0) + { + hue = 0.0; + saturation = 0.0; + } + else + { + // In this block, hue is properly defined, so we'll extract both hue + // and saturation information from the RGB color. + + // Hue can be thought of as a cyclical thing, between 0 degrees and 360 degrees. + // A hue of 0 degrees is red; 120 degrees is green; 240 degrees is blue; and 360 is back to red. + // Every other hue is somewhere between either red and green, green and blue, and blue and red, + // so every other hue can be thought of as an angle on this color wheel. + // These if/else statements determines where on this color wheel our color lies. + if (r == max) + { + // If the red channel is the most pronounced channel, then we exist + // somewhere between (-60, 60) on the color wheel - i.e., the section around 0 degrees + // where red dominates. We figure out where in that section we are exactly + // by considering whether the green or the blue channel is greater - by subtracting green from blue, + // then if green is greater, we'll nudge ourselves closer to 60, whereas if blue is greater, then + // we'll nudge ourselves closer to -60. We then divide by chroma (which will actually make the result larger, + // since chroma is a value between 0 and 1) to normalize the value to ensure that we get the right hue + // even if we're very close to greyscale. + hue = 60 * (g - b) / chroma; + } + else if (g == max) + { + // We do the exact same for the case where the green channel is the most pronounced channel, + // only this time we want to see if we should tilt towards the blue direction or the red direction. + // We add 120 to center our value in the green third of the color wheel. + hue = 120 + (60 * (b - r) / chroma); + } + else // blue == max + { + // And we also do the exact same for the case where the blue channel is the most pronounced channel, + // only this time we want to see if we should tilt towards the red direction or the green direction. + // We add 240 to center our value in the blue third of the color wheel. + hue = 240 + (60 * (r - g) / chroma); + } + + // Since we want to work within the range [0, 360), we'll add 360 to any value less than zero - + // this will bump red values from within -60 to -1 to 300 to 359. The hue is the same at both values. + if (hue < 0.0) + { + hue += 360.0; + } + + // The saturation, our final HSV axis, can be thought of as a value between 0 and 1 indicating how intense our color is. + // To find it, we divide the chroma - the distance between the minimum and the maximum RGB channels - by the maximum channel (i.e., the value). + // This effectively normalizes the chroma - if the maximum is 0.5 and the minimum is 0, the saturation will be (0.5 - 0) / 0.5 = 1, + // meaning that although this color is not as bright as it can be, the dark color is as intense as it possibly could be. + // If, on the other hand, the maximum is 0.5 and the minimum is 0.25, then the saturation will be (0.5 - 0.25) / 0.5 = 0.5, + // meaning that this color is partially washed out. + // A saturation value of 0 corresponds to a greyscale color, one in which the color is *completely* washed out and there is no actual hue. + saturation = chroma / value; + } + + return new HsvColor(a, hue, saturation, value, clampValues: false); + } + + /// + /// Indicates whether the values of two specified objects are equal. + /// + /// The first object to compare. + /// The second object to compare. + /// True if left and right are equal; otherwise, false. public static bool operator ==(Color left, Color right) { return left.Equals(right); } + /// + /// Indicates whether the values of two specified objects are not equal. + /// + /// The first object to compare. + /// The second object to compare. + /// True if left and right are not equal; otherwise, false. public static bool operator !=(Color left, Color right) { return !left.Equals(right); diff --git a/src/Avalonia.Visuals/Media/HslColor.cs b/src/Avalonia.Visuals/Media/HslColor.cs new file mode 100644 index 0000000000..e27a4f3106 --- /dev/null +++ b/src/Avalonia.Visuals/Media/HslColor.cs @@ -0,0 +1,476 @@ +// Color conversion portions of this source file are adapted from the Windows Community Toolkit project. +// (https://github.com/CommunityToolkit/WindowsCommunityToolkit) +// +// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. + +using System; +using System.Globalization; +using System.Text; +using Avalonia.Utilities; + +namespace Avalonia.Media +{ + /// + /// Defines a color using the hue/saturation/lightness (HSL) model. + /// +#if !BUILDTASK + public +#endif + readonly struct HslColor : IEquatable + { + /// + /// Initializes a new instance of the struct. + /// + /// The Alpha (transparency) component in the range from 0..1. + /// The Hue component in the range from 0..360. + /// Note that 360 is equivalent to 0 and will be adjusted automatically. + /// The Saturation component in the range from 0..1. + /// The Lightness component in the range from 0..1. + public HslColor( + double alpha, + double hue, + double saturation, + double lightness) + { + A = MathUtilities.Clamp(alpha, 0.0, 1.0); + H = MathUtilities.Clamp(hue, 0.0, 360.0); + S = MathUtilities.Clamp(saturation, 0.0, 1.0); + L = MathUtilities.Clamp(lightness, 0.0, 1.0); + + // The maximum value of Hue is technically 360 minus epsilon (just below 360). + // This is because, in a color circle, 360 degrees is equivalent to 0 degrees. + // However, that is too tricky to work with in code and isn't as intuitive. + // Therefore, since 360 == 0, just wrap 360 if needed back to 0. + H = (H == 360.0 ? 0 : H); + } + + /// + /// Initializes a new instance of the struct. + /// + /// + /// This constructor exists only for internal use where performance is critical. + /// Whether or not the component values are in the correct ranges must be known. + /// + /// The Alpha (transparency) component in the range from 0..1. + /// The Hue component in the range from 0..360. + /// Note that 360 is equivalent to 0 and will be adjusted automatically. + /// The Saturation component in the range from 0..1. + /// The Lightness component in the range from 0..1. + /// Whether to clamp component values to their required ranges. + internal HslColor( + double alpha, + double hue, + double saturation, + double lightness, + bool clampValues) + { + if (clampValues) + { + A = MathUtilities.Clamp(alpha, 0.0, 1.0); + H = MathUtilities.Clamp(hue, 0.0, 360.0); + S = MathUtilities.Clamp(saturation, 0.0, 1.0); + L = MathUtilities.Clamp(lightness, 0.0, 1.0); + + // See comments in constructor above + H = (H == 360.0 ? 0 : H); + } + else + { + A = alpha; + H = hue; + S = saturation; + L = lightness; + } + } + + /// + /// Initializes a new instance of the struct. + /// + /// The RGB color to convert to HSL. + public HslColor(Color color) + { + var hsl = Color.ToHsl(color); + + A = hsl.A; + H = hsl.H; + S = hsl.S; + L = hsl.L; + } + + /// + /// Gets the Alpha (transparency) component in the range from 0..1. + /// + public double A { get; } + + /// + /// Gets the Hue component in the range from 0..360. + /// Note that 360 is equivalent to 0 and will be adjusted automatically. + /// + public double H { get; } + + /// + /// Gets the Saturation component in the range from 0..1. + /// + public double S { get; } + + /// + /// Gets the Lightness component in the range from 0..1. + /// + public double L { get; } + + /// + public bool Equals(HslColor other) + { + return other.A == A && + other.H == H && + other.S == S && + other.L == L; + } + + /// + public override bool Equals(object? obj) + { + if (obj is HslColor hslColor) + { + return Equals(hslColor); + } + else + { + return false; + } + } + + /// + /// Gets a hashcode for this object. + /// Hashcode is not guaranteed to be unique. + /// + /// The hashcode for this object. + public override int GetHashCode() + { + // Same algorithm as Color + // This is used instead of HashCode.Combine() due to .NET Standard 2.0 requirements + unchecked + { + int hashCode = A.GetHashCode(); + hashCode = (hashCode * 397) ^ H.GetHashCode(); + hashCode = (hashCode * 397) ^ S.GetHashCode(); + hashCode = (hashCode * 397) ^ L.GetHashCode(); + return hashCode; + } + } + + /// + /// Returns the RGB color model equivalent of this HSL color. + /// + /// The RGB equivalent color. + public Color ToRgb() + { + // Use the by-component conversion method directly for performance + return HslColor.ToRgb(H, S, L, A); + } + + /// + public override string ToString() + { + var sb = new StringBuilder(); + + // Use a format similar to CSS. However: + // - To ensure precision is never lost, allow decimal places. + // This is especially important for round-trip serialization. + // - To maintain numerical consistency, do not use percent. + // + // Example: + // + // hsla(hue, saturation, lightness, alpha) + // hsla(230, 1.0, 0.5, 1.0) + // + + sb.Append("hsva("); + sb.Append(H.ToString(CultureInfo.InvariantCulture)); + sb.Append(", "); + sb.Append(S.ToString(CultureInfo.InvariantCulture)); + sb.Append(", "); + sb.Append(L.ToString(CultureInfo.InvariantCulture)); + sb.Append(", "); + sb.Append(A.ToString(CultureInfo.InvariantCulture)); + sb.Append(')'); + + return sb.ToString(); + } + + /// + /// Parses an HSL color string. + /// + /// The HSL color string to parse. + /// The parsed . + public static HslColor Parse(string s) + { + if (s is null) + { + throw new ArgumentNullException(nameof(s)); + } + + if (TryParse(s, out HslColor hslColor)) + { + return hslColor; + } + + throw new FormatException($"Invalid HSL color string: '{s}'."); + } + + /// + /// Parses an HSL color string. + /// + /// The HSL color string to parse. + /// The parsed . + /// True if parsing was successful; otherwise, false. + public static bool TryParse(string s, out HslColor hslColor) + { + hslColor = default; + + if (s is null) + { + return false; + } + + string workingString = s.Trim(); + + if (workingString.Length == 0 || + workingString.IndexOf(",", StringComparison.Ordinal) < 0) + { + return false; + } + + if (workingString.Length > 6 && + workingString.StartsWith("hsla(", StringComparison.OrdinalIgnoreCase) && + workingString.EndsWith(")", StringComparison.Ordinal)) + { + workingString = workingString.Substring(5, workingString.Length - 6); + } + + if (workingString.Length > 5 && + workingString.StartsWith("hsl(", StringComparison.OrdinalIgnoreCase) && + workingString.EndsWith(")", StringComparison.Ordinal)) + { + workingString = workingString.Substring(4, workingString.Length - 5); + } + + string[] components = workingString.Split(','); + + if (components.Length == 3) // HSL + { + if (double.TryParse(components[0], NumberStyles.Number, CultureInfo.InvariantCulture, out double hue) && + TryInternalParse(components[1], out double saturation) && + TryInternalParse(components[2], out double lightness)) + { + hslColor = new HslColor(1.0, hue, saturation, lightness); + return true; + } + } + else if (components.Length == 4) // HSLA + { + if (double.TryParse(components[0], NumberStyles.Number, CultureInfo.InvariantCulture, out double hue) && + TryInternalParse(components[1], out double saturation) && + TryInternalParse(components[2], out double lightness) && + TryInternalParse(components[3], out double alpha)) + { + hslColor = new HslColor(alpha, hue, saturation, lightness); + return true; + } + } + + // Local function to specially parse a double value with an optional percentage sign + bool TryInternalParse(string inString, out double outDouble) + { + // The percent sign, if it exists, must be at the end of the number + int percentIndex = inString.IndexOf("%", StringComparison.Ordinal); + + if (percentIndex >= 0) + { + var result = double.TryParse( + inString.Substring(0, percentIndex), + NumberStyles.Number, + CultureInfo.InvariantCulture, + out double percentage); + + outDouble = percentage / 100.0; + return result; + } + else + { + return double.TryParse( + inString, + NumberStyles.Number, + CultureInfo.InvariantCulture, + out outDouble); + } + } + + return false; + } + + /// + /// Creates a new from individual color component values. + /// + /// + /// This exists for symmetry with the struct; however, the + /// appropriate constructor should commonly be used instead. + /// + /// The Alpha (transparency) component in the range from 0..1. + /// The Hue component in the range from 0..360. + /// The Saturation component in the range from 0..1. + /// The Lightness component in the range from 0..1. + /// A new built from the individual color component values. + public static HslColor FromAhsl(double a, double h, double s, double l) + { + return new HslColor(a, h, s, l); + } + + /// + /// Creates a new from individual color component values. + /// + /// + /// This exists for symmetry with the struct; however, the + /// appropriate constructor should commonly be used instead. + /// + /// The Hue component in the range from 0..360. + /// The Saturation component in the range from 0..1. + /// The Lightness component in the range from 0..1. + /// A new built from the individual color component values. + public static HslColor FromHsl(double h, double s, double l) + { + return new HslColor(1.0, h, s, l); + } + + /// + /// Converts the given HSL color to its RGB color equivalent. + /// + /// The color in the HSL color model. + /// A new RGB equivalent to the given HSLA values. + public static Color ToRgb(HslColor hslColor) + { + return HslColor.ToRgb(hslColor.H, hslColor.S, hslColor.L, hslColor.A); + } + + /// + /// Converts the given HSLA color component values to their RGB color equivalent. + /// + /// The Hue component in the HSL color model in the range from 0..360. + /// The Saturation component in the HSL color model in the range from 0..1. + /// The Lightness component in the HSL color model in the range from 0..1. + /// The Alpha component in the range from 0..1. + /// A new RGB equivalent to the given HSLA values. + public static Color ToRgb( + double hue, + double saturation, + double lightness, + double alpha = 1.0) + { + // Note: Conversion code is originally based on ColorHelper in the Windows Community Toolkit (licensed MIT) + // https://github.com/CommunityToolkit/WindowsCommunityToolkit/blob/main/Microsoft.Toolkit.Uwp/Helpers/ColorHelper.cs + // It has been modified to ensure input ranges and not throw exceptions. + + // We want the hue to be between 0 and 359, + // so we first ensure that that's the case. + while (hue >= 360.0) + { + hue -= 360.0; + } + + while (hue < 0.0) + { + hue += 360.0; + } + + // We similarly clamp saturation, lightness and alpha between 0 and 1. + saturation = saturation < 0.0 ? 0.0 : saturation; + saturation = saturation > 1.0 ? 1.0 : saturation; + + lightness = lightness < 0.0 ? 0.0 : lightness; + lightness = lightness > 1.0 ? 1.0 : lightness; + + alpha = alpha < 0.0 ? 0.0 : alpha; + alpha = alpha > 1.0 ? 1.0 : alpha; + + double chroma = (1 - Math.Abs((2 * lightness) - 1)) * saturation; + double h1 = hue / 60; + double x = chroma * (1 - Math.Abs((h1 % 2) - 1)); + double m = lightness - (0.5 * chroma); + double r1, g1, b1; + + if (h1 < 1) + { + r1 = chroma; + g1 = x; + b1 = 0; + } + else if (h1 < 2) + { + r1 = x; + g1 = chroma; + b1 = 0; + } + else if (h1 < 3) + { + r1 = 0; + g1 = chroma; + b1 = x; + } + else if (h1 < 4) + { + r1 = 0; + g1 = x; + b1 = chroma; + } + else if (h1 < 5) + { + r1 = x; + g1 = 0; + b1 = chroma; + } + else + { + r1 = chroma; + g1 = 0; + b1 = x; + } + + return Color.FromArgb( + (byte)Math.Round(255 * alpha), + (byte)Math.Round(255 * (r1 + m)), + (byte)Math.Round(255 * (g1 + m)), + (byte)Math.Round(255 * (b1 + m))); + } + + /// + /// Indicates whether the values of two specified objects are equal. + /// + /// The first object to compare. + /// The second object to compare. + /// True if left and right are equal; otherwise, false. + public static bool operator ==(HslColor left, HslColor right) + { + return left.Equals(right); + } + + /// + /// Indicates whether the values of two specified objects are not equal. + /// + /// The first object to compare. + /// The second object to compare. + /// True if left and right are not equal; otherwise, false. + public static bool operator !=(HslColor left, HslColor right) + { + return !(left == right); + } + + /// + /// Explicit conversion from an to a . + /// + /// The to convert. + public static explicit operator Color(HslColor hslColor) + { + return hslColor.ToRgb(); + } + } +} diff --git a/src/Avalonia.Visuals/Media/HsvColor.cs b/src/Avalonia.Visuals/Media/HsvColor.cs index 4a0277b4d4..164aeb1df1 100644 --- a/src/Avalonia.Visuals/Media/HsvColor.cs +++ b/src/Avalonia.Visuals/Media/HsvColor.cs @@ -1,6 +1,6 @@ -// Color conversion portions of this source file are adapted from the WinUI project. -// (https://github.com/microsoft/microsoft-ui-xaml) -// +// Color conversion portions of this source file are adapted from the WinUI project. +// (https://github.com/microsoft/microsoft-ui-xaml) +// // Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. using System; @@ -21,11 +21,11 @@ namespace Avalonia.Media /// /// Initializes a new instance of the struct. /// - /// The Alpha (transparency) channel value in the range from 0..1. - /// The Hue channel value in the range from 0..360. + /// The Alpha (transparency) component in the range from 0..1. + /// The Hue component in the range from 0..360. /// Note that 360 is equivalent to 0 and will be adjusted automatically. - /// The Saturation channel value in the range from 0..1. - /// The Value channel value in the range from 0..1. + /// The Saturation component in the range from 0..1. + /// The Value component in the range from 0..1. public HsvColor( double alpha, double hue, @@ -49,14 +49,14 @@ namespace Avalonia.Media /// /// /// This constructor exists only for internal use where performance is critical. - /// Whether or not the channel values are in the correct ranges must be known. + /// Whether or not the component values are in the correct ranges must be known. /// - /// The Alpha (transparency) channel value in the range from 0..1. - /// The Hue channel value in the range from 0..360. + /// The Alpha (transparency) component in the range from 0..1. + /// The Hue component in the range from 0..360. /// Note that 360 is equivalent to 0 and will be adjusted automatically. - /// The Saturation channel value in the range from 0..1. - /// The Value channel value in the range from 0..1. - /// Whether to clamp channel values to their required ranges. + /// The Saturation component in the range from 0..1. + /// The Value component in the range from 0..1. + /// Whether to clamp component values to their required ranges. internal HsvColor( double alpha, double hue, @@ -89,7 +89,7 @@ namespace Avalonia.Media /// The RGB color to convert to HSV. public HsvColor(Color color) { - var hsv = HsvColor.FromRgb(color); + var hsv = Color.ToHsv(color); A = hsv.A; H = hsv.H; @@ -98,23 +98,23 @@ namespace Avalonia.Media } /// - /// Gets the Alpha (transparency) channel value in the range from 0..1. + /// Gets the Alpha (transparency) component in the range from 0..1. /// public double A { get; } /// - /// Gets the Hue channel value in the range from 0..360. + /// Gets the Hue component in the range from 0..360. /// Note that 360 is equivalent to 0 and will be adjusted automatically. /// public double H { get; } /// - /// Gets the Saturation channel value in the range from 0..1. + /// Gets the Saturation component in the range from 0..1. /// public double S { get; } /// - /// Gets the Value channel value in the range from 0..1. + /// Gets the Value component in the range from 0..1. /// public double V { get; } @@ -165,7 +165,7 @@ namespace Avalonia.Media /// The RGB equivalent color. public Color ToRgb() { - // Use the by-channel conversion method directly for performance + // Use the by-component conversion method directly for performance return HsvColor.ToRgb(H, S, V, A); } @@ -174,26 +174,16 @@ namespace Avalonia.Media { var sb = new StringBuilder(); - // Use a format similar to HSL in HTML/CSS "hsla(0, 100%, 50%, 0.5)" - // - // However: - // - To ensure precision is never lost, allow decimal places - // - To maintain numerical consistency do not use percent + // Use a format similar to CSS. However: + // - To ensure precision is never lost, allow decimal places. + // This is especially important for round-trip serialization. + // - To maintain numerical consistency, do not use percent. // // Example: // // hsva(hue, saturation, value, alpha) // hsva(230, 1.0, 0.5, 1.0) // - // Where: - // - // hue : double from 0 to 360 - // saturation : double from 0 to 1 - // (HTML uses a percentage) - // value : double from 0 to 1 - // (HTML uses a percentage) - // alpha : double from 0 to 1 - // (HTML does not use a percentage for alpha) sb.Append("hsva("); sb.Append(H.ToString(CultureInfo.InvariantCulture)); @@ -270,8 +260,8 @@ namespace Avalonia.Media if (components.Length == 3) // HSV { if (double.TryParse(components[0], NumberStyles.Number, CultureInfo.InvariantCulture, out double hue) && - double.TryParse(components[1], NumberStyles.Number, CultureInfo.InvariantCulture, out double saturation) && - double.TryParse(components[2], NumberStyles.Number, CultureInfo.InvariantCulture, out double value)) + TryInternalParse(components[1], out double saturation) && + TryInternalParse(components[2], out double value)) { hsvColor = new HsvColor(1.0, hue, saturation, value); return true; @@ -280,37 +270,80 @@ namespace Avalonia.Media else if (components.Length == 4) // HSVA { if (double.TryParse(components[0], NumberStyles.Number, CultureInfo.InvariantCulture, out double hue) && - double.TryParse(components[1], NumberStyles.Number, CultureInfo.InvariantCulture, out double saturation) && - double.TryParse(components[2], NumberStyles.Number, CultureInfo.InvariantCulture, out double value) && - double.TryParse(components[3], NumberStyles.Number, CultureInfo.InvariantCulture, out double alpha)) + TryInternalParse(components[1], out double saturation) && + TryInternalParse(components[2], out double value) && + TryInternalParse(components[3], out double alpha)) { hsvColor = new HsvColor(alpha, hue, saturation, value); return true; } } + // Local function to specially parse a double value with an optional percentage sign + bool TryInternalParse(string inString, out double outDouble) + { + // The percent sign, if it exists, must be at the end of the number + int percentIndex = inString.IndexOf("%", StringComparison.Ordinal); + + if (percentIndex >= 0) + { + var result = double.TryParse( + inString.Substring(0, percentIndex), + NumberStyles.Number, + CultureInfo.InvariantCulture, + out double percentage); + + outDouble = percentage / 100.0; + return result; + } + else + { + return double.TryParse( + inString, + NumberStyles.Number, + CultureInfo.InvariantCulture, + out outDouble); + } + } + return false; } /// - /// Creates a new from individual color channel values. + /// Creates a new from individual color component values. /// /// /// This exists for symmetry with the struct; however, the /// appropriate constructor should commonly be used instead. /// - /// The Alpha (transparency) channel value in the range from 0..1. - /// The Hue channel value in the range from 0..360. - /// The Saturation channel value in the range from 0..1. - /// The Value channel value in the range from 0..1. - /// A new built from the individual color channel values. + /// The Alpha (transparency) component in the range from 0..1. + /// The Hue component in the range from 0..360. + /// The Saturation component in the range from 0..1. + /// The Value component in the range from 0..1. + /// A new built from the individual color component values. public static HsvColor FromAhsv(double a, double h, double s, double v) { return new HsvColor(a, h, s, v); } /// - /// Converts the given HSV color to it's RGB color equivalent. + /// Creates a new from individual color component values. + /// + /// + /// This exists for symmetry with the struct; however, the + /// appropriate constructor should commonly be used instead. + /// + /// The Hue component in the range from 0..360. + /// The Saturation component in the range from 0..1. + /// The Value component in the range from 0..1. + /// A new built from the individual color component values. + public static HsvColor FromHsv(double h, double s, double v) + { + return new HsvColor(1.0, h, s, v); + } + + /// + /// Converts the given HSV color to its RGB color equivalent. /// /// The color in the HSV color model. /// A new RGB equivalent to the given HSVA values. @@ -320,12 +353,12 @@ namespace Avalonia.Media } /// - /// Converts the given HSVA color channel values to it's RGB color equivalent. + /// Converts the given HSVA color component values to their RGB color equivalent. /// - /// The hue channel value in the HSV color model in the range from 0..360. - /// The saturation channel value in the HSV color model in the range from 0..1. - /// The value channel value in the HSV color model in the range from 0..1. - /// The alpha channel value in the range from 0..1. + /// The Hue component in the HSV color model in the range from 0..360. + /// The Saturation component in the HSV color model in the range from 0..1. + /// The Value component in the HSV color model in the range from 0..1. + /// The Alpha component in the range from 0..1. /// A new RGB equivalent to the given HSVA values. public static Color ToRgb( double hue, @@ -336,7 +369,7 @@ namespace Avalonia.Media // Note: Conversion code is originally based on the C++ in WinUI (licensed MIT) // https://github.com/microsoft/microsoft-ui-xaml/blob/main/dev/Common/ColorConversion.cpp // This was used because it is the best documented and likely most optimized for performance - // Alpha channel support was added + // Alpha support was added // We want the hue to be between 0 and 359, // so we first ensure that that's the case. @@ -457,130 +490,6 @@ namespace Avalonia.Media (byte)Math.Round(b * 255)); } - /// - /// Converts the given RGB color to it's HSV color equivalent. - /// - /// The color in the RGB color model. - /// A new equivalent to the given RGBA values. - public static HsvColor FromRgb(Color color) - { - return HsvColor.FromRgb(color.R, color.G, color.B, color.A); - } - - /// - /// Converts the given RGBA color channel values to it's HSV color equivalent. - /// - /// The red channel value in the RGB color model. - /// The green channel value in the RGB color model. - /// The blue channel value in the RGB color model. - /// The alpha channel value. - /// A new equivalent to the given RGBA values. - public static HsvColor FromRgb( - byte red, - byte green, - byte blue, - byte alpha = 0xFF) - { - // Note: Conversion code is originally based on the C++ in WinUI (licensed MIT) - // https://github.com/microsoft/microsoft-ui-xaml/blob/main/dev/Common/ColorConversion.cpp - // This was used because it is the best documented and likely most optimized for performance - // Alpha channel support was added - - // Normalize RGBA channel values into the 0..1 range used by this algorithm - double r = red / 255.0; - double g = green / 255.0; - double b = blue / 255.0; - double a = alpha / 255.0; - - double hue; - double saturation; - double value; - - double max = r >= g ? (r >= b ? r : b) : (g >= b ? g : b); - double min = r <= g ? (r <= b ? r : b) : (g <= b ? g : b); - - // The value, a number between 0 and 1, is the largest of R, G, and B (divided by 255). - // Conceptually speaking, it represents how much color is present. - // If at least one of R, G, B is 255, then there exists as much color as there can be. - // If RGB = (0, 0, 0), then there exists no color at all - a value of zero corresponds - // to black (i.e., the absence of any color). - value = max; - - // The "chroma" of the color is a value directly proportional to the extent to which - // the color diverges from greyscale. If, for example, we have RGB = (255, 255, 0), - // then the chroma is maximized - this is a pure yellow, no gray of any kind. - // On the other hand, if we have RGB = (128, 128, 128), then the chroma being zero - // implies that this color is pure greyscale, with no actual hue to be found. - var chroma = max - min; - - // If the chrome is zero, then hue is technically undefined - a greyscale color - // has no hue. For the sake of convenience, we'll just set hue to zero, since - // it will be unused in this circumstance. Since the color is purely gray, - // saturation is also equal to zero - you can think of saturation as basically - // a measure of hue intensity, such that no hue at all corresponds to a - // nonexistent intensity. - if (chroma == 0) - { - hue = 0.0; - saturation = 0.0; - } - else - { - // In this block, hue is properly defined, so we'll extract both hue - // and saturation information from the RGB color. - - // Hue can be thought of as a cyclical thing, between 0 degrees and 360 degrees. - // A hue of 0 degrees is red; 120 degrees is green; 240 degrees is blue; and 360 is back to red. - // Every other hue is somewhere between either red and green, green and blue, and blue and red, - // so every other hue can be thought of as an angle on this color wheel. - // These if/else statements determines where on this color wheel our color lies. - if (r == max) - { - // If the red channel is the most pronounced channel, then we exist - // somewhere between (-60, 60) on the color wheel - i.e., the section around 0 degrees - // where red dominates. We figure out where in that section we are exactly - // by considering whether the green or the blue channel is greater - by subtracting green from blue, - // then if green is greater, we'll nudge ourselves closer to 60, whereas if blue is greater, then - // we'll nudge ourselves closer to -60. We then divide by chroma (which will actually make the result larger, - // since chroma is a value between 0 and 1) to normalize the value to ensure that we get the right hue - // even if we're very close to greyscale. - hue = 60 * (g - b) / chroma; - } - else if (g == max) - { - // We do the exact same for the case where the green channel is the most pronounced channel, - // only this time we want to see if we should tilt towards the blue direction or the red direction. - // We add 120 to center our value in the green third of the color wheel. - hue = 120 + (60 * (b - r) / chroma); - } - else // blue == max - { - // And we also do the exact same for the case where the blue channel is the most pronounced channel, - // only this time we want to see if we should tilt towards the red direction or the green direction. - // We add 240 to center our value in the blue third of the color wheel. - hue = 240 + (60 * (r - g) / chroma); - } - - // Since we want to work within the range [0, 360), we'll add 360 to any value less than zero - - // this will bump red values from within -60 to -1 to 300 to 359. The hue is the same at both values. - if (hue < 0.0) - { - hue += 360.0; - } - - // The saturation, our final HSV axis, can be thought of as a value between 0 and 1 indicating how intense our color is. - // To find it, we divide the chroma - the distance between the minimum and the maximum RGB channels - by the maximum channel (i.e., the value). - // This effectively normalizes the chroma - if the maximum is 0.5 and the minimum is 0, the saturation will be (0.5 - 0) / 0.5 = 1, - // meaning that although this color is not as bright as it can be, the dark color is as intense as it possibly could be. - // If, on the other hand, the maximum is 0.5 and the minimum is 0.25, then the saturation will be (0.5 - 0.25) / 0.5 = 0.5, - // meaning that this color is partially washed out. - // A saturation value of 0 corresponds to a greyscale color, one in which the color is *completely* washed out and there is no actual hue. - saturation = chroma / value; - } - - return new HsvColor(a, hue, saturation, value, false); - } - /// /// Indicates whether the values of two specified objects are equal. /// diff --git a/tests/Avalonia.Visuals.UnitTests/Media/ColorTests.cs b/tests/Avalonia.Visuals.UnitTests/Media/ColorTests.cs index d68c2fd5fd..352e58abd1 100644 --- a/tests/Avalonia.Visuals.UnitTests/Media/ColorTests.cs +++ b/tests/Avalonia.Visuals.UnitTests/Media/ColorTests.cs @@ -199,5 +199,129 @@ namespace Avalonia.Visuals.UnitTests.Media { Assert.False(Color.TryParse(input, out _)); } + + [Fact] + public void Try_Parse_HslColor() + { + // Inline data requires constants, so the data is handled internally here + var data = new Tuple[] + { + // HSV + Tuple.Create("hsl(0, 0, 0)", new HslColor(1, 0, 0, 0)), + Tuple.Create("hsl(0, 0%, 0%)", new HslColor(1, 0, 0, 0)), + Tuple.Create("hsl(180, 0.5, 0.5)", new HslColor(1, 180, 0.5, 0.5)), + Tuple.Create("hsl(180, 50%, 50%)", new HslColor(1, 180, 0.5, 0.5)), + Tuple.Create("hsl(360, 1.0, 1.0)", new HslColor(1, 0, 1, 1)), // Wraps Hue to zero + Tuple.Create("hsl(360, 100%, 100%)", new HslColor(1, 0, 1, 1)), // Wraps Hue to zero + + Tuple.Create("hsl(-1000, -1000, -1000)", new HslColor(1, 0, 0, 0)), // Clamps to min + Tuple.Create("hsl(-1000, -1000%, -1000%)", new HslColor(1, 0, 0, 0)), // Clamps to min + Tuple.Create("hsl(1000, 1000, 1000)", new HslColor(1, 0, 1, 1)), // Clamps to max + Tuple.Create("hsl(1000, 1000%, 1000%)", new HslColor(1, 0, 1, 1)), // Clamps to max + + Tuple.Create("hsl(300, 0.8, 0.2)", new HslColor(1.0, 300, 0.8, 0.2)), + Tuple.Create("hsl(300, 80%, 20%)", new HslColor(1.0, 300, 0.8, 0.2)), + + // HSVA + Tuple.Create("hsla(0, 0, 0, 0)", new HslColor(0, 0, 0, 0)), + Tuple.Create("hsla(0, 0%, 0%, 0%)", new HslColor(0, 0, 0, 0)), + Tuple.Create("hsla(180, 0.5, 0.5, 0.5)", new HslColor(0.5, 180, 0.5, 0.5)), + Tuple.Create("hsla(180, 50%, 50%, 50%)", new HslColor(0.5, 180, 0.5, 0.5)), + Tuple.Create("hsla(360, 1.0, 1.0, 1.0)", new HslColor(1, 0, 1, 1)), // Wraps Hue to zero + Tuple.Create("hsla(360, 100%, 100%, 100%)", new HslColor(1, 0, 1, 1)), // Wraps Hue to zero + + Tuple.Create("hsla(-1000, -1000, -1000, -1000)", new HslColor(0, 0, 0, 0)), // Clamps to min + Tuple.Create("hsla(-1000, -1000%, -1000%, -1000%)", new HslColor(0, 0, 0, 0)), // Clamps to min + Tuple.Create("hsla(1000, 1000, 1000, 1000)", new HslColor(1, 0, 1, 1)), // Clamps to max (Hue wraps to zero) + Tuple.Create("hsla(1000, 1000%, 1000%, 1000%)", new HslColor(1, 0, 1, 1)), // Clamps to max (Hue wraps to zero) + + Tuple.Create("hsla(300, 0.9, 0.2, 0.8)", new HslColor(0.8, 300, 0.9, 0.2)), + Tuple.Create("hsla(300, 90%, 20%, 0.8)", new HslColor(0.8, 300, 0.9, 0.2)), + }; + + foreach (var dataPoint in data) + { + Assert.True(HslColor.TryParse(dataPoint.Item1, out HslColor parsedHslColor)); + Assert.True(dataPoint.Item2 == parsedHslColor); + } + } + + [Fact] + public void Try_Parse_HsvColor() + { + // Inline data requires constants, so the data is handled internally here + var data = new Tuple[] + { + // HSV + Tuple.Create("hsv(0, 0, 0)", new HsvColor(1, 0, 0, 0)), + Tuple.Create("hsv(0, 0%, 0%)", new HsvColor(1, 0, 0, 0)), + Tuple.Create("hsv(180, 0.5, 0.5)", new HsvColor(1, 180, 0.5, 0.5)), + Tuple.Create("hsv(180, 50%, 50%)", new HsvColor(1, 180, 0.5, 0.5)), + Tuple.Create("hsv(360, 1.0, 1.0)", new HsvColor(1, 0, 1, 1)), // Wraps Hue to zero + Tuple.Create("hsv(360, 100%, 100%)", new HsvColor(1, 0, 1, 1)), // Wraps Hue to zero + + Tuple.Create("hsv(-1000, -1000, -1000)", new HsvColor(1, 0, 0, 0)), // Clamps to min + Tuple.Create("hsv(-1000, -1000%, -1000%)", new HsvColor(1, 0, 0, 0)), // Clamps to min + Tuple.Create("hsv(1000, 1000, 1000)", new HsvColor(1, 0, 1, 1)), // Clamps to max + Tuple.Create("hsv(1000, 1000%, 1000%)", new HsvColor(1, 0, 1, 1)), // Clamps to max + + Tuple.Create("hsv(300, 0.8, 0.2)", new HsvColor(1.0, 300, 0.8, 0.2)), + Tuple.Create("hsv(300, 80%, 20%)", new HsvColor(1.0, 300, 0.8, 0.2)), + + // HSVA + Tuple.Create("hsva(0, 0, 0, 0)", new HsvColor(0, 0, 0, 0)), + Tuple.Create("hsva(0, 0%, 0%, 0%)", new HsvColor(0, 0, 0, 0)), + Tuple.Create("hsva(180, 0.5, 0.5, 0.5)", new HsvColor(0.5, 180, 0.5, 0.5)), + Tuple.Create("hsva(180, 50%, 50%, 50%)", new HsvColor(0.5, 180, 0.5, 0.5)), + Tuple.Create("hsva(360, 1.0, 1.0, 1.0)", new HsvColor(1, 0, 1, 1)), // Wraps Hue to zero + Tuple.Create("hsva(360, 100%, 100%, 100%)", new HsvColor(1, 0, 1, 1)), // Wraps Hue to zero + + Tuple.Create("hsva(-1000, -1000, -1000, -1000)", new HsvColor(0, 0, 0, 0)), // Clamps to min + Tuple.Create("hsva(-1000, -1000%, -1000%, -1000%)", new HsvColor(0, 0, 0, 0)), // Clamps to min + Tuple.Create("hsva(1000, 1000, 1000, 1000)", new HsvColor(1, 0, 1, 1)), // Clamps to max (Hue wraps to zero) + Tuple.Create("hsva(1000, 1000%, 1000%, 1000%)", new HsvColor(1, 0, 1, 1)), // Clamps to max (Hue wraps to zero) + + Tuple.Create("hsva(300, 0.9, 0.2, 0.8)", new HsvColor(0.8, 300, 0.9, 0.2)), + Tuple.Create("hsva(300, 90%, 20%, 0.8)", new HsvColor(0.8, 300, 0.9, 0.2)), + }; + + foreach (var dataPoint in data) + { + Assert.True(HsvColor.TryParse(dataPoint.Item1, out HsvColor parsedHsvColor)); + Assert.True(dataPoint.Item2 == parsedHsvColor); + } + } + + [Fact] + public void Try_Parse_All_Formats_With_Conversion() + { + // Inline data requires constants, so the data is handled internally here + var data = new Tuple[] + { + // RGB + Tuple.Create("White", new Color(0xff, 0xff, 0xff, 0xff)), + Tuple.Create("#123456", new Color(0xff, 0x12, 0x34, 0x56)), + + Tuple.Create("rgb(100, 30, 45)", new Color(255, 100, 30, 45)), + Tuple.Create("rgba(100, 30, 45, 0.9)", new Color(229, 100, 30, 45)), + Tuple.Create("rgba(100, 30, 45, 90%)", new Color(229, 100, 30, 45)), + + // HSL + Tuple.Create("hsl(296, 85%, 12%)", new Color(255, 53, 5, 57)), + Tuple.Create("hsla(296, 0.85, 0.12, 0.9)", new Color(230, 53, 5, 57)), + Tuple.Create("hsla(296, 85%, 12%, 90%)", new Color(230, 53, 5, 57)), + + // HSV + Tuple.Create("hsv(240, 83%, 78%)", new Color(255, 34, 34, 199)), + Tuple.Create("hsva(240, 0.83, 0.78, 0.9)", new Color(230, 34, 34, 199)), + Tuple.Create("hsva(240, 83%, 78%, 90%)", new Color(230, 34, 34, 199)), + }; + + foreach (var dataPoint in data) + { + Assert.True(Color.TryParse(dataPoint.Item1, out Color parsedColor)); + Assert.True(dataPoint.Item2 == parsedColor); + } + } } }