diff --git a/src/Avalonia.Base/Media/Color.cs b/src/Avalonia.Base/Media/Color.cs index ae16c30320..624513f065 100644 --- a/src/Avalonia.Base/Media/Color.cs +++ b/src/Avalonia.Base/Media/Color.cs @@ -21,7 +21,7 @@ namespace Avalonia.Media #if !BUILDTASK public #endif - readonly struct Color : IEquatable + readonly struct Color : IEquatable, IFormattable { private const double byteToDouble = 1.0 / 255; @@ -461,6 +461,97 @@ namespace Avalonia.Media } } + /// + /// Returns a formatted string representation of the color. + /// + /// + /// The format specifier. All color types support all specifiers via auto-conversion. + /// Uppercase includes alpha, lowercase excludes it. % suffix = percent mode: + /// + /// null or "" — Default (known name or #aarrggbb) + /// "X" — XAML hex with alpha: #AARRGGBB + /// "x" — Hex without alpha: #RRGGBB + /// "H" — HTML hex with alpha: #RRGGBBAA + /// "R" — CSS rgba absolute: rgba(r, g, b, a) + /// "r" — CSS rgb absolute: rgb(r, g, b) + /// "R%" — CSS rgba percent: rgba(r%, g%, b%, a%) + /// "r%" — CSS rgb percent: rgb(r%, g%, b%) + /// "L"/"l"/"L%"/"l%" — CSS hsl/hsla (auto-converts to HSL) + /// "V"/"v"/"V%"/"v%" — CSS hsv/hsva (auto-converts to HSV) + /// + /// "C" and "A" are reserved for future complex format strings. + /// + /// Ignored. Color formatting is culture-invariant. + /// The formatted string representation. + /// Thrown when is not recognized. + public string ToString(string? format, IFormatProvider? formatProvider) + { + if (string.IsNullOrEmpty(format)) + { + return ToString(); + } + + return format switch + { + "X" => $"#{A:X2}{R:X2}{G:X2}{B:X2}", + "x" => $"#{R:X2}{G:X2}{B:X2}", + "H" => $"#{R:X2}{G:X2}{B:X2}{A:X2}", + + "R" => FormatRgbCss(), + "r" => FormatRgbCss(includeAlpha: false), + "R%" => FormatRgbPercentCss(), + "r%" => FormatRgbPercentCss(includeAlpha: false), + + "L" or "l" or "L%" or "l%" => ToHsl().ToString(format, formatProvider), + "V" or "v" or "V%" or "v%" => ToHsv().ToString(format, formatProvider), + + _ => throw new FormatException($"Format string '{format}' is not supported.") + }; + } + + /// + /// Formats the color as a CSS rgb() or rgba() string using absolute RGB components and fractional alpha. + /// + private string FormatRgbCss(bool includeAlpha = true) + { + if (includeAlpha) + { + return string.Format( + CultureInfo.InvariantCulture, + "rgba({0}, {1}, {2}, {3:F2})", + R, + G, + B, + A * byteToDouble); + } + + return string.Format(CultureInfo.InvariantCulture, "rgb({0}, {1}, {2})", R, G, B); + } + + /// + /// Formats the color as a CSS rgb() or rgba() string with all components as percentages. + /// + private string FormatRgbPercentCss(bool includeAlpha = true) + { + int rPct = (int)Math.Round(R * byteToDouble * 100.0); + int gPct = (int)Math.Round(G * byteToDouble * 100.0); + int bPct = (int)Math.Round(B * byteToDouble * 100.0); + + if (includeAlpha) + { + int aPct = (int)Math.Round(A * byteToDouble * 100.0); + return string.Format( + CultureInfo.InvariantCulture, + "rgba({0}%, {1}%, {2}%, {3}%)", + rPct, + gPct, + bPct, + aPct); + } + + return string.Format(CultureInfo.InvariantCulture, "rgb({0}%, {1}%, {2}%)", rPct, gPct, bPct); + } + /// /// Returns the integer representation of the color. /// diff --git a/src/Avalonia.Base/Media/HslColor.cs b/src/Avalonia.Base/Media/HslColor.cs index 02176d65d7..2a09575d69 100644 --- a/src/Avalonia.Base/Media/HslColor.cs +++ b/src/Avalonia.Base/Media/HslColor.cs @@ -17,7 +17,7 @@ namespace Avalonia.Media #if !BUILDTASK public #endif - readonly struct HslColor : IEquatable + readonly struct HslColor : IEquatable, IFormattable { /// /// Initializes a new instance of the struct. @@ -177,6 +177,99 @@ namespace Avalonia.Media return HslColor.ToHsv(H, S, L, A); } + /// + /// Returns a formatted string representation of the HSL color. + /// + /// + /// The format specifier. All color types support all specifiers via auto-conversion. + /// Uppercase includes alpha, lowercase excludes it. % suffix = percent mode. + /// + /// null or "" — Default (full-precision hsla(h, s, l, a)) + /// "X" — XAML hex: #AARRGGBB + /// "x" — Hex: #RRGGBB + /// "H" — HTML hex: #RRGGBBAA + /// "R"/"r"/"R%"/"r%" — CSS rgb/rgba (auto-converts to RGB) + /// "L" — CSS hsla: hsla(h, s%, l%, a) + /// "l" — CSS hsl: hsl(h, s%, l%) + /// "L%" — All percent: hsla(h%, s%, l%, a%) + /// "l%" — All percent: hsl(h%, s%, l%) + /// "V"/"v"/"V%"/"v%" — CSS hsv/hsva (auto-converts to HSV) + /// + /// "C" and "A" are reserved for future complex format strings. + /// + /// Ignored. Color formatting is culture-invariant. + /// The formatted string representation. + /// Thrown when is not recognized. + public string ToString(string? format, IFormatProvider? formatProvider) + { + if (string.IsNullOrEmpty(format)) + { + return ToString(); + } + + return format switch + { + "X" or "x" or "H" or "R" or "r" or "R%" or "r%" => ToRgb().ToString(format, formatProvider), + + "L" => FormatHslCss(), + "l" => FormatHslCss(includeAlpha: false), + "L%" => FormatHslPercentCss(), + "l%" => FormatHslPercentCss(includeAlpha: false), + + "V" or "v" or "V%" or "v%" => ToHsv().ToString(format, formatProvider), + + _ => throw new FormatException($"Format string '{format}' is not supported.") + }; + } + + /// + /// Formats the color as a CSS hsl() or hsla() string using degrees, percent saturation/lightness, + /// and fractional alpha. + /// + private string FormatHslCss(bool includeAlpha = true) + { + int hDeg = (int)Math.Round(H); + int sPct = (int)Math.Round(S * 100.0); + int lPct = (int)Math.Round(L * 100.0); + + if (includeAlpha) + { + return string.Format( + CultureInfo.InvariantCulture, + "hsla({0}, {1}%, {2}%, {3:F2})", + hDeg, + sPct, + lPct, + A); + } + + return string.Format(CultureInfo.InvariantCulture, "hsl({0}, {1}%, {2}%)", hDeg, sPct, lPct); + } + + /// + /// Formats the color as a CSS hsl() or hsla() string with all components as percentages. + /// + private string FormatHslPercentCss(bool includeAlpha = true) + { + int hPct = (int)Math.Round(H / 360.0 * 100.0); + int sPct = (int)Math.Round(S * 100.0); + int lPct = (int)Math.Round(L * 100.0); + + if (includeAlpha) + { + int aPct = (int)Math.Round(A * 100.0); + return string.Format( + CultureInfo.InvariantCulture, + "hsla({0}%, {1}%, {2}%, {3}%)", + hPct, + sPct, + lPct, + aPct); + } + + return string.Format(CultureInfo.InvariantCulture, "hsl({0}%, {1}%, {2}%)", hPct, sPct, lPct); + } + /// public override string ToString() { @@ -193,7 +286,7 @@ namespace Avalonia.Media // hsla(230, 1.0, 0.5, 1.0) // - sb.Append("hsva("); + sb.Append("hsla("); sb.Append(H.ToString(CultureInfo.InvariantCulture)); sb.Append(", "); sb.Append(S.ToString(CultureInfo.InvariantCulture)); diff --git a/src/Avalonia.Base/Media/HsvColor.cs b/src/Avalonia.Base/Media/HsvColor.cs index 03949d32aa..7ff180c8c5 100644 --- a/src/Avalonia.Base/Media/HsvColor.cs +++ b/src/Avalonia.Base/Media/HsvColor.cs @@ -17,7 +17,7 @@ namespace Avalonia.Media #if !BUILDTASK public #endif - readonly struct HsvColor : IEquatable + readonly struct HsvColor : IEquatable, IFormattable { /// /// Initializes a new instance of the struct. @@ -207,6 +207,99 @@ namespace Avalonia.Media return HsvColor.ToHsl(H, S, V, A); } + /// + /// Returns a formatted string representation of the HSV color. + /// + /// + /// The format specifier. All color types support all specifiers via auto-conversion. + /// Uppercase includes alpha, lowercase excludes it. % suffix = percent mode. + /// + /// null or "" — Default (full-precision hsva(h, s, v, a)) + /// "X" — XAML hex: #AARRGGBB + /// "x" — Hex: #RRGGBB + /// "H" — HTML hex: #RRGGBBAA + /// "R"/"r"/"R%"/"r%" — CSS rgb/rgba (auto-converts to RGB) + /// "L"/"l"/"L%"/"l%" — CSS hsl/hsla (auto-converts to HSL) + /// "V" — CSS hsva: hsva(h, s%, v%, a) + /// "v" — CSS hsv: hsv(h, s%, v%) + /// "V%" — All percent: hsva(h%, s%, v%, a%) + /// "v%" — All percent: hsv(h%, s%, v%) + /// + /// "C" and "A" are reserved for future complex format strings. + /// + /// Ignored. Color formatting is culture-invariant. + /// The formatted string representation. + /// Thrown when is not recognized. + public string ToString(string? format, IFormatProvider? formatProvider) + { + if (string.IsNullOrEmpty(format)) + { + return ToString(); + } + + return format switch + { + "X" or "x" or "H" or "R" or "r" or "R%" or "r%" => ToRgb().ToString(format, formatProvider), + + "L" or "l" or "L%" or "l%" => ToHsl().ToString(format, formatProvider), + + "V" => FormatHsvCss(), + "v" => FormatHsvCss(includeAlpha: false), + "V%" => FormatHsvPercentCss(), + "v%" => FormatHsvPercentCss(includeAlpha: false), + + _ => throw new FormatException($"Format string '{format}' is not supported.") + }; + } + + /// + /// Formats the color as a CSS hsv() or hsva() string using degrees, percent saturation/value, + /// and fractional alpha. + /// + private string FormatHsvCss(bool includeAlpha = true) + { + int hDeg = (int)Math.Round(H); + int sPct = (int)Math.Round(S * 100.0); + int vPct = (int)Math.Round(V * 100.0); + + if (includeAlpha) + { + return string.Format( + CultureInfo.InvariantCulture, + "hsva({0}, {1}%, {2}%, {3:F2})", + hDeg, + sPct, + vPct, + A); + } + + return string.Format(CultureInfo.InvariantCulture, "hsv({0}, {1}%, {2}%)", hDeg, sPct, vPct); + } + + /// + /// Formats the color as a CSS hsv() or hsva() string with all components as percentages. + /// + private string FormatHsvPercentCss(bool includeAlpha = true) + { + int hPct = (int)Math.Round(H / 360.0 * 100.0); + int sPct = (int)Math.Round(S * 100.0); + int vPct = (int)Math.Round(V * 100.0); + + if (includeAlpha) + { + int aPct = (int)Math.Round(A * 100.0); + return string.Format( + CultureInfo.InvariantCulture, + "hsva({0}%, {1}%, {2}%, {3}%)", + hPct, + sPct, + vPct, + aPct); + } + + return string.Format(CultureInfo.InvariantCulture, "hsv({0}%, {1}%, {2}%)", hPct, sPct, vPct); + } + /// public override string ToString() { diff --git a/tests/Avalonia.Base.UnitTests/Media/ColorTests.cs b/tests/Avalonia.Base.UnitTests/Media/ColorTests.cs index 52680eb59b..4b48a728a4 100644 --- a/tests/Avalonia.Base.UnitTests/Media/ColorTests.cs +++ b/tests/Avalonia.Base.UnitTests/Media/ColorTests.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; using Avalonia.Media; using Xunit; @@ -364,5 +365,415 @@ namespace Avalonia.Base.UnitTests.Media Assert.Equal(convertedHsl, dataPoint.Item2); } } + // ===================================================================== + // IFormattable unified format specifier tests + // + // All three color types (Color, HslColor, HsvColor) support ALL + // format specifiers. Cross-model formats auto-convert. + // + // Convention: + // Uppercase = include alpha, a-suffixed prefix (rgba, hsla, hsva) + // Lowercase = exclude alpha, plain prefix (rgb, hsl, hsv) + // "%" suffix = percent mode + // ===================================================================== + + [Fact] + public void Color_ToString_Default_Returns_KnownName() + { + var red = new Color(0xFF, 0xFF, 0x00, 0x00); + + Assert.Equal("Red", red.ToString()); + } + + [Fact] + public void Color_ToString_Default_Returns_Hex_For_Unknown() + { + var color = new Color(0x40, 0xFF, 0x88, 0x44); + + Assert.Equal("#40ff8844", color.ToString()); + } + + [Fact] + public void Color_ToString_Null_Format_Matches_Default() + { + var color = new Color(0x40, 0xFF, 0x88, 0x44); + + Assert.Equal(color.ToString(), color.ToString(null, null)); + } + + [Fact] + public void Color_ToString_Empty_Format_Matches_Default() + { + var color = new Color(0x40, 0xFF, 0x88, 0x44); + + Assert.Equal(color.ToString(), color.ToString("", null)); + } + + [Fact] + public void HslColor_ToString_Null_Format_Matches_Default() + { + var color = new HslColor(0.8, 200, 0.6, 0.4); + + Assert.Equal(color.ToString(), color.ToString(null, null)); + } + + [Fact] + public void HsvColor_ToString_Null_Format_Matches_Default() + { + var color = new HsvColor(0.8, 200, 0.6, 0.4); + + Assert.Equal(color.ToString(), color.ToString(null, null)); + } + + [Theory] + [InlineData(0xFF, 0xFF, 0x88, 0x44, "#FFFF8844")] + [InlineData(0x40, 0xFF, 0x88, 0x44, "#40FF8844")] + [InlineData(0xFF, 0x00, 0x00, 0x00, "#FF000000")] + [InlineData(0x00, 0x00, 0x00, 0x00, "#00000000")] + public void Color_ToString_X_Returns_Xaml_Hex_With_Alpha(byte a, byte r, byte g, byte b, string expected) + { + var color = new Color(a, r, g, b); + + Assert.Equal(expected, color.ToString("X", CultureInfo.InvariantCulture)); + } + + [Theory] + [InlineData(0xFF, 0xFF, 0x88, 0x44, "#FF8844")] + [InlineData(0x40, 0xFF, 0x88, 0x44, "#FF8844")] + [InlineData(0x00, 0x00, 0x00, 0x00, "#000000")] + public void Color_ToString_x_Returns_Hex_Without_Alpha(byte a, byte r, byte g, byte b, string expected) + { + var color = new Color(a, r, g, b); + + Assert.Equal(expected, color.ToString("x", CultureInfo.InvariantCulture)); + } + + [Theory] + [InlineData(0xFF, 0xFF, 0x88, 0x44, "#FF8844FF")] + [InlineData(0x40, 0xFF, 0x88, 0x44, "#FF884440")] + [InlineData(0x00, 0x00, 0x00, 0x00, "#00000000")] + public void Color_ToString_H_Returns_Html_Hex_With_Alpha(byte a, byte r, byte g, byte b, string expected) + { + var color = new Color(a, r, g, b); + + Assert.Equal(expected, color.ToString("H", CultureInfo.InvariantCulture)); + } + + [Fact] + public void HslColor_ToString_X_Converts_To_Rgb_Hex() + { + // Pure red: HSL(0, 1, 0.5) = RGB(255, 0, 0) + var hsl = new HslColor(1.0, 0, 1.0, 0.5); + + Assert.Equal("#FFFF0000", hsl.ToString("X", CultureInfo.InvariantCulture)); + } + + [Fact] + public void HslColor_ToString_x_Converts_To_Rgb_Hex() + { + var hsl = new HslColor(1.0, 0, 1.0, 0.5); + + Assert.Equal("#FF0000", hsl.ToString("x", CultureInfo.InvariantCulture)); + } + + [Fact] + public void HsvColor_ToString_X_Converts_To_Rgb_Hex() + { + // Pure red: HSV(0, 1, 1) = RGB(255, 0, 0) + var hsv = new HsvColor(1.0, 0, 1.0, 1.0); + + Assert.Equal("#FFFF0000", hsv.ToString("X", CultureInfo.InvariantCulture)); + } + + [Fact] + public void HsvColor_ToString_x_Converts_To_Rgb_Hex() + { + var hsv = new HsvColor(1.0, 0, 1.0, 1.0); + + Assert.Equal("#FF0000", hsv.ToString("x", CultureInfo.InvariantCulture)); + } + + [Theory] + [InlineData(0xFF, 0xFF, 0x88, 0x44, "rgba(255, 136, 68, 1.00)")] + [InlineData(0x80, 0xFF, 0x88, 0x44, "rgba(255, 136, 68, 0.50)")] + [InlineData(0x00, 0x00, 0x00, 0x00, "rgba(0, 0, 0, 0.00)")] + public void Color_ToString_R_Returns_Rgba_With_Alpha(byte a, byte r, byte g, byte b, string expected) + { + var color = new Color(a, r, g, b); + + Assert.Equal(expected, color.ToString("R", CultureInfo.InvariantCulture)); + } + + [Theory] + [InlineData(0xFF, 0xFF, 0x88, 0x44, "rgb(255, 136, 68)")] + [InlineData(0x80, 0xFF, 0x88, 0x44, "rgb(255, 136, 68)")] + [InlineData(0xFF, 0x00, 0x00, 0x00, "rgb(0, 0, 0)")] + public void Color_ToString_r_Returns_Rgb_Without_Alpha(byte a, byte r, byte g, byte b, string expected) + { + var color = new Color(a, r, g, b); + + Assert.Equal(expected, color.ToString("r", CultureInfo.InvariantCulture)); + } + + [Fact] + public void HslColor_ToString_R_Converts_To_Rgba() + { + // Pure blue: HSL(240, 1, 0.5) = RGB(0, 0, 255) + var hsl = new HslColor(1.0, 240, 1.0, 0.5); + + Assert.Equal("rgba(0, 0, 255, 1.00)", hsl.ToString("R", CultureInfo.InvariantCulture)); + } + + [Fact] + public void HsvColor_ToString_r_Converts_To_Rgb() + { + // Pure red: HSV(0, 1, 1) = RGB(255, 0, 0) + var hsv = new HsvColor(1.0, 0, 1.0, 1.0); + + Assert.Equal("rgb(255, 0, 0)", hsv.ToString("r", CultureInfo.InvariantCulture)); + } + + [Theory] + [InlineData(0xFF, 0xFF, 0x80, 0x00, "rgba(100%, 50%, 0%, 100%)")] + [InlineData(0x80, 0xFF, 0x80, 0x00, "rgba(100%, 50%, 0%, 50%)")] + [InlineData(0x00, 0x00, 0x00, 0x00, "rgba(0%, 0%, 0%, 0%)")] + public void Color_ToString_RPct_Returns_Rgba_Percent(byte a, byte r, byte g, byte b, string expected) + { + var color = new Color(a, r, g, b); + + Assert.Equal(expected, color.ToString("R%", CultureInfo.InvariantCulture)); + } + + [Theory] + [InlineData(0xFF, 0xFF, 0x80, 0x00, "rgb(100%, 50%, 0%)")] + [InlineData(0x80, 0xFF, 0x80, 0x00, "rgb(100%, 50%, 0%)")] + [InlineData(0xFF, 0x00, 0x00, 0x00, "rgb(0%, 0%, 0%)")] + public void Color_ToString_rPct_Returns_Rgb_Percent(byte a, byte r, byte g, byte b, string expected) + { + var color = new Color(a, r, g, b); + + Assert.Equal(expected, color.ToString("r%", CultureInfo.InvariantCulture)); + } + + [Fact] + public void HslColor_ToString_RPct_Converts_To_Rgba_Percent() + { + // Pure red: HSL(0, 1, 0.5) = RGB(255, 0, 0) + var hsl = new HslColor(1.0, 0, 1.0, 0.5); + + Assert.Equal("rgba(100%, 0%, 0%, 100%)", hsl.ToString("R%", CultureInfo.InvariantCulture)); + } + + [Theory] + [InlineData(1.0, 180, 0.5, 0.5, "hsla(180, 50%, 50%, 1.00)")] + [InlineData(0.5, 240, 0.8, 0.2, "hsla(240, 80%, 20%, 0.50)")] + [InlineData(0.0, 0, 0.0, 0.0, "hsla(0, 0%, 0%, 0.00)")] + public void HslColor_ToString_L_Returns_Hsla_With_Alpha(double a, double h, double s, double l, string expected) + { + var color = new HslColor(a, h, s, l); + + Assert.Equal(expected, color.ToString("L", CultureInfo.InvariantCulture)); + } + + [Theory] + [InlineData(1.0, 180, 0.5, 0.5, "hsl(180, 50%, 50%)")] + [InlineData(0.5, 240, 0.8, 0.2, "hsl(240, 80%, 20%)")] + [InlineData(0.0, 0, 0.0, 0.0, "hsl(0, 0%, 0%)")] + public void HslColor_ToString_l_Returns_Hsl_Without_Alpha(double a, double h, double s, double l, string expected) + { + var color = new HslColor(a, h, s, l); + + Assert.Equal(expected, color.ToString("l", CultureInfo.InvariantCulture)); + } + + [Fact] + public void Color_ToString_L_Converts_To_Hsla() + { + // Pure red: RGB(255, 0, 0) = HSL(0, 100%, 50%) + var color = new Color(0xFF, 0xFF, 0x00, 0x00); + + Assert.Equal("hsla(0, 100%, 50%, 1.00)", color.ToString("L", CultureInfo.InvariantCulture)); + } + + [Fact] + public void Color_ToString_l_Converts_To_Hsl() + { + var color = new Color(0xFF, 0xFF, 0x00, 0x00); + + Assert.Equal("hsl(0, 100%, 50%)", color.ToString("l", CultureInfo.InvariantCulture)); + } + + [Fact] + public void HsvColor_ToString_L_Converts_To_Hsla() + { + // Pure red: HSV(0, 1, 1) = HSL(0, 100%, 50%) + var hsv = new HsvColor(1.0, 0, 1.0, 1.0); + + Assert.Equal("hsla(0, 100%, 50%, 1.00)", hsv.ToString("L", CultureInfo.InvariantCulture)); + } + + [Theory] + [InlineData(1.0, 180, 0.5, 0.5, "hsla(50%, 50%, 50%, 100%)")] + [InlineData(0.5, 90, 1.0, 1.0, "hsla(25%, 100%, 100%, 50%)")] + [InlineData(1.0, 0, 0.0, 0.0, "hsla(0%, 0%, 0%, 100%)")] + public void HslColor_ToString_LPct_Returns_Hsla_All_Percent(double a, double h, double s, double l, string expected) + { + var color = new HslColor(a, h, s, l); + + Assert.Equal(expected, color.ToString("L%", CultureInfo.InvariantCulture)); + } + + [Theory] + [InlineData(1.0, 180, 0.5, 0.5, "hsl(50%, 50%, 50%)")] + [InlineData(0.5, 90, 1.0, 1.0, "hsl(25%, 100%, 100%)")] + [InlineData(1.0, 0, 0.0, 0.0, "hsl(0%, 0%, 0%)")] + public void HslColor_ToString_lPct_Returns_Hsl_All_Percent(double a, double h, double s, double l, string expected) + { + var color = new HslColor(a, h, s, l); + + Assert.Equal(expected, color.ToString("l%", CultureInfo.InvariantCulture)); + } + + [Theory] + [InlineData(1.0, 180, 0.5, 0.5, "hsva(180, 50%, 50%, 1.00)")] + [InlineData(0.5, 240, 0.8, 0.2, "hsva(240, 80%, 20%, 0.50)")] + [InlineData(0.0, 0, 0.0, 0.0, "hsva(0, 0%, 0%, 0.00)")] + public void HsvColor_ToString_V_Returns_Hsva_With_Alpha(double a, double h, double s, double v, string expected) + { + var color = new HsvColor(a, h, s, v); + + Assert.Equal(expected, color.ToString("V", CultureInfo.InvariantCulture)); + } + + [Theory] + [InlineData(1.0, 180, 0.5, 0.5, "hsv(180, 50%, 50%)")] + [InlineData(0.5, 240, 0.8, 0.2, "hsv(240, 80%, 20%)")] + [InlineData(0.0, 0, 0.0, 0.0, "hsv(0, 0%, 0%)")] + public void HsvColor_ToString_v_Returns_Hsv_Without_Alpha(double a, double h, double s, double v, string expected) + { + var color = new HsvColor(a, h, s, v); + + Assert.Equal(expected, color.ToString("v", CultureInfo.InvariantCulture)); + } + + [Fact] + public void Color_ToString_V_Converts_To_Hsva() + { + // Pure red: RGB(255, 0, 0) = HSV(0, 100%, 100%) + var color = new Color(0xFF, 0xFF, 0x00, 0x00); + + Assert.Equal("hsva(0, 100%, 100%, 1.00)", color.ToString("V", CultureInfo.InvariantCulture)); + } + + [Fact] + public void HslColor_ToString_V_Converts_To_Hsva() + { + // Pure red: HSL(0, 1, 0.5) = HSV(0, 100%, 100%) + var hsl = new HslColor(1.0, 0, 1.0, 0.5); + + Assert.Equal("hsva(0, 100%, 100%, 1.00)", hsl.ToString("V", CultureInfo.InvariantCulture)); + } + + [Theory] + [InlineData(1.0, 180, 0.5, 0.5, "hsva(50%, 50%, 50%, 100%)")] + [InlineData(0.5, 90, 1.0, 1.0, "hsva(25%, 100%, 100%, 50%)")] + [InlineData(1.0, 0, 0.0, 0.0, "hsva(0%, 0%, 0%, 100%)")] + public void HsvColor_ToString_VPct_Returns_Hsva_All_Percent(double a, double h, double s, double v, string expected) + { + var color = new HsvColor(a, h, s, v); + + Assert.Equal(expected, color.ToString("V%", CultureInfo.InvariantCulture)); + } + + [Theory] + [InlineData(1.0, 180, 0.5, 0.5, "hsv(50%, 50%, 50%)")] + [InlineData(0.5, 90, 1.0, 1.0, "hsv(25%, 100%, 100%)")] + [InlineData(1.0, 0, 0.0, 0.0, "hsv(0%, 0%, 0%)")] + public void HsvColor_ToString_vPct_Returns_Hsv_All_Percent(double a, double h, double s, double v, string expected) + { + var color = new HsvColor(a, h, s, v); + + Assert.Equal(expected, color.ToString("v%", CultureInfo.InvariantCulture)); + } + + [Fact] + public void Color_ToString_Invalid_Format_Throws() + { + var color = new Color(0xFF, 0xFF, 0x00, 0x00); + + Assert.Throws(() => color.ToString("Z", null)); + } + + [Fact] + public void HslColor_ToString_Invalid_Format_Throws() + { + var color = new HslColor(1.0, 0, 0, 0); + + Assert.Throws(() => color.ToString("Z", null)); + } + + [Fact] + public void HsvColor_ToString_Invalid_Format_Throws() + { + var color = new HsvColor(1.0, 0, 0, 0); + + Assert.Throws(() => color.ToString("Z", null)); + } + + [Theory] + [InlineData("C")] + [InlineData("c")] + [InlineData("A")] + [InlineData("a")] + [InlineData("P")] + [InlineData("h")] + public void Color_ToString_Reserved_And_Removed_Specifiers_Throw(string format) + { + var color = new Color(0xFF, 0xFF, 0x00, 0x00); + + Assert.Throws(() => color.ToString(format, null)); + } + + [Theory] + [InlineData("C")] + [InlineData("c")] + public void HslColor_ToString_Reserved_C_Throws(string format) + { + var color = new HslColor(1.0, 0, 1.0, 0.5); + + Assert.Throws(() => color.ToString(format, null)); + } + + [Theory] + [InlineData("C")] + [InlineData("c")] + public void HsvColor_ToString_Reserved_C_Throws(string format) + { + var color = new HsvColor(1.0, 0, 1.0, 1.0); + + Assert.Throws(() => color.ToString(format, null)); + } + + [Fact] + public void Color_ToString_IFormatProvider_Is_Ignored() + { + var color = new Color(0x80, 0xFF, 0x88, 0x44); + var french = CultureInfo.GetCultureInfo("fr-FR"); + + Assert.Equal( + color.ToString("R", CultureInfo.InvariantCulture), + color.ToString("R", french)); + } + + [Fact] + public void HslColor_ToString_IFormatProvider_Is_Ignored() + { + var color = new HslColor(0.5, 180, 0.5, 0.5); + var french = CultureInfo.GetCultureInfo("fr-FR"); + + Assert.Equal( + color.ToString("L", CultureInfo.InvariantCulture), + color.ToString("L", french)); + } } }