| | | 1 | | namespace Allyaria.Theming.Types; |
| | | 2 | | |
| | | 3 | | /// <summary>Represents an immutable color theme with red, green, blue, and alpha channels.</summary> |
| | | 4 | | /// <remarks> |
| | | 5 | | /// <para> |
| | | 6 | | /// A <see cref="HexColor" /> can be constructed directly from component values (<see cref="HexByte" />) or parsed f |
| | | 7 | | /// a wide range of color string formats, including: |
| | | 8 | | /// </para> |
| | | 9 | | /// <list type="bullet"> |
| | | 10 | | /// <item> |
| | | 11 | | /// <description><b>Hexadecimal</b> — <c>#RGB</c>, <c>#RGBA</c>, <c>#RRGGBB</c>, or <c>#RRGGBBAA</c></descri |
| | | 12 | | /// </item> |
| | | 13 | | /// <item> |
| | | 14 | | /// <description> |
| | | 15 | | /// <b>Functional RGB(A)</b> — <c>rgb(...)</c> and <c>rgba(...)</c>, including modern CSS Color Level 4 synt |
| | | 16 | | /// (e.g., <c>rgb(255 0 0 / .5)</c>) |
| | | 17 | | /// </description> |
| | | 18 | | /// </item> |
| | | 19 | | /// <item> |
| | | 20 | | /// <description><b>Functional HSV(A)</b> — <c>hsv(...)</c> and <c>hsva(...)</c></description> |
| | | 21 | | /// </item> |
| | | 22 | | /// <item> |
| | | 23 | | /// <description> |
| | | 24 | | /// <b>Named Colors</b> — case-insensitive lookup against the global <see cref="Colors" /> registry (e.g., |
| | | 25 | | /// <c>"Red500"</c>). The lookup uses the comparer configured in that registry (typically |
| | | 26 | | /// <see cref="StringComparer.InvariantCultureIgnoreCase" />). |
| | | 27 | | /// </description> |
| | | 28 | | /// </item> |
| | | 29 | | /// </list> |
| | | 30 | | /// <para> |
| | | 31 | | /// The type is a <see langword="readonly struct" />, ensuring immutability and thread safety. All channel values ar |
| | | 32 | | /// normalized at construction time, and derived HSV components (<see cref="H" />, <see cref="S" />, <see cref="V" / |
| | | 33 | | /// are computed automatically. |
| | | 34 | | /// </para> |
| | | 35 | | /// <para> |
| | | 36 | | /// If an input string cannot be parsed as a supported format or a known color name, an |
| | | 37 | | /// <see cref="AryArgumentException" /> is thrown. |
| | | 38 | | /// </para> |
| | | 39 | | /// </remarks> |
| | | 40 | | public readonly struct HexColor : IComparable<HexColor>, IEquatable<HexColor> |
| | | 41 | | { |
| | | 42 | | /// <summary> |
| | | 43 | | /// Regular-expression sub-pattern used to match an alpha (opacity) component in color functions. Accepts values <c> |
| | | 44 | | /// <c>1</c>, <c>1.0</c>, <c>0.5</c>, or fractional forms such as <c>.5</c>. |
| | | 45 | | /// </summary> |
| | | 46 | | private const string AlphaPattern = @"(?<alpha>(?:0?\.\d+|0|1(?:\.0+)?|(?:100|[1-9]?\d)%))"; |
| | | 47 | | |
| | | 48 | | /// <summary>Precompiled regex pattern for hexadecimal color strings.</summary> |
| | 1 | 49 | | private static readonly Regex HexColorPattern = new( |
| | 1 | 50 | | pattern: "^#([0-9A-F]{3}|[0-9A-F]{4}|[0-9A-F]{6}|[0-9A-F]{8})$", |
| | 1 | 51 | | options: RegexOptions.Compiled | RegexOptions.IgnoreCase |
| | 1 | 52 | | ); |
| | | 53 | | |
| | | 54 | | /// <summary> |
| | | 55 | | /// Compiled regular expression that matches an <c>hsva(h, s%, v%, a)</c> color function containing hue, saturation, |
| | | 56 | | /// and alpha components. Alpha values are validated against <see cref="AlphaPattern" />. |
| | | 57 | | /// </summary> |
| | 1 | 58 | | private static readonly Regex HsvaPattern = new( |
| | 1 | 59 | | pattern: |
| | 1 | 60 | | @"^hsva\s*\(\s*([+-]?(?:\d+(?:\.\d+)?|\.\d+))\s*,\s*([+-]?(?:\d+(?:\.\d+)?|\.\d+))%?\s*,\s*([+-]?(?:\d+(?:\.\d+) |
| | 1 | 61 | | AlphaPattern + @"\s*\)$", |
| | 1 | 62 | | options: RegexOptions.Compiled | RegexOptions.IgnoreCase |
| | 1 | 63 | | ); |
| | | 64 | | |
| | | 65 | | /// <summary> |
| | | 66 | | /// Compiled regular expression that matches an <c>hsv(h, s%, v%)</c> color function containing hue, saturation, and |
| | | 67 | | /// components. The pattern supports optional signs and decimal fractions, but does <b>not</b> allow an alpha compon |
| | | 68 | | /// (see <see cref="HsvaPattern" /> for that). |
| | | 69 | | /// </summary> |
| | 1 | 70 | | private static readonly Regex HsvPattern = new( |
| | 1 | 71 | | pattern: |
| | 1 | 72 | | @"^hsv\s*\(\s*([+-]?(?:\d+(?:\.\d+)?|\.\d+))\s*,\s*([+-]?(?:\d+(?:\.\d+)?|\.\d+))%?\s*,\s*([+-]?(?:\d+(?:\.\d+)? |
| | 1 | 73 | | options: RegexOptions.Compiled | RegexOptions.IgnoreCase |
| | 1 | 74 | | ); |
| | | 75 | | |
| | | 76 | | /// <summary> |
| | | 77 | | /// Compiled regular expression that matches modern CSS Color Level 4 syntax for <c>rgb(...)</c> or <c>rgba(...)</c> |
| | | 78 | | /// channels are space-separated and the alpha component follows a slash—for example, <c>rgb(255 0 0 / .5)</c>. Fall |
| | | 79 | | /// to the same channel validation as <see cref="RgbaPattern" />. |
| | | 80 | | /// </summary> |
| | 1 | 81 | | private static readonly Regex RgbaCss4Pattern = new( |
| | 1 | 82 | | pattern: |
| | 1 | 83 | | @"^rgba?\s*\(\s*([+-]?(?:\d+(?:\.\d+)?|\.\d+)%?)\s+([+-]?(?:\d+(?:\.\d+)?|\.\d+)%?)\s+([+-]?(?:\d+(?:\.\d+)?|\.\ |
| | 1 | 84 | | + AlphaPattern + @")?\s*\)$", |
| | 1 | 85 | | options: RegexOptions.Compiled | RegexOptions.IgnoreCase |
| | 1 | 86 | | ); |
| | | 87 | | |
| | | 88 | | /// <summary> |
| | | 89 | | /// Compiled regular expression that matches an <c>rgba(r, g, b, a)</c> color function with integer RGB channels and |
| | | 90 | | /// explicit alpha component validated by <see cref="AlphaPattern" />. This corresponds to the traditional comma-sep |
| | | 91 | | /// CSS syntax. |
| | | 92 | | /// </summary> |
| | 1 | 93 | | private static readonly Regex RgbaPattern = new( |
| | 1 | 94 | | pattern: |
| | 1 | 95 | | @"^rgba\s*\(\s*([+-]?(?:\d{1,3}(?:\.\d+)?%?))\s*,\s*([+-]?(?:\d{1,3}(?:\.\d+)?%?))\s*,\s*([+-]?(?:\d{1,3}(?:\.\d |
| | 1 | 96 | | + AlphaPattern + @"\s*\)$", |
| | 1 | 97 | | options: RegexOptions.Compiled | RegexOptions.IgnoreCase |
| | 1 | 98 | | ); |
| | | 99 | | |
| | | 100 | | /// <summary> |
| | | 101 | | /// Compiled regular expression that matches an <c>rgb(r, g, b)</c> color function consisting of three integer chann |
| | | 102 | | /// values in the 0–255 range. This pattern does not support alpha; use <see cref="RgbaPattern" /> or |
| | | 103 | | /// <see cref="RgbaCss4Pattern" /> for variants that do. |
| | | 104 | | /// </summary> |
| | 1 | 105 | | private static readonly Regex RgbPattern = new( |
| | 1 | 106 | | pattern: |
| | 1 | 107 | | @"^rgb\s*\(\s*([+-]?(?:\d{1,3}(?:\.\d+)?%?))\s*,\s*([+-]?(?:\d{1,3}(?:\.\d+)?%?))\s*,\s*([+-]?(?:\d{1,3}(?:\.\d+ |
| | 1 | 108 | | options: RegexOptions.Compiled | RegexOptions.IgnoreCase |
| | 1 | 109 | | ); |
| | | 110 | | |
| | | 111 | | /// <summary> |
| | | 112 | | /// Initializes a new instance of the <see cref="HexColor" /> struct with all channels default-initialized to 0. |
| | | 113 | | /// </summary> |
| | | 114 | | public HexColor() |
| | 2 | 115 | | : this(red: new HexByte(), green: new HexByte(), blue: new HexByte(), alpha: new HexByte()) { } |
| | | 116 | | |
| | | 117 | | /// <summary>Initializes a new instance of the <see cref="HexColor" /> struct from individual channels.</summary> |
| | | 118 | | /// <param name="red">The red channel theme.</param> |
| | | 119 | | /// <param name="green">The green channel theme.</param> |
| | | 120 | | /// <param name="blue">The blue channel theme.</param> |
| | | 121 | | /// <param name="alpha">The optional alpha channel theme; if <see langword="null" />, defaults to 255 (opaque).</par |
| | | 122 | | public HexColor(HexByte red, HexByte green, HexByte blue, HexByte? alpha = null) |
| | | 123 | | { |
| | 1373177 | 124 | | R = red; |
| | 1373177 | 125 | | G = green; |
| | 1373177 | 126 | | B = blue; |
| | 1373177 | 127 | | A = alpha ?? new HexByte(value: 255); |
| | | 128 | | |
| | 1373177 | 129 | | RgbToHsv(hue: out var h, saturation: out var s, value: out var v); |
| | | 130 | | |
| | 1373177 | 131 | | H = h; |
| | 1373177 | 132 | | S = s; |
| | 1373177 | 133 | | V = v; |
| | 1373177 | 134 | | } |
| | | 135 | | |
| | | 136 | | /// <summary> |
| | | 137 | | /// Initializes a new instance of the <see cref="HexColor" /> struct from a color string. Accepts the following form |
| | | 138 | | /// <list type="bullet"> |
| | | 139 | | /// <item> |
| | | 140 | | /// <description>Hex: <c>#RGB</c>, <c>#RGBA</c>, <c>#RRGGBB</c>, <c>#RRGGBBAA</c></description> |
| | | 141 | | /// </item> |
| | | 142 | | /// <item> |
| | | 143 | | /// <description>RGB(A): <c>rgb(...)</c>, <c>rgba(...)</c> (including CSS Color Level 4 slash-alpha syntax)< |
| | | 144 | | /// </item> |
| | | 145 | | /// <item> |
| | | 146 | | /// <description>HSV(A): <c>hsv(...)</c>, <c>hsva(...)</c></description> |
| | | 147 | | /// </item> |
| | | 148 | | /// <item> |
| | | 149 | | /// <description> |
| | | 150 | | /// <b>Named colors</b>: a case-insensitive lookup against the global <c>Colors</c> registry (e.g., <c>"Red5 |
| | | 151 | | /// ). This is attempted only if the theme does not start with <c>"#"</c>, <c>"rgb"</c>, or <c>"hsv"</c>. |
| | | 152 | | /// </description> |
| | | 153 | | /// </item> |
| | | 154 | | /// </list> |
| | | 155 | | /// </summary> |
| | | 156 | | /// <param name="value">The color string to parse. Leading/trailing whitespace is ignored.</param> |
| | | 157 | | /// <exception cref="AryArgumentException"> |
| | | 158 | | /// Thrown when <paramref name="value" /> is null/empty or does not match any supported format or known color name. |
| | | 159 | | /// </exception> |
| | | 160 | | /// <remarks> |
| | | 161 | | /// Named-color comparison follows the <see cref="StringComparer" /> used by the <c>Colors</c> registry (e.g., |
| | | 162 | | /// <c>InvariantCultureIgnoreCase</c>). If layering concerns matter, consider moving name resolution out of |
| | | 163 | | /// <see cref="HexColor" /> to avoid an upward dependency on theming constants. |
| | | 164 | | /// </remarks> |
| | | 165 | | public HexColor(string value) |
| | | 166 | | { |
| | 13027 | 167 | | AryGuard.NotNullOrWhiteSpace(value: value); |
| | | 168 | | |
| | | 169 | | HexByte red; |
| | | 170 | | HexByte green; |
| | | 171 | | HexByte blue; |
| | | 172 | | HexByte alpha; |
| | 13026 | 173 | | var trimmed = value.Trim(); |
| | | 174 | | |
| | 13026 | 175 | | if (trimmed.StartsWith(value: "hsv", comparisonType: StringComparison.OrdinalIgnoreCase)) |
| | | 176 | | { |
| | 7 | 177 | | ParseHsva(value: trimmed, red: out red, green: out green, blue: out blue, alpha: out alpha); |
| | | 178 | | } |
| | 13019 | 179 | | else if (trimmed.StartsWith(value: "rgb", comparisonType: StringComparison.OrdinalIgnoreCase)) |
| | | 180 | | { |
| | 5 | 181 | | ParseRgba(value: trimmed, red: out red, green: out green, blue: out blue, alpha: out alpha); |
| | | 182 | | } |
| | 13014 | 183 | | else if (trimmed.StartsWith(value: "#", comparisonType: StringComparison.OrdinalIgnoreCase)) |
| | | 184 | | { |
| | 13005 | 185 | | ParseHex(value: trimmed, red: out red, green: out green, blue: out blue, alpha: out alpha); |
| | | 186 | | } |
| | 9 | 187 | | else if (!TryParseColorName(value: trimmed, red: out red, green: out green, blue: out blue, alpha: out alpha)) |
| | | 188 | | { |
| | 5 | 189 | | throw new AryArgumentException(message: $"Invalid color string: {value}.", argName: nameof(value)); |
| | | 190 | | } |
| | | 191 | | |
| | 13013 | 192 | | R = red; |
| | 13013 | 193 | | G = green; |
| | 13013 | 194 | | B = blue; |
| | 13013 | 195 | | A = alpha; |
| | | 196 | | |
| | 13013 | 197 | | RgbToHsv(hue: out var h, saturation: out var s, value: out var v); |
| | | 198 | | |
| | 13013 | 199 | | H = h; |
| | 13013 | 200 | | S = s; |
| | 13013 | 201 | | V = v; |
| | 13013 | 202 | | } |
| | | 203 | | |
| | | 204 | | /// <summary>Gets the alpha (opacity) channel.</summary> |
| | 1354997 | 205 | | public HexByte A { get; } |
| | | 206 | | |
| | | 207 | | /// <summary>Gets the blue channel.</summary> |
| | 4188823 | 208 | | public HexByte B { get; } |
| | | 209 | | |
| | | 210 | | /// <summary>Gets the green channel.</summary> |
| | 4188823 | 211 | | public HexByte G { get; } |
| | | 212 | | |
| | | 213 | | /// <summary> |
| | | 214 | | /// Gets the <b>Hue</b> component of the color, expressed in degrees within the range [0, 360). Represents the color |
| | | 215 | | /// dominant wavelength — <c>0</c> = red, <c>120</c> = green, <c>240</c> = blue. |
| | | 216 | | /// </summary> |
| | 1167221 | 217 | | public double H { get; } |
| | | 218 | | |
| | | 219 | | /// <summary>Gets the red channel.</summary> |
| | 4188830 | 220 | | public HexByte R { get; } |
| | | 221 | | |
| | | 222 | | /// <summary> |
| | | 223 | | /// Gets the <b>Saturation</b> component of the color, normalized to the range <c>[0, 1]</c>. A theme of <c>0</c> in |
| | | 224 | | /// a fully desaturated color (gray), while <c>1</c> indicates full color intensity. |
| | | 225 | | /// </summary> |
| | 1167220 | 226 | | public double S { get; } |
| | | 227 | | |
| | | 228 | | /// <summary> |
| | | 229 | | /// Gets the <b>Value</b> (brightness) component of the color, normalized to the range <c>[0, 1]</c>. A theme of <c> |
| | | 230 | | /// represents black, and <c>1</c> represents the brightest possible version of the color. |
| | | 231 | | /// </summary> |
| | 674267 | 232 | | public double V { get; } |
| | | 233 | | |
| | | 234 | | /// <summary> |
| | | 235 | | /// Linearly interpolates the current <see cref="V" /> (theme/brightness) toward a specified target theme by a given |
| | | 236 | | /// factor within the closed interval <c>[0, 1]</c>. |
| | | 237 | | /// </summary> |
| | | 238 | | /// <param name="target">The target brightness theme to blend toward (0–1).</param> |
| | | 239 | | /// <param name="factor"> |
| | | 240 | | /// The interpolation factor clamped to <c>[0, 1]</c>. A theme of <c>0</c> returns the current <see cref="V" />; a t |
| | | 241 | | /// <c>1</c> returns the <paramref name="target" />. |
| | | 242 | | /// </param> |
| | | 243 | | /// <returns>The interpolated scalar theme resulting from the linear blend.</returns> |
| | 16125 | 244 | | private double BlendValue(double target, double factor) => V + (target - V) * factor; |
| | | 245 | | |
| | | 246 | | /// <summary>Clamps a normalized channel (0–1) to a byte (0–255) using banker's rounding (ToEven).</summary> |
| | | 247 | | /// <param name="value">The normalized theme.</param> |
| | | 248 | | /// <returns>The clamped 8-bit channel theme.</returns> |
| | | 249 | | private static byte ClampToByte(double value) |
| | 3501678 | 250 | | => (byte)Math.Clamp(value: Math.Round(value: value * 255.0, mode: MidpointRounding.ToEven), min: 0, max: 255); |
| | | 251 | | |
| | | 252 | | /// <summary>Compares this instance with another <see cref="HexColor" /> to determine relative ordering.</summary> |
| | | 253 | | /// <param name="other">The other color to compare.</param> |
| | | 254 | | /// <returns> |
| | | 255 | | /// A theme less than zero if this instance precedes <paramref name="other" />; zero if equal; greater than zero if |
| | | 256 | | /// instance follows <paramref name="other" />. |
| | | 257 | | /// </returns> |
| | | 258 | | public int CompareTo(HexColor other) |
| | 5 | 259 | | => (Red: R, Green: G, Blue: B, Alpha: A).CompareTo(other: (other.R, other.G, other.B, other.A)); |
| | | 260 | | |
| | | 261 | | /// <summary> |
| | | 262 | | /// Calculates the WCAG 2.2 contrast ratio between the current color (treated as the foreground) and a specified bac |
| | | 263 | | /// color, based on their relative luminance in the sRGB color space. |
| | | 264 | | /// </summary> |
| | | 265 | | /// <param name="background"> |
| | | 266 | | /// The background <see cref="HexColor" /> against which to measure contrast. Both the current color and |
| | | 267 | | /// <paramref name="background" /> are assumed to be fully opaque. |
| | | 268 | | /// </param> |
| | | 269 | | /// <exception cref="AryArgumentException">Thrown when the contrast ratio is NaN or Infinity.</exception> |
| | | 270 | | /// <returns> |
| | | 271 | | /// A <see cref="double" /> representing the contrast ratio, defined as <c>(Lighter + 0.05) / (Darker + 0.05)</c>, w |
| | | 272 | | /// <em>L</em> is the WCAG relative luminance. The ratio ranges from <c>1.0</c> (no contrast) to <em>21.0</em> (maxi |
| | | 273 | | /// contrast). |
| | | 274 | | /// </returns> |
| | | 275 | | /// <remarks> |
| | | 276 | | /// The returned ratio can be evaluated against WCAG 2.2 contrast thresholds: |
| | | 277 | | /// <list type="bullet"> |
| | | 278 | | /// <item> |
| | | 279 | | /// <description>Normal text: ≥ 4.5 : 1</description> |
| | | 280 | | /// </item> |
| | | 281 | | /// <item> |
| | | 282 | | /// <description>Large text (≥ 18 pt or 14 pt bold): ≥ 3 : 1</description> |
| | | 283 | | /// </item> |
| | | 284 | | /// <item> |
| | | 285 | | /// <description>UI components and graphics: ≥ 3 : 1</description> |
| | | 286 | | /// </item> |
| | | 287 | | /// </list> |
| | | 288 | | /// </remarks> |
| | | 289 | | public double ContrastRatio(HexColor background) |
| | | 290 | | { |
| | 1210814 | 291 | | var foregroundL = ToRelativeLuminance(); |
| | 1210814 | 292 | | var backgroundL = background.ToRelativeLuminance(); |
| | 1210814 | 293 | | var lighter = Math.Max(val1: foregroundL, val2: backgroundL); |
| | 1210814 | 294 | | var darker = Math.Min(val1: foregroundL, val2: backgroundL); |
| | 1210814 | 295 | | var result = (lighter + 0.05) / (darker + 0.05); |
| | | 296 | | |
| | 1210814 | 297 | | return double.IsNaN(d: result) || double.IsInfinity(d: result) |
| | 1210814 | 298 | | ? throw new AryArgumentException( |
| | 1210814 | 299 | | message: "The specified colors are not valid colors.", argName: nameof(background) |
| | 1210814 | 300 | | ) |
| | 1210814 | 301 | | : result; |
| | | 302 | | } |
| | | 303 | | |
| | | 304 | | /// <summary> |
| | | 305 | | /// Reduces the color’s saturation by a specified fraction and optionally blends its brightness toward a mid-tone to |
| | | 306 | | /// maintain perceptual balance. |
| | | 307 | | /// </summary> |
| | | 308 | | /// <param name="desaturateBy"> |
| | | 309 | | /// The amount to decrease saturation, expressed as a normalized fraction in <c>[0,1]</c> (e.g., <c>0.6</c> = reduce |
| | | 310 | | /// 60%). Values outside this range are clamped automatically. |
| | | 311 | | /// </param> |
| | | 312 | | /// <param name="valueBlendTowardMid"> |
| | | 313 | | /// The blend factor toward a mid-tone brightness (V = 0.5), clamped to <c>[0, 1]</c>. Higher values yield a more ne |
| | | 314 | | /// evenly lit result. |
| | | 315 | | /// </param> |
| | | 316 | | /// <returns>A new <see cref="HexColor" /> instance representing the desaturated color.</returns> |
| | | 317 | | public HexColor Desaturate(double desaturateBy = 0.5, double valueBlendTowardMid = 0.15) |
| | 16125 | 318 | | => FromHsva( |
| | 16125 | 319 | | hue: H, |
| | 16125 | 320 | | saturation: Math.Clamp(value: S - Math.Clamp(value: desaturateBy, min: 0.0, max: 1.0), min: 0.0, max: 1.0), |
| | 16125 | 321 | | value: BlendValue(target: 0.5, factor: Math.Clamp(value: valueBlendTowardMid, min: 0.0, max: 1.0)), |
| | 16125 | 322 | | alpha: A.ToNormalized() |
| | 16125 | 323 | | ); |
| | | 324 | | |
| | | 325 | | /// <summary> |
| | | 326 | | /// Resolves a foreground color that meets (or best-approaches) a minimum contrast ratio over the background by pres |
| | | 327 | | /// the foreground hue and saturation (HSV H/S) and adjusting only theme (V). If that hue rail cannot reach the targ |
| | | 328 | | /// (even at V = 0 or 1), the method mixes toward black and white and returns the closest solution that meets—or |
| | | 329 | | /// best-approaches—the target. |
| | | 330 | | /// </summary> |
| | | 331 | | /// <param name="background">Background color (opaque).</param> |
| | | 332 | | /// <param name="minimumRatio">Required minimum contrast ratio (1–21, e.g., <c>4.5</c> for body text).</param> |
| | | 333 | | /// <exception cref="AryArgumentException">Thrown when the minimum ratio is less than 1 or greater than 21.</excepti |
| | | 334 | | /// <returns>The resolved color.</returns> |
| | | 335 | | public HexColor EnsureContrast(HexColor background, double minimumRatio = 3.0) |
| | | 336 | | { |
| | 178686 | 337 | | AryGuard.InRange(value: minimumRatio, min: 1.0, max: 21.0); |
| | | 338 | | |
| | 178686 | 339 | | var startRatio = ContrastRatio(background: background); |
| | | 340 | | |
| | 178686 | 341 | | if (startRatio >= minimumRatio) |
| | | 342 | | { |
| | 120498 | 343 | | return this; |
| | | 344 | | } |
| | | 345 | | |
| | 58188 | 346 | | var direction = ValueDirection(background: background); |
| | | 347 | | |
| | | 348 | | // 1) Preferred theme-rail attempt |
| | 58188 | 349 | | var first = SearchValueRail(direction: direction, background: background, minimumRatio: minimumRatio); |
| | | 350 | | |
| | 58188 | 351 | | if (first.IsMinimumMet) |
| | | 352 | | { |
| | 53393 | 353 | | return first.ForegroundColor; |
| | | 354 | | } |
| | | 355 | | |
| | | 356 | | // 2) Poles |
| | 4795 | 357 | | var towardWhite = SearchTowardPole( |
| | 4795 | 358 | | pole: Colors.White.SetAlpha(alpha: A.Value), background: background, minimumRatio: minimumRatio |
| | 4795 | 359 | | ); |
| | | 360 | | |
| | 4795 | 361 | | var towardBlack = SearchTowardPole( |
| | 4795 | 362 | | pole: Colors.Black.SetAlpha(alpha: A.Value), background: background, minimumRatio: minimumRatio |
| | 4795 | 363 | | ); |
| | | 364 | | |
| | 4795 | 365 | | if (towardWhite.IsMinimumMet) |
| | | 366 | | { |
| | 4244 | 367 | | return towardWhite.ForegroundColor; |
| | | 368 | | } |
| | | 369 | | |
| | 551 | 370 | | if (towardBlack.IsMinimumMet) |
| | | 371 | | { |
| | 550 | 372 | | return towardBlack.ForegroundColor; |
| | | 373 | | } |
| | | 374 | | |
| | | 375 | | // 3) Best-effort fallback (no one met the target): pick highest contrast among ALL candidates tried |
| | 1 | 376 | | var best = first; |
| | | 377 | | |
| | 1 | 378 | | if (towardWhite.ContrastRatio > best.ContrastRatio) |
| | | 379 | | { |
| | | 380 | | // Code Coverage: This is unreachable code. |
| | 0 | 381 | | best = towardWhite; |
| | | 382 | | } |
| | | 383 | | |
| | 1 | 384 | | if (towardBlack.ContrastRatio > best.ContrastRatio) |
| | | 385 | | { |
| | 1 | 386 | | best = towardBlack; |
| | | 387 | | } |
| | | 388 | | |
| | 1 | 389 | | return best.ForegroundColor; |
| | | 390 | | } |
| | | 391 | | |
| | | 392 | | /// <summary>Indicates whether the current color is equal to another <see cref="HexColor" />.</summary> |
| | | 393 | | /// <param name="other">The color to compare with.</param> |
| | | 394 | | /// <returns><see langword="true" /> if the colors are equal; otherwise, <see langword="false" />.</returns> |
| | | 395 | | public bool Equals(HexColor other) |
| | 2246 | 396 | | => R.Equals(other: other.R) && G.Equals(other: other.G) && B.Equals(other: other.B) && A.Equals(other: other.A); |
| | | 397 | | |
| | | 398 | | /// <summary>Determines whether the specified object is equal to the current instance.</summary> |
| | | 399 | | /// <param name="obj">The object to compare with.</param> |
| | | 400 | | /// <returns><see langword="true" /> if equal; otherwise, <see langword="false" />.</returns> |
| | 38 | 401 | | public override bool Equals(object? obj) => obj is HexColor other && Equals(other: other); |
| | | 402 | | |
| | | 403 | | /// <summary>Creates a <see cref="HexColor" /> from HSVA component values.</summary> |
| | | 404 | | /// <param name="hue">The hue component in degrees (nominally 0–360; values are normalized).</param> |
| | | 405 | | /// <param name="saturation">The saturation component in the 0–1 range.</param> |
| | | 406 | | /// <param name="value">The theme (brightness) component in the 0–1 range.</param> |
| | | 407 | | /// <param name="alpha">The alpha component in the 0–1 range. Defaults to 1.0 (opaque).</param> |
| | | 408 | | /// <returns>A <see cref="HexColor" /> representing the specified HSVA.</returns> |
| | | 409 | | public static HexColor FromHsva(double hue, double saturation, double value, double alpha = 1.0) |
| | | 410 | | { |
| | 1167222 | 411 | | var h = hue % 360.0; |
| | | 412 | | |
| | 1167222 | 413 | | if (h < 0) |
| | | 414 | | { |
| | 1 | 415 | | h += 360.0; |
| | | 416 | | } |
| | | 417 | | |
| | 1167222 | 418 | | var s = Math.Clamp(value: saturation, min: 0.0, max: 1.0); |
| | 1167222 | 419 | | var v = Math.Clamp(value: value, min: 0.0, max: 1.0); |
| | 1167222 | 420 | | var a = HexByte.FromNormalized(value: Math.Clamp(value: alpha, min: 0.0, max: 1.0)); |
| | | 421 | | |
| | 1167222 | 422 | | return HsvaToRgba(hue: h, saturation: s, value: v, alpha: a); |
| | | 423 | | } |
| | | 424 | | |
| | | 425 | | /// <summary>Returns a hash code for this instance.</summary> |
| | | 426 | | /// <returns>A hash code for the current object.</returns> |
| | 2 | 427 | | public override int GetHashCode() => HashCode.Combine(value1: R, value2: G, value3: B, value4: A); |
| | | 428 | | |
| | | 429 | | /// <summary>Converts HSVA values to an equivalent <see cref="HexColor" />.</summary> |
| | | 430 | | /// <param name="hue">Hue in degrees (can wrap), nominally 0–360.</param> |
| | | 431 | | /// <param name="saturation">Saturation in the 0–1 range.</param> |
| | | 432 | | /// <param name="value">Value (brightness) in the 0–1 range.</param> |
| | | 433 | | /// <param name="alpha">Alpha channel as a <see cref="HexByte" />.</param> |
| | | 434 | | /// <returns>A <see cref="HexColor" /> representing the HSVA input.</returns> |
| | | 435 | | private static HexColor HsvaToRgba(double hue, double saturation, double value, HexByte alpha) |
| | | 436 | | { |
| | 1167226 | 437 | | hue %= 360.0; |
| | | 438 | | |
| | 1167226 | 439 | | if (hue < 0) |
| | | 440 | | { |
| | 1 | 441 | | hue += 360.0; |
| | | 442 | | } |
| | | 443 | | |
| | 1167226 | 444 | | hue = (hue % 360.0 + 360.0) % 360.0; |
| | | 445 | | |
| | | 446 | | double red, green, blue; |
| | | 447 | | |
| | 1167226 | 448 | | var chroma = value * saturation; |
| | 1167226 | 449 | | var prime = hue / 60.0; |
| | 1167226 | 450 | | var x = chroma * (1.0 - Math.Abs(value: prime % 2.0 - 1.0)); |
| | 1167226 | 451 | | var m = value - chroma; |
| | | 452 | | |
| | | 453 | | switch (prime) |
| | | 454 | | { |
| | | 455 | | case < 1: |
| | 988576 | 456 | | red = chroma; |
| | 988576 | 457 | | green = x; |
| | 988576 | 458 | | blue = 0; |
| | | 459 | | |
| | 988576 | 460 | | break; |
| | | 461 | | case < 2: |
| | 20339 | 462 | | red = x; |
| | 20339 | 463 | | green = chroma; |
| | 20339 | 464 | | blue = 0; |
| | | 465 | | |
| | 20339 | 466 | | break; |
| | | 467 | | case < 3: |
| | 27659 | 468 | | red = 0; |
| | 27659 | 469 | | green = chroma; |
| | 27659 | 470 | | blue = x; |
| | | 471 | | |
| | 27659 | 472 | | break; |
| | | 473 | | case < 4: |
| | 89314 | 474 | | red = 0; |
| | 89314 | 475 | | green = x; |
| | 89314 | 476 | | blue = chroma; |
| | | 477 | | |
| | 89314 | 478 | | break; |
| | | 479 | | case < 5: |
| | 5901 | 480 | | red = x; |
| | 5901 | 481 | | green = 0; |
| | 5901 | 482 | | blue = chroma; |
| | | 483 | | |
| | 5901 | 484 | | break; |
| | | 485 | | default: |
| | 35437 | 486 | | red = chroma; |
| | 35437 | 487 | | green = 0; |
| | 35437 | 488 | | blue = x; |
| | | 489 | | |
| | | 490 | | break; |
| | | 491 | | } |
| | | 492 | | |
| | 1167226 | 493 | | return new HexColor( |
| | 1167226 | 494 | | red: ClampToByte(value: red + m), green: ClampToByte(value: green + m), blue: ClampToByte(value: blue + m), |
| | 1167226 | 495 | | alpha: alpha |
| | 1167226 | 496 | | ); |
| | | 497 | | } |
| | | 498 | | |
| | | 499 | | /// <summary> |
| | | 500 | | /// Produces the photographic negative of the current color by inverting each RGB channel (i.e., <c>R' = 255 − R</c> |
| | | 501 | | /// <c>G' = 255 − G</c>, <c>B' = 255 − B</c>). The alpha channel is preserved. |
| | | 502 | | /// </summary> |
| | | 503 | | /// <remarks> |
| | | 504 | | /// This matches the classic “negative image” operation performed in RGB space. The resulting hue/saturation/theme m |
| | | 505 | | /// equal a simple 180° hue rotation; it is the exact per-channel inversion of the underlying RGB values. |
| | | 506 | | /// </remarks> |
| | | 507 | | /// <returns>A new <see cref="HexColor" /> representing the RGB-inverted (negative) color.</returns> |
| | | 508 | | public HexColor Invert() |
| | | 509 | | { |
| | 1 | 510 | | var r = (byte)(255 - R.Value); |
| | 1 | 511 | | var g = (byte)(255 - G.Value); |
| | 1 | 512 | | var b = (byte)(255 - B.Value); |
| | | 513 | | |
| | 1 | 514 | | return new HexColor(red: r, green: g, blue: b, alpha: A); |
| | | 515 | | } |
| | | 516 | | |
| | | 517 | | /// <summary>Determines whether the color is perceptually dark based on its relative luminance.</summary> |
| | | 518 | | /// <returns> |
| | | 519 | | /// <see langword="true" /> if <see cref="ToRelativeLuminance()" /> is less than <c>0.5</c>; otherwise, |
| | | 520 | | /// <see langword="false" />. |
| | | 521 | | /// </returns> |
| | | 522 | | /// <remarks> |
| | | 523 | | /// This method provides a simple luminance-based classification that can be used for dynamic contrast adjustments, |
| | | 524 | | /// choosing a light foreground color for dark backgrounds. |
| | | 525 | | /// </remarks> |
| | 2 | 526 | | public bool IsDark() => ToRelativeLuminance() < 0.5; |
| | | 527 | | |
| | | 528 | | /// <summary>Determines whether the color is perceptually light based on its relative luminance.</summary> |
| | | 529 | | /// <returns> |
| | | 530 | | /// <see langword="true" /> if <see cref="ToRelativeLuminance()" /> is greater than or equal to <c>0.5</c>; otherwis |
| | | 531 | | /// <see langword="false" />. |
| | | 532 | | /// </returns> |
| | | 533 | | /// <remarks> |
| | | 534 | | /// Complements <see cref="IsDark()" />. Useful for automatically selecting dark text or icons when the background c |
| | | 535 | | /// light. |
| | | 536 | | /// </remarks> |
| | 2 | 537 | | public bool IsLight() => ToRelativeLuminance() >= 0.5; |
| | | 538 | | |
| | | 539 | | /// <summary>Determines whether the current color is fully opaque.</summary> |
| | | 540 | | /// <returns> |
| | | 541 | | /// <see langword="true" /> if the alpha channel (<see cref="A" />) equals <c>255</c>; otherwise, <see langword="fal |
| | | 542 | | /// </returns> |
| | | 543 | | /// <remarks> |
| | | 544 | | /// This check is useful for quickly identifying colors that require no alpha blending or compositing in rendering |
| | | 545 | | /// pipelines. |
| | | 546 | | /// </remarks> |
| | 1 | 547 | | public bool IsOpaque() => A.Value is 255; |
| | | 548 | | |
| | | 549 | | /// <summary>Determines whether the current color is fully transparent.</summary> |
| | | 550 | | /// <returns> |
| | | 551 | | /// <see langword="true" /> if the alpha channel (<see cref="A" />) equals <c>0</c>; otherwise, <see langword="false |
| | | 552 | | /// </returns> |
| | | 553 | | /// <remarks>A fully transparent color contributes no visible effect when composited over other surfaces.</remarks> |
| | 3139 | 554 | | public bool IsTransparent() => A.Value is 0; |
| | | 555 | | |
| | | 556 | | /// <summary>Parses a color string into a new <see cref="HexColor" />.</summary> |
| | | 557 | | /// <param name="value">The color string to parse.</param> |
| | | 558 | | /// <returns>A new <see cref="HexColor" />.</returns> |
| | 1 | 559 | | public static HexColor Parse(string value) => new(value: value); |
| | | 560 | | |
| | | 561 | | /// <summary> |
| | | 562 | | /// Parses a normalized alpha component from a string value in the range [0, 1] and converts it to a <see cref="HexB |
| | | 563 | | /// representation. |
| | | 564 | | /// </summary> |
| | | 565 | | /// <param name="value"> |
| | | 566 | | /// The string containing the alpha value to parse. Leading and trailing whitespace are ignored. The value must repr |
| | | 567 | | /// finite number within the inclusive range [0, 1]. |
| | | 568 | | /// </param> |
| | | 569 | | /// <returns>A <see cref="HexByte" /> corresponding to the parsed and clamped alpha value.</returns> |
| | | 570 | | /// <exception cref="AryArgumentException"> |
| | | 571 | | /// Thrown when <paramref name="value" /> cannot be parsed as a finite <see cref="double" /> or is outside the [0, 1 |
| | | 572 | | /// range. |
| | | 573 | | /// </exception> |
| | | 574 | | private static HexByte ParseAlpha(string value) |
| | | 575 | | { |
| | 3 | 576 | | var trimmed = value.Trim(); |
| | | 577 | | |
| | 3 | 578 | | if (!double.TryParse( |
| | 3 | 579 | | s: trimmed, style: NumberStyles.Float, provider: CultureInfo.InvariantCulture, result: out var alpha |
| | 3 | 580 | | ) || |
| | 3 | 581 | | !double.IsFinite(d: alpha)) |
| | | 582 | | { |
| | | 583 | | // Code Coverage: This is unreachable code. |
| | 0 | 584 | | throw new AryArgumentException(message: $"Invalid alpha value: {value}", argName: nameof(value)); |
| | | 585 | | } |
| | | 586 | | |
| | 3 | 587 | | AryGuard.InRange(value: alpha, min: 0.0, max: 1.0, argName: nameof(value)); |
| | | 588 | | |
| | 3 | 589 | | return HexByte.FromNormalized(value: alpha); |
| | | 590 | | } |
| | | 591 | | |
| | | 592 | | /// <summary> |
| | | 593 | | /// Parses an integer channel value from a string and returns it as a <see cref="HexByte" />. The value is expected |
| | | 594 | | /// represent an 8-bit channel in the range [0, 255]. |
| | | 595 | | /// </summary> |
| | | 596 | | /// <param name="value">The string containing the channel value to parse.</param> |
| | | 597 | | /// <returns>A <see cref="HexByte" /> corresponding to the parsed channel value.</returns> |
| | | 598 | | /// <exception cref="AryArgumentException"> |
| | | 599 | | /// Thrown when <paramref name="value" /> cannot be parsed as a byte or is outside the valid 0–255 range. |
| | | 600 | | /// </exception> |
| | | 601 | | private static HexByte ParseByte(string value) |
| | 9 | 602 | | => byte.TryParse( |
| | 9 | 603 | | s: value, style: NumberStyles.Integer, provider: CultureInfo.InvariantCulture, result: out var byteValue |
| | 9 | 604 | | ) |
| | 9 | 605 | | ? new HexByte(value: byteValue) |
| | 9 | 606 | | : throw new AryArgumentException(message: $"Byte theme is out of range: {value}", argName: nameof(value)); |
| | | 607 | | |
| | | 608 | | /// <summary> |
| | | 609 | | /// Parses a color channel from a string that may be expressed either as an absolute value or as a percentage. Perce |
| | | 610 | | /// values are normalized to the range [0, 1] before being converted to a <see cref="HexByte" />. |
| | | 611 | | /// </summary> |
| | | 612 | | /// <param name="value"> |
| | | 613 | | /// The channel string to parse. If it ends with <c>'%'</c>, it is interpreted as a percentage in the range [0, 100] |
| | | 614 | | /// otherwise, it is parsed as an integer channel value in the range [0, 255]. |
| | | 615 | | /// </param> |
| | | 616 | | /// <returns>A <see cref="HexByte" /> representing the parsed channel value.</returns> |
| | | 617 | | /// <exception cref="AryArgumentException"> |
| | | 618 | | /// Thrown when <paramref name="value" /> cannot be parsed as a valid channel value or percentage, or when the numer |
| | | 619 | | /// percentage is outside the 0–100 range. |
| | | 620 | | /// </exception> |
| | | 621 | | private static HexByte ParseChannel(string value) |
| | | 622 | | { |
| | 12 | 623 | | var trimmed = value.Trim(); |
| | | 624 | | |
| | 12 | 625 | | if (!trimmed.EndsWith(value: '%')) |
| | | 626 | | { |
| | 9 | 627 | | return ParseByte(value: trimmed); |
| | | 628 | | } |
| | | 629 | | |
| | 3 | 630 | | var text = trimmed.TrimEnd(trimChar: '%').Trim(); |
| | | 631 | | |
| | 3 | 632 | | if (!double.TryParse( |
| | 3 | 633 | | s: text, style: NumberStyles.Float, provider: CultureInfo.InvariantCulture, result: out var channel |
| | 3 | 634 | | ) || |
| | 3 | 635 | | !double.IsFinite(d: channel)) |
| | | 636 | | { |
| | | 637 | | // Code Coverage: This is unreachable code. |
| | 0 | 638 | | throw new AryArgumentException(message: $"Invalid channel percentage: {value}", argName: nameof(value)); |
| | | 639 | | } |
| | | 640 | | |
| | 3 | 641 | | AryGuard.InRange(value: channel, min: 0.0, max: 100.0, argName: nameof(value)); |
| | | 642 | | |
| | 3 | 643 | | return HexByte.FromNormalized(value: channel / 100.0); |
| | | 644 | | } |
| | | 645 | | |
| | | 646 | | /// <summary> |
| | | 647 | | /// Parses a hexadecimal color string and outputs channel components. Accepts <c>#RGB</c>, <c>#RGBA</c>, <c>#RRGGBB< |
| | | 648 | | /// and <c>#RRGGBBAA</c>. |
| | | 649 | | /// </summary> |
| | | 650 | | /// <param name="value">The hex color string.</param> |
| | | 651 | | /// <param name="red">The resulting red component.</param> |
| | | 652 | | /// <param name="green">The resulting green component.</param> |
| | | 653 | | /// <param name="blue">The resulting blue component.</param> |
| | | 654 | | /// <param name="alpha">The resulting alpha component.</param> |
| | | 655 | | /// <exception cref="AryArgumentException">Thrown when the format is invalid or contains non-hex digits.</exception> |
| | | 656 | | private static void ParseHex(string value, |
| | | 657 | | out HexByte red, |
| | | 658 | | out HexByte green, |
| | | 659 | | out HexByte blue, |
| | | 660 | | out HexByte alpha) |
| | | 661 | | { |
| | 13005 | 662 | | var match = HexColorPattern.Match(input: value.Trim()); |
| | | 663 | | |
| | 13005 | 664 | | if (!match.Success) |
| | | 665 | | { |
| | 4 | 666 | | throw new AryArgumentException(message: $"Invalid hex color format: {value}", argName: nameof(value)); |
| | | 667 | | } |
| | | 668 | | |
| | 13001 | 669 | | var hexValue = match.Groups[groupnum: 1].Value; |
| | | 670 | | |
| | 13001 | 671 | | switch (hexValue.Length) |
| | | 672 | | { |
| | | 673 | | case 3: |
| | 1 | 674 | | Span<char> buf3 = stackalloc char[8]; |
| | 1 | 675 | | buf3[index: 0] = hexValue[index: 0]; |
| | 1 | 676 | | buf3[index: 1] = hexValue[index: 0]; |
| | 1 | 677 | | buf3[index: 2] = hexValue[index: 1]; |
| | 1 | 678 | | buf3[index: 3] = hexValue[index: 1]; |
| | 1 | 679 | | buf3[index: 4] = hexValue[index: 2]; |
| | 1 | 680 | | buf3[index: 5] = hexValue[index: 2]; |
| | 1 | 681 | | buf3[index: 6] = 'F'; |
| | 1 | 682 | | buf3[index: 7] = 'F'; |
| | 1 | 683 | | hexValue = new string(value: buf3); |
| | | 684 | | |
| | 1 | 685 | | break; |
| | | 686 | | |
| | | 687 | | case 4: |
| | 1 | 688 | | Span<char> buf4 = stackalloc char[8]; |
| | 1 | 689 | | buf4[index: 0] = hexValue[index: 0]; |
| | 1 | 690 | | buf4[index: 1] = hexValue[index: 0]; |
| | 1 | 691 | | buf4[index: 2] = hexValue[index: 1]; |
| | 1 | 692 | | buf4[index: 3] = hexValue[index: 1]; |
| | 1 | 693 | | buf4[index: 4] = hexValue[index: 2]; |
| | 1 | 694 | | buf4[index: 5] = hexValue[index: 2]; |
| | 1 | 695 | | buf4[index: 6] = hexValue[index: 3]; |
| | 1 | 696 | | buf4[index: 7] = hexValue[index: 3]; |
| | 1 | 697 | | hexValue = new string(value: buf4); |
| | | 698 | | |
| | 1 | 699 | | break; |
| | | 700 | | |
| | | 701 | | case 6: |
| | 45 | 702 | | hexValue += "FF"; |
| | | 703 | | |
| | | 704 | | break; |
| | | 705 | | |
| | | 706 | | case 8: |
| | | 707 | | break; |
| | | 708 | | } |
| | | 709 | | |
| | 13001 | 710 | | var r = byte.Parse(s: hexValue[..2], style: NumberStyles.HexNumber, provider: CultureInfo.InvariantCulture); |
| | | 711 | | |
| | 13001 | 712 | | var g = byte.Parse( |
| | 13001 | 713 | | s: hexValue.AsSpan(start: 2, length: 2), style: NumberStyles.HexNumber, |
| | 13001 | 714 | | provider: CultureInfo.InvariantCulture |
| | 13001 | 715 | | ); |
| | | 716 | | |
| | 13001 | 717 | | var b = byte.Parse( |
| | 13001 | 718 | | s: hexValue.AsSpan(start: 4, length: 2), style: NumberStyles.HexNumber, |
| | 13001 | 719 | | provider: CultureInfo.InvariantCulture |
| | 13001 | 720 | | ); |
| | | 721 | | |
| | 13001 | 722 | | var a = byte.Parse( |
| | 13001 | 723 | | s: hexValue.AsSpan(start: 6, length: 2), style: NumberStyles.HexNumber, |
| | 13001 | 724 | | provider: CultureInfo.InvariantCulture |
| | 13001 | 725 | | ); |
| | | 726 | | |
| | 13001 | 727 | | red = new HexByte(value: r); |
| | 13001 | 728 | | green = new HexByte(value: g); |
| | 13001 | 729 | | blue = new HexByte(value: b); |
| | 13001 | 730 | | alpha = new HexByte(value: a); |
| | 13001 | 731 | | } |
| | | 732 | | |
| | | 733 | | /// <summary>Parses an HSVA string and outputs equivalent RGBA channel components.</summary> |
| | | 734 | | /// <param name="value">The HSVA string to parse.</param> |
| | | 735 | | /// <param name="red">The resulting red component.</param> |
| | | 736 | | /// <param name="green">The resulting green component.</param> |
| | | 737 | | /// <param name="blue">The resulting blue component.</param> |
| | | 738 | | /// <param name="alpha">The resulting alpha component.</param> |
| | | 739 | | /// <exception cref="AryArgumentException">Thrown when the string is invalid or values are out of range.</exception> |
| | | 740 | | private static void ParseHsva(string value, |
| | | 741 | | out HexByte red, |
| | | 742 | | out HexByte green, |
| | | 743 | | out HexByte blue, |
| | | 744 | | out HexByte alpha) |
| | | 745 | | { |
| | 7 | 746 | | var match = HsvaPattern.Match(input: value); |
| | | 747 | | |
| | 7 | 748 | | if (!match.Success) |
| | | 749 | | { |
| | 6 | 750 | | match = HsvPattern.Match(input: value); |
| | | 751 | | |
| | 6 | 752 | | if (!match.Success) |
| | | 753 | | { |
| | 2 | 754 | | throw new AryArgumentException(message: $"Invalid HSV(A) color: {value}", argName: nameof(value)); |
| | | 755 | | } |
| | | 756 | | } |
| | | 757 | | |
| | 5 | 758 | | var h = ParseHue(value: match.Groups[groupnum: 1].Value); |
| | 5 | 759 | | var s = ParsePercent(value: match.Groups[groupnum: 2].Value); |
| | 4 | 760 | | var v = ParsePercent(value: match.Groups[groupnum: 3].Value); |
| | | 761 | | |
| | 4 | 762 | | var a = match.Groups[groupname: "alpha"].Success |
| | 4 | 763 | | ? ParseAlpha(value: match.Groups[groupname: "alpha"].Value) |
| | 4 | 764 | | : new HexByte(value: 255); |
| | | 765 | | |
| | 4 | 766 | | var color = HsvaToRgba(hue: h, saturation: s, value: v, alpha: a); |
| | | 767 | | |
| | 4 | 768 | | red = color.R; |
| | 4 | 769 | | green = color.G; |
| | 4 | 770 | | blue = color.B; |
| | 4 | 771 | | alpha = color.A; |
| | 4 | 772 | | } |
| | | 773 | | |
| | | 774 | | /// <summary>Parses a hue theme from a string.</summary> |
| | | 775 | | /// <param name="value">The string containing the hue theme.</param> |
| | | 776 | | /// <returns>The parsed hue (not yet normalized).</returns> |
| | | 777 | | /// <exception cref="AryArgumentException">Thrown when the hue theme is invalid.</exception> |
| | | 778 | | private static double ParseHue(string value) |
| | 5 | 779 | | => !double.TryParse( |
| | 5 | 780 | | s: value, style: NumberStyles.Float, provider: CultureInfo.InvariantCulture, result: out var hue |
| | 5 | 781 | | ) || |
| | 5 | 782 | | !double.IsFinite(d: hue) |
| | 5 | 783 | | ? throw new AryArgumentException(message: $"Invalid hue theme: {value}", argName: nameof(value)) |
| | 5 | 784 | | : hue; |
| | | 785 | | |
| | | 786 | | /// <summary> |
| | | 787 | | /// Parses a percentage-like string into a fractional value in the range [0, 1]. Inputs may be given either as a raw |
| | | 788 | | /// fraction (e.g., <c>0.5</c>) or as a percentage (e.g., <c>50</c> or <c>50%</c>). |
| | | 789 | | /// </summary> |
| | | 790 | | /// <param name="value"> |
| | | 791 | | /// The string containing the percentage. If it ends with <c>'%'</c>, it is treated as a percentage between 0 and 10 |
| | | 792 | | /// otherwise values ≤ 1 are interpreted as fractions and larger values as percentages. |
| | | 793 | | /// </param> |
| | | 794 | | /// <returns>The parsed percentage expressed as a normalized fraction in the range [0, 1].</returns> |
| | | 795 | | /// <exception cref="AryArgumentException"> |
| | | 796 | | /// Thrown when <paramref name="value" /> cannot be parsed as a finite <see cref="double" /> or when the resulting |
| | | 797 | | /// percentage is outside the 0–100 range. |
| | | 798 | | /// </exception> |
| | | 799 | | private static double ParsePercent(string value) |
| | | 800 | | { |
| | 9 | 801 | | var trimmed = value.Trim(); |
| | 9 | 802 | | var hadPercent = trimmed.EndsWith(value: "%", comparisonType: StringComparison.Ordinal); |
| | | 803 | | |
| | 9 | 804 | | var numericText = hadPercent |
| | 9 | 805 | | ? trimmed.TrimEnd(trimChar: '%').Trim() |
| | 9 | 806 | | : trimmed; |
| | | 807 | | |
| | 9 | 808 | | if (!double.TryParse( |
| | 9 | 809 | | s: numericText, style: NumberStyles.Float, provider: CultureInfo.InvariantCulture, |
| | 9 | 810 | | result: out var number |
| | 9 | 811 | | ) || |
| | 9 | 812 | | !double.IsFinite(d: number)) |
| | | 813 | | { |
| | | 814 | | // Code Coverage: This is unreachable code. |
| | 0 | 815 | | throw new AryArgumentException(message: $"Invalid percentage value: {value}", argName: nameof(value)); |
| | | 816 | | } |
| | | 817 | | |
| | 9 | 818 | | var percent = hadPercent |
| | 9 | 819 | | ? number |
| | 9 | 820 | | : number <= 1.0 |
| | 9 | 821 | | ? number * 100.0 |
| | 9 | 822 | | : number; |
| | | 823 | | |
| | 9 | 824 | | AryGuard.InRange(value: percent, min: 0.0, max: 100.0, argName: nameof(value)); |
| | | 825 | | |
| | 8 | 826 | | return percent / 100.0; |
| | | 827 | | } |
| | | 828 | | |
| | | 829 | | /// <summary>Parses an RGBA functional string and outputs component channels.</summary> |
| | | 830 | | /// <param name="value">The RGBA string to parse.</param> |
| | | 831 | | /// <param name="red">The resulting red component.</param> |
| | | 832 | | /// <param name="green">The resulting green component.</param> |
| | | 833 | | /// <param name="blue">The resulting blue component.</param> |
| | | 834 | | /// <param name="alpha">The resulting alpha component.</param> |
| | | 835 | | /// <exception cref="AryArgumentException">Thrown when the input is invalid.</exception> |
| | | 836 | | private static void ParseRgba(string value, |
| | | 837 | | out HexByte red, |
| | | 838 | | out HexByte green, |
| | | 839 | | out HexByte blue, |
| | | 840 | | out HexByte alpha) |
| | | 841 | | { |
| | 5 | 842 | | var match = RgbaPattern.Match(input: value); |
| | | 843 | | |
| | 5 | 844 | | if (!match.Success) |
| | | 845 | | { |
| | 4 | 846 | | match = RgbPattern.Match(input: value); |
| | | 847 | | |
| | 4 | 848 | | if (!match.Success) |
| | | 849 | | { |
| | 2 | 850 | | match = RgbaCss4Pattern.Match(input: value); |
| | | 851 | | |
| | 2 | 852 | | if (!match.Success) |
| | | 853 | | { |
| | 1 | 854 | | throw new AryArgumentException(message: $"Invalid RGB(A) color: {value}", argName: nameof(value)); |
| | | 855 | | } |
| | | 856 | | } |
| | | 857 | | } |
| | | 858 | | |
| | 4 | 859 | | red = ParseChannel(value: match.Groups[groupnum: 1].Value); |
| | 4 | 860 | | green = ParseChannel(value: match.Groups[groupnum: 2].Value); |
| | 4 | 861 | | blue = ParseChannel(value: match.Groups[groupnum: 3].Value); |
| | | 862 | | |
| | 4 | 863 | | alpha = match.Groups[groupname: "alpha"].Success |
| | 4 | 864 | | ? match.Groups[groupname: "alpha"].Value.TrimEnd().EndsWith( |
| | 4 | 865 | | value: "%", comparisonType: StringComparison.Ordinal |
| | 4 | 866 | | ) |
| | 4 | 867 | | ? HexByte.FromNormalized( |
| | 4 | 868 | | value: Math.Clamp( |
| | 4 | 869 | | value: double.Parse( |
| | 4 | 870 | | s: match.Groups[groupname: "alpha"].Value.TrimEnd(trimChar: '%'), |
| | 4 | 871 | | style: NumberStyles.Float, provider: CultureInfo.InvariantCulture |
| | 4 | 872 | | ) / 100.0, min: 0.0, max: 1.0 |
| | 4 | 873 | | ) |
| | 4 | 874 | | ) |
| | 4 | 875 | | : ParseAlpha(value: match.Groups[groupname: "alpha"].Value) |
| | 4 | 876 | | : new HexByte(value: 255); |
| | 4 | 877 | | } |
| | | 878 | | |
| | | 879 | | /// <summary> |
| | | 880 | | /// Converts the current RGB color components (<see cref="R" />, <see cref="G" />, <see cref="B" />) into their |
| | | 881 | | /// corresponding HSV (Hue, Saturation, Value) representation. |
| | | 882 | | /// </summary> |
| | | 883 | | /// <param name="hue"> |
| | | 884 | | /// The resulting hue component, expressed in degrees within the range [0, 360). A theme of <c>0</c> represents red, |
| | | 885 | | /// <c>120</c> represents green, and <c>240</c> represents blue. |
| | | 886 | | /// </param> |
| | | 887 | | /// <param name="saturation"> |
| | | 888 | | /// The resulting saturation component, a normalized theme in the range [0, 1], where <c>0</c> indicates a shade of |
| | | 889 | | /// and <c>1</c> represents full color intensity. |
| | | 890 | | /// </param> |
| | | 891 | | /// <param name="value"> |
| | | 892 | | /// The resulting theme (brightness) component, a normalized theme in the range [0, 1], where <c>0</c> represents bl |
| | | 893 | | /// <c>1</c> represents the brightest form of the color. |
| | | 894 | | /// </param> |
| | | 895 | | /// <remarks> |
| | | 896 | | /// This method performs a precise RGB → HSV conversion without floating-point equality comparisons. The dominant co |
| | | 897 | | /// channel is determined from the original byte values to prevent loss of precision. When all RGB components are eq |
| | | 898 | | /// shade of gray), the hue is set to <c>0</c> by convention. |
| | | 899 | | /// </remarks> |
| | | 900 | | private void RgbToHsv(out double hue, out double saturation, out double value) |
| | | 901 | | { |
| | | 902 | | // Determine max/min from bytes for sector & delta, then scale once: |
| | 1386190 | 903 | | var red = R.Value; |
| | 1386190 | 904 | | var green = G.Value; |
| | 1386190 | 905 | | var blue = B.Value; |
| | | 906 | | |
| | 1386190 | 907 | | var maxByte = Math.Max(val1: red, val2: Math.Max(val1: green, val2: blue)); |
| | 1386190 | 908 | | var minByte = Math.Min(val1: red, val2: Math.Min(val1: green, val2: blue)); |
| | | 909 | | |
| | 1386190 | 910 | | var max = maxByte / 255.0; |
| | 1386190 | 911 | | var min = minByte / 255.0; |
| | 1386190 | 912 | | var delta = max - min; |
| | | 913 | | |
| | 1386190 | 914 | | var rN = red / 255.0; |
| | 1386190 | 915 | | var gN = green / 255.0; |
| | 1386190 | 916 | | var bN = blue / 255.0; |
| | | 917 | | |
| | | 918 | | // Value (V) |
| | 1386190 | 919 | | value = max; |
| | | 920 | | |
| | | 921 | | // Saturation (S) |
| | 1386190 | 922 | | saturation = max <= 0.0 |
| | 1386190 | 923 | | ? 0.0 |
| | 1386190 | 924 | | : delta / max; |
| | | 925 | | |
| | | 926 | | // Hue (H) |
| | 1386190 | 927 | | if (delta is 0.0) |
| | | 928 | | { |
| | | 929 | | // Gray — hue undefined; choose 0 by convention |
| | 1101153 | 930 | | hue = 0.0; |
| | | 931 | | |
| | 1101153 | 932 | | return; |
| | | 933 | | } |
| | | 934 | | |
| | | 935 | | // Pick sector by the *byte* that was the maximum (exact compare, stable on ties). |
| | | 936 | | // Tie-breaking falls back to the first true branch in this order: R, then G, then B. |
| | 285037 | 937 | | if (red >= green && red >= blue) |
| | | 938 | | { |
| | 120836 | 939 | | hue = 60.0 * ((gN - bN) / delta); |
| | | 940 | | } |
| | 164201 | 941 | | else if (green >= red && green >= blue) |
| | | 942 | | { |
| | 85721 | 943 | | hue = 60.0 * ((bN - rN) / delta + 2.0); |
| | | 944 | | } |
| | | 945 | | else |
| | | 946 | | { |
| | 78480 | 947 | | hue = 60.0 * ((rN - gN) / delta + 4.0); |
| | | 948 | | } |
| | | 949 | | |
| | | 950 | | // Normalize hue to [0, 360) |
| | 285037 | 951 | | if (hue < 0.0) |
| | | 952 | | { |
| | 33460 | 953 | | hue += 360.0; |
| | | 954 | | } |
| | 285037 | 955 | | } |
| | | 956 | | |
| | | 957 | | /// <summary> |
| | | 958 | | /// Binary-search mixing of a starting foreground toward a pole (black or white) in sRGB, returning the closest solu |
| | | 959 | | /// that meets—or best-approaches—the target contrast ratio. |
| | | 960 | | /// </summary> |
| | | 961 | | /// <param name="pole">Target pole (typically Black or White).</param> |
| | | 962 | | /// <param name="background">Background color (opaque).</param> |
| | | 963 | | /// <param name="minimumRatio">Target contrast ratio.</param> |
| | | 964 | | /// <returns>The resolution result for this pole.</returns> |
| | | 965 | | private ContrastResult SearchTowardPole(HexColor pole, HexColor background, double minimumRatio) |
| | | 966 | | { |
| | | 967 | | const int iterations = 18; |
| | | 968 | | const double eps = 1e-4; |
| | | 969 | | |
| | 9590 | 970 | | var bestRatio = -1.0; |
| | 9590 | 971 | | var bestColor = this; |
| | | 972 | | |
| | 9590 | 973 | | var met = false; |
| | 9590 | 974 | | var low = 0.0; |
| | 9590 | 975 | | var high = 1.0; |
| | | 976 | | |
| | 268520 | 977 | | for (var i = 0; i < iterations; i++) |
| | | 978 | | { |
| | 134260 | 979 | | var mid = Math.Clamp(value: 0.5 * (low + high), min: 0.0, max: 1.0); |
| | 134260 | 980 | | var candidate = ToLerpLinearPreserveAlpha(end: pole, factor: mid); |
| | 134260 | 981 | | var ratio = candidate.ContrastRatio(background: background); |
| | | 982 | | |
| | 134260 | 983 | | if (ratio > bestRatio) |
| | | 984 | | { |
| | 24573 | 985 | | bestRatio = ratio; |
| | 24573 | 986 | | bestColor = candidate; |
| | | 987 | | } |
| | | 988 | | |
| | 134260 | 989 | | if (ratio >= minimumRatio) |
| | | 990 | | { |
| | 41437 | 991 | | met = true; |
| | 41437 | 992 | | high = mid; |
| | | 993 | | } |
| | | 994 | | else |
| | | 995 | | { |
| | 92823 | 996 | | low = mid; |
| | | 997 | | } |
| | | 998 | | |
| | 134260 | 999 | | if (high - low < eps) |
| | | 1000 | | { |
| | | 1001 | | break; |
| | | 1002 | | } |
| | | 1003 | | } |
| | | 1004 | | |
| | 9590 | 1005 | | var finalColor = met |
| | 9590 | 1006 | | ? ToLerpLinearPreserveAlpha(end: pole, factor: high) |
| | 9590 | 1007 | | : bestColor; |
| | | 1008 | | |
| | 9590 | 1009 | | var finalRatio = finalColor.ContrastRatio(background: background); |
| | | 1010 | | |
| | 9590 | 1011 | | return new ContrastResult(ForegroundColor: finalColor, ContrastRatio: finalRatio, IsMinimumMet: met); |
| | | 1012 | | } |
| | | 1013 | | |
| | | 1014 | | /// <summary> |
| | | 1015 | | /// Binary search along the HSV theme rail (holding H and S constant) to find the minimum-change V that meets a requ |
| | | 1016 | | /// contrast ratio; returns the best-approaching candidate when unreachable. |
| | | 1017 | | /// </summary> |
| | | 1018 | | /// <param name="direction"><c>+1</c> to brighten; <c>-1</c> to darken.</param> |
| | | 1019 | | /// <param name="background">Background color.</param> |
| | | 1020 | | /// <param name="minimumRatio">Target contrast ratio.</param> |
| | | 1021 | | /// <returns>Resolution result for this search branch.</returns> |
| | | 1022 | | private ContrastResult SearchValueRail(int direction, HexColor background, double minimumRatio) |
| | | 1023 | | { |
| | | 1024 | | const int iterations = 18; |
| | | 1025 | | const double eps = 1e-4; |
| | | 1026 | | |
| | 58188 | 1027 | | var bestRatio = -1.0; |
| | 58188 | 1028 | | var bestColor = FromHsva(hue: H, saturation: S, value: V, alpha: A.ToNormalized()); |
| | | 1029 | | |
| | | 1030 | | double low, high; |
| | 58188 | 1031 | | double? found = null; |
| | | 1032 | | |
| | 58188 | 1033 | | if (direction > 0) |
| | | 1034 | | { |
| | 32658 | 1035 | | low = V; |
| | 32658 | 1036 | | high = 1.0; |
| | | 1037 | | } |
| | | 1038 | | else |
| | | 1039 | | { |
| | 25530 | 1040 | | low = 0.0; |
| | 25530 | 1041 | | high = V; |
| | | 1042 | | } |
| | | 1043 | | |
| | 1437000 | 1044 | | for (var i = 0; i < iterations; i++) |
| | | 1045 | | { |
| | 718500 | 1046 | | var mid = Math.Clamp(value: 0.5 * (low + high), min: 0.0, max: 1.0); |
| | 718500 | 1047 | | var candidate = FromHsva(hue: H, saturation: S, value: mid, alpha: A.ToNormalized()); |
| | 718500 | 1048 | | var ratio = candidate.ContrastRatio(background: background); |
| | | 1049 | | |
| | 718500 | 1050 | | if (ratio > bestRatio) |
| | | 1051 | | { |
| | 223403 | 1052 | | bestRatio = ratio; |
| | 223403 | 1053 | | bestColor = candidate; |
| | | 1054 | | } |
| | | 1055 | | |
| | 718500 | 1056 | | if (ratio >= minimumRatio) |
| | | 1057 | | { |
| | 509971 | 1058 | | found = mid; |
| | 509971 | 1059 | | high = mid; |
| | | 1060 | | } |
| | | 1061 | | else |
| | | 1062 | | { |
| | 208529 | 1063 | | low = mid; |
| | | 1064 | | } |
| | | 1065 | | |
| | 718500 | 1066 | | if (high - low < eps) |
| | | 1067 | | { |
| | | 1068 | | break; |
| | | 1069 | | } |
| | | 1070 | | } |
| | | 1071 | | |
| | 58188 | 1072 | | if (!found.HasValue) |
| | | 1073 | | { |
| | 4795 | 1074 | | return new ContrastResult(ForegroundColor: bestColor, ContrastRatio: bestRatio, IsMinimumMet: false); |
| | | 1075 | | } |
| | | 1076 | | |
| | 53393 | 1077 | | var finalColor = FromHsva(hue: H, saturation: S, value: high, alpha: A.ToNormalized()); |
| | 53393 | 1078 | | var finalRatio = finalColor.ContrastRatio(background: background); |
| | | 1079 | | |
| | 53393 | 1080 | | return new ContrastResult(ForegroundColor: finalColor, ContrastRatio: finalRatio, IsMinimumMet: true); |
| | | 1081 | | } |
| | | 1082 | | |
| | | 1083 | | /// <summary> |
| | | 1084 | | /// Creates a new <see cref="HexColor" /> instance using the current red, green, and blue components, but with the |
| | | 1085 | | /// specified alpha (opacity) theme. |
| | | 1086 | | /// </summary> |
| | | 1087 | | /// <param name="alpha"> |
| | | 1088 | | /// The new alpha component, expressed as a byte in the range <c>[0, 255]</c>, where <c>0</c> represents full transp |
| | | 1089 | | /// and <c>255</c> represents full opacity. |
| | | 1090 | | /// </param> |
| | | 1091 | | /// <returns> |
| | | 1092 | | /// A new <see cref="HexColor" /> that is identical in color to the current instance but with the provided alpha the |
| | | 1093 | | /// applied. |
| | | 1094 | | /// </returns> |
| | | 1095 | | /// <remarks> |
| | | 1096 | | /// This method does not modify the current instance; it returns a new color structure with the updated transparency |
| | | 1097 | | /// component. |
| | | 1098 | | /// </remarks> |
| | 66022 | 1099 | | public HexColor SetAlpha(byte alpha) => new(red: R, green: G, blue: B, alpha: new HexByte(value: alpha)); |
| | | 1100 | | |
| | | 1101 | | /// <summary> |
| | | 1102 | | /// Adjusts the perceived lightness of the color by shifting its theme (<see cref="V" />) upward or downward dependi |
| | | 1103 | | /// whether the color is currently light or dark. |
| | | 1104 | | /// </summary> |
| | | 1105 | | /// <param name="delta"> |
| | | 1106 | | /// The magnitude of change to apply to <see cref="V" />, expressed as a fraction in <c>[0,1]</c>. Positive values l |
| | | 1107 | | /// dark colors and darken light colors automatically. |
| | | 1108 | | /// </param> |
| | | 1109 | | /// <returns>A new <see cref="HexColor" /> instance with the adjusted brightness level.</returns> |
| | | 1110 | | public HexColor ShiftLightness(double delta = 0.05) |
| | | 1111 | | { |
| | 204626 | 1112 | | var direction = V >= 0.5 |
| | 204626 | 1113 | | ? -1.0 |
| | 204626 | 1114 | | : 1.0; |
| | | 1115 | | |
| | 204626 | 1116 | | return FromHsva( |
| | 204626 | 1117 | | hue: H, saturation: S, value: Math.Clamp(value: V + delta * direction, min: 0.0, max: 1.0), |
| | 204626 | 1118 | | alpha: A.ToNormalized() |
| | 204626 | 1119 | | ); |
| | | 1120 | | } |
| | | 1121 | | |
| | | 1122 | | /// <summary> |
| | | 1123 | | /// Converts the current color into an accent variant suitable for emphasizing interactive or highlighted UI element |
| | | 1124 | | /// Internally adjusts perceived lightness using <see cref="ShiftLightness(double)" /> with a stronger delta. |
| | | 1125 | | /// </summary> |
| | | 1126 | | /// <returns>A new <see cref="HexColor" /> representing the accent variant of the current color.</returns> |
| | 112849 | 1127 | | public HexColor ToAccent() => ShiftLightness(delta: 0.6); |
| | | 1128 | | |
| | | 1129 | | /// <summary> |
| | | 1130 | | /// Produces a disabled-state variant of the current color by reducing saturation and slightly flattening perceived |
| | | 1131 | | /// contrast. Intended for controls that are present but not interactive. |
| | | 1132 | | /// </summary> |
| | | 1133 | | /// <returns>A new <see cref="HexColor" /> representing the disabled-state color.</returns> |
| | 8061 | 1134 | | public HexColor ToDisabled() => Desaturate(desaturateBy: 0.6); |
| | | 1135 | | |
| | | 1136 | | /// <summary> |
| | | 1137 | | /// Produces a dragged-state variant of the current color by adjusting lightness for improved visual feedback while |
| | | 1138 | | /// draggable element is being moved. |
| | | 1139 | | /// </summary> |
| | | 1140 | | /// <returns>A new <see cref="HexColor" /> representing the dragged-state color.</returns> |
| | 8061 | 1141 | | public HexColor ToDragged() => ShiftLightness(delta: 0.18); |
| | | 1142 | | |
| | | 1143 | | /// <summary> |
| | | 1144 | | /// Produces a slightly elevated variant of the current color corresponding to the first elevation level in layered |
| | | 1145 | | /// surfaces. |
| | | 1146 | | /// </summary> |
| | | 1147 | | /// <returns>A new <see cref="HexColor" /> representing the elevation 1 color.</returns> |
| | 619 | 1148 | | public HexColor ToElevation1() => ShiftLightness(delta: 0.02); |
| | | 1149 | | |
| | | 1150 | | /// <summary> |
| | | 1151 | | /// Produces an elevated variant of the current color corresponding to the second elevation level in layered UI surf |
| | | 1152 | | /// </summary> |
| | | 1153 | | /// <returns>A new <see cref="HexColor" /> representing the elevation 2 color.</returns> |
| | 619 | 1154 | | public HexColor ToElevation2() => ShiftLightness(delta: 0.04); |
| | | 1155 | | |
| | | 1156 | | /// <summary> |
| | | 1157 | | /// Produces an elevated variant of the current color corresponding to the third elevation level in layered UI surfa |
| | | 1158 | | /// </summary> |
| | | 1159 | | /// <returns>A new <see cref="HexColor" /> representing the elevation 3 color.</returns> |
| | 619 | 1160 | | public HexColor ToElevation3() => ShiftLightness(delta: 0.06); |
| | | 1161 | | |
| | | 1162 | | /// <summary> |
| | | 1163 | | /// Produces an elevated variant of the current color corresponding to the fourth elevation level in layered UI surf |
| | | 1164 | | /// </summary> |
| | | 1165 | | /// <returns>A new <see cref="HexColor" /> representing the elevation 4 color.</returns> |
| | 619 | 1166 | | public HexColor ToElevation4() => ShiftLightness(delta: 0.08); |
| | | 1167 | | |
| | | 1168 | | /// <summary> |
| | | 1169 | | /// Produces an elevated variant of the current color corresponding to the fifth elevation level in layered UI surfa |
| | | 1170 | | /// </summary> |
| | | 1171 | | /// <returns>A new <see cref="HexColor" /> representing the elevation 5 color.</returns> |
| | 619 | 1172 | | public HexColor ToElevation5() => ShiftLightness(delta: 0.1); |
| | | 1173 | | |
| | | 1174 | | /// <summary> |
| | | 1175 | | /// Produces a focused-state variant of the current color, typically used to render focus rings or outlines with enh |
| | | 1176 | | /// prominence relative to the base color. |
| | | 1177 | | /// </summary> |
| | | 1178 | | /// <returns>A new <see cref="HexColor" /> representing the focused-state color.</returns> |
| | 8061 | 1179 | | public HexColor ToFocused() => ShiftLightness(delta: 0.1); |
| | | 1180 | | |
| | | 1181 | | /// <summary> |
| | | 1182 | | /// Derives a high-contrast foreground variant from the current color by strongly adjusting lightness. Intended for |
| | | 1183 | | /// iconography rendered on top of the base color. |
| | | 1184 | | /// </summary> |
| | | 1185 | | /// <returns>A new <see cref="HexColor" /> suitable for use as a foreground color.</returns> |
| | 56425 | 1186 | | public HexColor ToForeground() => ShiftLightness(delta: 0.9); |
| | | 1187 | | |
| | | 1188 | | /// <summary> |
| | | 1189 | | /// Produces a hover-state variant of the current color by slightly adjusting lightness to provide visual feedback w |
| | | 1190 | | /// pointer hovers over an interactive element. |
| | | 1191 | | /// </summary> |
| | | 1192 | | /// <returns>A new <see cref="HexColor" /> representing the hovered-state color.</returns> |
| | 8061 | 1193 | | public HexColor ToHovered() => ShiftLightness(delta: 0.06); |
| | | 1194 | | |
| | | 1195 | | /// <summary> |
| | | 1196 | | /// Gamma-correct (linear-light) interpolation between two colors, including alpha. Uses linearization via |
| | | 1197 | | /// <see cref="HexByte.ToLerpLinearHexByte" /> for perceptually better blends than plain sRGB lerp. |
| | | 1198 | | /// </summary> |
| | | 1199 | | /// <param name="end">InlineEnd color.</param> |
| | | 1200 | | /// <param name="factor">Interpolation factor clamped to <c>[0, 1]</c>.</param> |
| | | 1201 | | /// <returns>The interpolated color.</returns> |
| | | 1202 | | public HexColor ToLerpLinear(HexColor end, double factor) |
| | 1 | 1203 | | => new( |
| | 1 | 1204 | | red: R.ToLerpLinearHexByte(end: end.R, factor: factor), |
| | 1 | 1205 | | green: G.ToLerpLinearHexByte(end: end.G, factor: factor), |
| | 1 | 1206 | | blue: B.ToLerpLinearHexByte(end: end.B, factor: factor), |
| | 1 | 1207 | | alpha: A.ToLerpHexByte(end: end.A, factor: factor) |
| | 1 | 1208 | | ); |
| | | 1209 | | |
| | | 1210 | | /// <summary> |
| | | 1211 | | /// Gamma-correct (linear-light) interpolation between two colors, preserving alpha. Uses linearization via |
| | | 1212 | | /// <see cref="HexByte.ToLerpLinearHexByte" /> for perceptually better blends than plain sRGB lerp. |
| | | 1213 | | /// </summary> |
| | | 1214 | | /// <param name="end">InlineEnd color.</param> |
| | | 1215 | | /// <param name="factor">Interpolation factor clamped to <c>[0, 1]</c>.</param> |
| | | 1216 | | /// <returns>The interpolated color.</returns> |
| | | 1217 | | public HexColor ToLerpLinearPreserveAlpha(HexColor end, double factor) |
| | 139919 | 1218 | | => new( |
| | 139919 | 1219 | | red: R.ToLerpLinearHexByte(end: end.R, factor: factor), |
| | 139919 | 1220 | | green: G.ToLerpLinearHexByte(end: end.G, factor: factor), |
| | 139919 | 1221 | | blue: B.ToLerpLinearHexByte(end: end.B, factor: factor), |
| | 139919 | 1222 | | alpha: A |
| | 139919 | 1223 | | ); |
| | | 1224 | | |
| | | 1225 | | /// <summary> |
| | | 1226 | | /// Produces a pressed-state variant of the current color by adjusting lightness to convey a deeper interaction stat |
| | | 1227 | | /// a control is actively pressed or engaged. |
| | | 1228 | | /// </summary> |
| | | 1229 | | /// <returns>A new <see cref="HexColor" /> representing the pressed-state color.</returns> |
| | 8061 | 1230 | | public HexColor ToPressed() => ShiftLightness(delta: 0.14); |
| | | 1231 | | |
| | | 1232 | | /// <summary>Computes WCAG relative luminance from an opaque sRGB color.</summary> |
| | | 1233 | | /// <returns>Relative luminance in <c>[0, 1]</c>.</returns> |
| | | 1234 | | /// <remarks> |
| | | 1235 | | /// Relative luminance is computed using the linearized sRGB formula: <c>0.2126 × R + 0.7152 × G + 0.0722 × B</c>. |
| | | 1236 | | /// </remarks> |
| | | 1237 | | public double ToRelativeLuminance() |
| | | 1238 | | { |
| | 2421634 | 1239 | | var redLuminance = R.ToSrgbLinearValue(); |
| | 2421634 | 1240 | | var greenLuminance = G.ToSrgbLinearValue(); |
| | 2421634 | 1241 | | var blueLuminance = B.ToSrgbLinearValue(); |
| | | 1242 | | |
| | 2421634 | 1243 | | return 0.2126 * redLuminance + 0.7152 * greenLuminance + 0.0722 * blueLuminance; |
| | | 1244 | | } |
| | | 1245 | | |
| | | 1246 | | /// <summary>Returns a hexadecimal string representation of the color in the form <c>#RRGGBBAA</c>.</summary> |
| | | 1247 | | /// <returns>The string representation.</returns> |
| | 30622 | 1248 | | public override string ToString() => $"#{R}{G}{B}{A}"; |
| | | 1249 | | |
| | | 1250 | | /// <summary> |
| | | 1251 | | /// Produces a visited-state variant of the current color by modestly reducing saturation while preserving general h |
| | | 1252 | | /// brightness, making it suitable for indicating visited links or previously activated actions. |
| | | 1253 | | /// </summary> |
| | | 1254 | | /// <returns>A new <see cref="HexColor" /> representing the visited-state color.</returns> |
| | 8061 | 1255 | | public HexColor ToVisited() => Desaturate(desaturateBy: 0.3); |
| | | 1256 | | |
| | | 1257 | | /// <summary>Attempts to parse the specified color string.</summary> |
| | | 1258 | | /// <param name="value">The color string to parse.</param> |
| | | 1259 | | /// <param name="result">When this method returns, contains the parsed color or default on failure.</param> |
| | | 1260 | | /// <returns><see langword="true" /> if parsing succeeded; otherwise, <see langword="false" />.</returns> |
| | | 1261 | | public static bool TryParse(string? value, out HexColor result) |
| | | 1262 | | { |
| | 3 | 1263 | | if (string.IsNullOrWhiteSpace(value: value)) |
| | | 1264 | | { |
| | 1 | 1265 | | result = default(HexColor); |
| | | 1266 | | |
| | 1 | 1267 | | return false; |
| | | 1268 | | } |
| | | 1269 | | |
| | | 1270 | | try |
| | | 1271 | | { |
| | 2 | 1272 | | result = new HexColor(value: value); |
| | | 1273 | | |
| | 1 | 1274 | | return true; |
| | | 1275 | | } |
| | 1 | 1276 | | catch |
| | | 1277 | | { |
| | 1 | 1278 | | result = default(HexColor); |
| | | 1279 | | |
| | 1 | 1280 | | return false; |
| | | 1281 | | } |
| | 2 | 1282 | | } |
| | | 1283 | | |
| | | 1284 | | /// <summary> |
| | | 1285 | | /// Attempts to resolve a named color (e.g., <c>"Red500"</c>) from the global color registry and returns its RGBA ch |
| | | 1286 | | /// components. |
| | | 1287 | | /// </summary> |
| | | 1288 | | /// <param name="value"> |
| | | 1289 | | /// The color name to look up. Comparison is case-insensitive (per the dictionary’s comparer). Leading/trailing whit |
| | | 1290 | | /// is ignored. |
| | | 1291 | | /// </param> |
| | | 1292 | | /// <param name="red">On success, receives the resolved red channel; otherwise <c>0</c>.</param> |
| | | 1293 | | /// <param name="green">On success, receives the resolved green channel; otherwise <c>0</c>.</param> |
| | | 1294 | | /// <param name="blue">On success, receives the resolved blue channel; otherwise <c>0</c>.</param> |
| | | 1295 | | /// <param name="alpha">On success, receives the resolved alpha channel; otherwise <c>0</c> (transparent).</param> |
| | | 1296 | | /// <returns><see langword="true" /> if the name maps to a known color; otherwise <see langword="false" />.</returns |
| | | 1297 | | /// <remarks> |
| | | 1298 | | /// On failure, all channel outputs are set to <c>0</c>. The lookup uses whatever <see cref="StringComparer" /> the |
| | | 1299 | | /// <c>Colors</c> dictionary was constructed with (e.g., <c>InvariantCultureIgnoreCase</c> or <c>OrdinalIgnoreCase</ |
| | | 1300 | | /// </remarks> |
| | | 1301 | | private static bool TryParseColorName(string value, |
| | | 1302 | | out HexByte red, |
| | | 1303 | | out HexByte green, |
| | | 1304 | | out HexByte blue, |
| | | 1305 | | out HexByte alpha) |
| | | 1306 | | { |
| | 9 | 1307 | | red = new HexByte(); |
| | 9 | 1308 | | green = new HexByte(); |
| | 9 | 1309 | | blue = new HexByte(); |
| | 9 | 1310 | | alpha = new HexByte(); |
| | | 1311 | | |
| | 9 | 1312 | | if (!Colors.TryGet(name: value, value: out var color)) |
| | | 1313 | | { |
| | 5 | 1314 | | return false; |
| | | 1315 | | } |
| | | 1316 | | |
| | 4 | 1317 | | red = color.R; |
| | 4 | 1318 | | green = color.G; |
| | 4 | 1319 | | blue = color.B; |
| | 4 | 1320 | | alpha = color.A; |
| | | 1321 | | |
| | 4 | 1322 | | return true; |
| | | 1323 | | } |
| | | 1324 | | |
| | | 1325 | | /// <summary> |
| | | 1326 | | /// Chooses the initial direction to adjust HSV Value (V) for the foreground in order to locally increase contrast a |
| | | 1327 | | /// the background. Returns <c>+1</c> to brighten or <c>-1</c> to darken. |
| | | 1328 | | /// </summary> |
| | | 1329 | | /// <param name="background">Background color (opaque).</param> |
| | | 1330 | | /// <returns><c>+1</c> if brightening increases contrast more; otherwise <c>-1</c>.</returns> |
| | | 1331 | | private int ValueDirection(HexColor background) |
| | | 1332 | | { |
| | | 1333 | | const double step = 0.02; // 2% of the 0..1 range |
| | | 1334 | | |
| | 58188 | 1335 | | var up = FromHsva( |
| | 58188 | 1336 | | hue: H, saturation: S, value: Math.Clamp(value: V + step, min: 0.0, max: 1.0), alpha: A.ToNormalized() |
| | 58188 | 1337 | | ); |
| | | 1338 | | |
| | 58188 | 1339 | | var down = FromHsva( |
| | 58188 | 1340 | | hue: H, saturation: S, value: Math.Clamp(value: V - step, min: 0.0, max: 1.0), alpha: A.ToNormalized() |
| | 58188 | 1341 | | ); |
| | | 1342 | | |
| | 58188 | 1343 | | var ratioUp = up.ContrastRatio(background: background); |
| | 58188 | 1344 | | var ratioDown = down.ContrastRatio(background: background); |
| | | 1345 | | |
| | 58188 | 1346 | | return ratioUp > ratioDown |
| | 58188 | 1347 | | ? +1 |
| | 58188 | 1348 | | : -1; |
| | | 1349 | | } |
| | | 1350 | | |
| | | 1351 | | /// <summary>Determines whether two <see cref="HexColor" /> values are equal.</summary> |
| | | 1352 | | /// <param name="left">The first theme to compare.</param> |
| | | 1353 | | /// <param name="right">The second theme to compare.</param> |
| | | 1354 | | /// <returns><see langword="true" /> if the values are equal; otherwise, <see langword="false" />.</returns> |
| | 1 | 1355 | | public static bool operator ==(HexColor left, HexColor right) => left.Equals(other: right); |
| | | 1356 | | |
| | | 1357 | | /// <summary>Determines whether one <see cref="HexColor" /> is greater than another.</summary> |
| | | 1358 | | /// <param name="left">The left operand.</param> |
| | | 1359 | | /// <param name="right">The right operand.</param> |
| | | 1360 | | /// <returns><see langword="true" /> if <paramref name="left" /> is greater; otherwise, <see langword="false" />.</r |
| | 1 | 1361 | | public static bool operator >(HexColor left, HexColor right) => left.CompareTo(other: right) > 0; |
| | | 1362 | | |
| | | 1363 | | /// <summary>Determines whether one <see cref="HexColor" /> is greater than or equal to another.</summary> |
| | | 1364 | | /// <param name="left">The left operand.</param> |
| | | 1365 | | /// <param name="right">The right operand.</param> |
| | | 1366 | | /// <returns> |
| | | 1367 | | /// <see langword="true" /> if <paramref name="left" /> is greater than or equal to <paramref name="right" />; other |
| | | 1368 | | /// <see langword="false" />. |
| | | 1369 | | /// </returns> |
| | 1 | 1370 | | public static bool operator >=(HexColor left, HexColor right) => left.CompareTo(other: right) >= 0; |
| | | 1371 | | |
| | | 1372 | | /// <summary>Implicitly converts a color string to a <see cref="HexColor" />.</summary> |
| | | 1373 | | /// <param name="hex">The color string to convert.</param> |
| | | 1374 | | /// <returns>The resulting <see cref="HexColor" />.</returns> |
| | 31 | 1375 | | public static implicit operator HexColor(string hex) => new(value: hex); |
| | | 1376 | | |
| | | 1377 | | /// <summary>Implicitly converts a <see cref="HexColor" /> to its string representation.</summary> |
| | | 1378 | | /// <param name="value">The color theme.</param> |
| | | 1379 | | /// <returns>The string representation.</returns> |
| | 12552 | 1380 | | public static implicit operator string(HexColor value) => value.ToString(); |
| | | 1381 | | |
| | | 1382 | | /// <summary>Determines whether two <see cref="HexColor" /> values are not equal.</summary> |
| | | 1383 | | /// <param name="left">The first theme to compare.</param> |
| | | 1384 | | /// <param name="right">The second theme to compare.</param> |
| | | 1385 | | /// <returns><see langword="true" /> if the values are not equal; otherwise, <see langword="false" />.</returns> |
| | 1 | 1386 | | public static bool operator !=(HexColor left, HexColor right) => !left.Equals(other: right); |
| | | 1387 | | |
| | | 1388 | | /// <summary>Determines whether one <see cref="HexColor" /> is less than another.</summary> |
| | | 1389 | | /// <param name="left">The left operand.</param> |
| | | 1390 | | /// <param name="right">The right operand.</param> |
| | | 1391 | | /// <returns> |
| | | 1392 | | /// <see langword="true" /> if <paramref name="left" /> is less than <paramref name="right" />; otherwise, |
| | | 1393 | | /// <see langword="false" />. |
| | | 1394 | | /// </returns> |
| | 1 | 1395 | | public static bool operator <(HexColor left, HexColor right) => left.CompareTo(other: right) < 0; |
| | | 1396 | | |
| | | 1397 | | /// <summary>Determines whether one <see cref="HexColor" /> is less than or equal to another.</summary> |
| | | 1398 | | /// <param name="left">The left operand.</param> |
| | | 1399 | | /// <param name="right">The right operand.</param> |
| | | 1400 | | /// <returns> |
| | | 1401 | | /// <see langword="true" /> if <paramref name="left" /> is less than or equal to <paramref name="right" />; otherwis |
| | | 1402 | | /// <see langword="false" />. |
| | | 1403 | | /// </returns> |
| | 1 | 1404 | | public static bool operator <=(HexColor left, HexColor right) => left.CompareTo(other: right) <= 0; |
| | | 1405 | | } |