From 79162b5da8c39c6fd0fffc21bfc872025f0ae561 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen Date: Wed, 19 Aug 2026 17:22:19 +0000 Subject: [PATCH] feat: implement unified IFormattable on Color, HslColor, and HsvColor (#20919) * fix: correct HslColor.ToString() prefix from hsva to hsla Pre-existing copy-paste bug: HslColor.ToString() was outputting "hsva(" instead of "hsla(" for its default format. * feat: implement unified IFormattable on Color, HslColor, and HsvColor All three color types now support all format specifiers via auto-conversion, following the DateTime analogy where the same format codes work regardless of source type. Format specifiers: Hex: X (#AARRGGBB), x (#RRGGBB), H (#RRGGBBAA) RGB: R/r (absolute), R%/r% (percent) HSL: L/l (CSS standard), L%/l% (all percent) HSV: V/v (CSS standard), V%/v% (all percent) Convention: uppercase = include alpha (rgba/hsla/hsva prefix), lowercase = exclude alpha (rgb/hsl/hsv prefix). Each type handles its native model natively and delegates cross-model formats via ToRgb()/ToHsl()/ToHsv(). Breaking changes from prior PR iteration: - "h" dropped (identical to "x" without alpha) - "C"/"c" removed, reserved for future complex format strings - "P"/"p" replaced by "R%"/"r%" - "R" now outputs rgba() not rgb() (CSS convention) - "L" now outputs hsla() not hsl() (CSS convention) - "V" now outputs hsva() not hsv() (CSS convention) * refactor(media): clarify color CSS formatters Color formatting now exposes the same IFormattable implementation in the BuildTask compile path as in normal builds. Keeping the interface conditional made the public contract harder to reason about even though the formatting members are System-only APIs. Remove the BuildTask guard from the formatting members, combine alpha and no-alpha CSS helpers behind includeAlpha parameters, and rename the helpers so future changes preserve the CSS-compliant output contract. * Remove regions --------- Co-authored-by: Jumar Macato <16554748+jmacato@users.noreply.github.com> Co-authored-by: Nathan Nguyen Co-authored-by: Julien Lebosquain --- src/Avalonia.Base/Media/Color.cs | 93 +++- src/Avalonia.Base/Media/HslColor.cs | 97 ++++- src/Avalonia.Base/Media/HsvColor.cs | 95 +++- .../Media/ColorTests.cs | 411 ++++++++++++++++++ 4 files changed, 692 insertions(+), 4 deletions(-) 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)); + } } }