< Summary

Information
Class: Allyaria.Theming.Helpers.ColorHelper
Assembly: Allyaria.Theming
File(s): /home/runner/work/allyaria/allyaria/src/Allyaria.Theming/Helpers/ColorHelper.cs
Line coverage
100%
Covered lines: 101
Uncovered lines: 0
Coverable lines: 101
Total lines: 316
Line coverage: 100%
Branch coverage
100%
Covered branches: 38
Total branches: 38
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
Blend(...)100%11100%
ChooseValueDirection(...)100%66100%
ContrastRatio(...)100%11100%
EnsureMinimumContrast(...)100%1212100%
LerpSrgb(...)100%11100%
Lerp()100%11100%
MixSrgb(...)100%11100%
RelativeLuminance(...)100%11100%
SearchTowardPole(...)100%88100%
SearchValueRail(...)100%1010100%
SrgbToLinear(...)100%22100%

File(s)

/home/runner/work/allyaria/allyaria/src/Allyaria.Theming/Helpers/ColorHelper.cs

#LineLine coverage
 1using Allyaria.Theming.Constants;
 2using Allyaria.Theming.Primitives;
 3using Allyaria.Theming.Values;
 4
 5namespace Allyaria.Theming.Helpers;
 6
 7/// <summary>
 8/// Color utilities: WCAG contrast computation, hue-preserving contrast repair for opaque colors, and small reusable
 9/// helpers used by palette derivations (e.g., HSVA clamped creation, scalar blending). No allocations (static helper), 
 10/// alpha/canvas blending.
 11/// </summary>
 12internal static class ColorHelper
 13{
 14    /// <summary>Linearly blends a scalar value toward a target by a factor in [0..1].</summary>
 15    /// <param name="start">Starting value.</param>
 16    /// <param name="target">Target value.</param>
 17    /// <param name="t">
 18    /// Blend factor in [0..1]. <c>0</c> returns <paramref name="start" />, <c>1</c> returns <paramref name="target" />.
 19    /// </param>
 20    /// <returns>The blended scalar.</returns>
 21    public static double Blend(double start, double target, double t)
 22    {
 3023        t = Math.Clamp(t, 0.0, 1.0);
 24
 3025        return start + (target - start) * t;
 26    }
 27
 28    /// <summary>
 29    /// Chooses the initial direction to adjust V (HSV Value) to locally increase contrast (+1 brighten, -1 darken).
 30    /// </summary>
 31    /// <param name="foreground">ForegroundColor (opaque).</param>
 32    /// <param name="background">BackgroundColor (opaque).</param>
 33    /// <returns>+1 if brightening increases contrast more; otherwise -1.</returns>
 34    private static int ChooseValueDirection(AllyariaColorValue foreground, AllyariaColorValue background)
 35    {
 36        const double step = 2.0; // percent V
 37
 12038        double h = foreground.H, s = foreground.S, v = foreground.V;
 39
 4040        var up = AllyariaColorValue.FromHsva(h, s, Math.Clamp(v + step, 0.0, 100.0));
 4041        var dn = AllyariaColorValue.FromHsva(h, s, Math.Clamp(v - step, 0.0, 100.0));
 42
 4043        var rUp = ContrastRatio(up, background);
 4044        var rDn = ContrastRatio(dn, background);
 45
 4046        if (Math.Abs(rUp - rDn) < 1e-6)
 47        {
 48            // Tie-break: push away from mid to reach an extreme sooner
 449            return v >= 50.0
 450                ? -1
 451                : +1;
 52        }
 53
 3654        return rUp > rDn
 3655            ? +1
 3656            : -1;
 57    }
 58
 59    /// <summary>Computes WCAG contrast ratio between two opaque sRGB colors.</summary>
 60    /// <param name="foreground">ForegroundColor color (opaque).</param>
 61    /// <param name="background">BackgroundColor color (opaque).</param>
 62    /// <returns>(L1 + 0.05) / (L2 + 0.05) with WCAG relative luminance.</returns>
 63    public static double ContrastRatio(AllyariaColorValue foreground, AllyariaColorValue background)
 64    {
 168065        var lf = RelativeLuminance(foreground);
 168066        var lb = RelativeLuminance(background);
 67
 168068        var lighter = Math.Max(lf, lb);
 168069        var darker = Math.Min(lf, lb);
 70
 168071        return (lighter + 0.05) / (darker + 0.05);
 72    }
 73
 74    /// <summary>
 75    /// Resolves a foreground color that meets a minimum contrast over the background by preserving the foreground hue a
 76    /// saturation (HSV H/S) and adjusting only value (V). If that hue rail cannot reach the target (even at V=0% or V=1
 77    /// mixes toward black and white and returns the closest solution that meets (or best-approaches) the target.
 78    /// </summary>
 79    /// <param name="foreground">Starting foreground (opaque).</param>
 80    /// <param name="background">BackgroundColor (opaque).</param>
 81    /// <param name="minimumRatio">Required minimum ratio (e.g., 4.5 for body text).</param>
 82    /// <returns><see cref="ContrastResult" /> with final color and achieved ratio.</returns>
 83    public static ContrastResult EnsureMinimumContrast(AllyariaColorValue foreground,
 84        AllyariaColorValue background,
 85        double minimumRatio = 3.0)
 86    {
 87        // Early accept
 24088        var startRatio = ContrastRatio(foreground, background);
 89
 24090        if (startRatio >= minimumRatio)
 91        {
 20092            return new ContrastResult(foreground, background, startRatio, true);
 93        }
 94
 95        // Reuse AllyariaColorValue’s HSV API to avoid duplicating conversions.
 4096        var h = foreground.H; // degrees
 4097        var s = foreground.S; // percent
 4098        var v = foreground.V; // percent
 99
 100        // Choose the V direction that locally increases contrast.
 40101        var initialDir = ChooseValueDirection(foreground, background);
 102
 103        // 1) Try along the hue rail in the better direction first.
 40104        var first = SearchValueRail(h, s, v, initialDir, background, minimumRatio);
 105
 40106        if (first.MeetsMinimum)
 107        {
 22108            return first;
 109        }
 110
 111        // 2) Try the opposite direction on the hue rail.
 18112        var second = SearchValueRail(h, s, v, -initialDir, background, minimumRatio);
 113
 18114        if (second.MeetsMinimum)
 115        {
 6116            return second;
 117        }
 118
 119        // 3) Guarantee path: mix toward white; prefer any that meets; otherwise best-approaching.
 12120        var towardPole = SearchTowardPole(foreground, Colors.White, background, minimumRatio);
 121
 12122        if (towardPole.MeetsMinimum)
 123        {
 2124            return towardPole;
 125        }
 126
 127        // 4) Still not met: return the best-approaching overall.
 10128        var best = first;
 129
 10130        if (second.ContrastRatio > best.ContrastRatio)
 131        {
 2132            best = second;
 133        }
 134
 10135        if (towardPole.ContrastRatio > best.ContrastRatio)
 136        {
 4137            best = towardPole;
 138        }
 139
 10140        return best;
 141    }
 142
 143    /// <summary>Linear interpolation in sRGB between two opaque colors.</summary>
 144    /// <param name="start">Start color.</param>
 145    /// <param name="end">End color.</param>
 146    /// <param name="t">Mix factor in [0,1].</param>
 147    /// <returns>Interpolated color.</returns>
 148    private static AllyariaColorValue LerpSrgb(AllyariaColorValue start, AllyariaColorValue end, double t)
 149    {
 224150        t = Math.Clamp(t, 0.0, 1.0);
 151
 152        static byte Lerp(byte a, byte b, double tt)
 672153            => (byte)Math.Clamp((int)Math.Round(a + (b - a) * tt, MidpointRounding.AwayFromZero), 0, 255);
 154
 224155        return AllyariaColorValue.FromRgba(
 224156            Lerp(start.R, end.R, t),
 224157            Lerp(start.G, end.G, t),
 224158            Lerp(start.B, end.B, t)
 224159        );
 160    }
 161
 162    /// <summary>
 163    /// sRGB-space linear interpolation between two opaque colors. Note this is *not* perceptually uniform.
 164    /// </summary>
 165    /// <param name="a">Start color.</param>
 166    /// <param name="b">End color.</param>
 167    /// <param name="t">Blend factor in [0..1].</param>
 168    /// <returns>Blended color in sRGB.</returns>
 6169    public static AllyariaColorValue MixSrgb(AllyariaColorValue a, AllyariaColorValue b, double t) => LerpSrgb(a, b, t);
 170
 171    /// <summary>WCAG relative luminance from sRGB bytes.</summary>
 172    /// <param name="color">Opaque sRGB color.</param>
 173    /// <returns>Relative luminance [0..1].</returns>
 174    public static double RelativeLuminance(AllyariaColorValue color)
 175    {
 3384176        var rl = SrgbToLinear(color.R);
 3384177        var gl = SrgbToLinear(color.G);
 3384178        var bl = SrgbToLinear(color.B);
 179
 3384180        return 0.2126 * rl + 0.7152 * gl + 0.0722 * bl;
 181    }
 182
 183    /// <summary>
 184    /// Binary-search mixing the starting foreground toward a pole (black or white) in sRGB, returning the closest solut
 185    /// that meets (or best-approaches) the target.
 186    /// </summary>
 187    /// <param name="start">Starting foreground (opaque).</param>
 188    /// <param name="pole">Target pole (<see cref="Colors.Black" /> or <see cref="Colors.White" />).</param>
 189    /// <param name="background">BackgroundColor (opaque).</param>
 190    /// <param name="minimumRatio">Target ratio.</param>
 191    /// <returns>Resolution result for this pole.</returns>
 192    private static ContrastResult SearchTowardPole(AllyariaColorValue start,
 193        AllyariaColorValue pole,
 194        AllyariaColorValue background,
 195        double minimumRatio)
 196    {
 24197        double lo = 0.0, hi = 1.0;
 198        const int iters = 18;
 12199        var bestRatio = -1.0;
 12200        var bestColor = start;
 12201        var met = false;
 202
 456203        for (var i = 0; i < iters; i++)
 204        {
 216205            var mid = 0.5 * (lo + hi);
 216206            var candidate = LerpSrgb(start, pole, mid);
 216207            var ratio = ContrastRatio(candidate, background);
 208
 216209            if (ratio > bestRatio)
 210            {
 94211                bestRatio = ratio;
 94212                bestColor = candidate;
 213            }
 214
 216215            if (ratio >= minimumRatio)
 216            {
 22217                met = true;
 22218                hi = mid; // seek closest-to-start satisfying mix
 219            }
 220            else
 221            {
 194222                lo = mid;
 223            }
 224        }
 225
 12226        var finalColor = met
 12227            ? LerpSrgb(start, pole, hi)
 12228            : bestColor;
 229
 12230        var finalRatio = ContrastRatio(finalColor, background);
 231
 12232        return new ContrastResult(finalColor, background, finalRatio, met);
 233    }
 234
 235    /// <summary>
 236    /// Binary search along the HSV Value rail (keeping H and S) to find the minimum-change V that meets the contrast
 237    /// requirement; returns best-approaching if unreachable.
 238    /// </summary>
 239    /// <param name="h">Hue (degrees).</param>
 240    /// <param name="s">Saturation (percent).</param>
 241    /// <param name="vStart">Starting Value (percent).</param>
 242    /// <param name="direction">+1 brighten, -1 darken.</param>
 243    /// <param name="background">BackgroundColor (opaque).</param>
 244    /// <param name="minimumRatio">Target ratio.</param>
 245    /// <returns>Resolution result for this search branch.</returns>
 246    private static ContrastResult SearchValueRail(double h,
 247        double s,
 248        double vStart,
 249        int direction,
 250        AllyariaColorValue background,
 251        double minimumRatio)
 252    {
 253        double lo, hi;
 254
 58255        if (direction > 0)
 256        {
 22257            lo = vStart;
 22258            hi = 100.0;
 259        }
 260        else
 261        {
 36262            lo = vStart;
 36263            hi = 0.0;
 264        }
 265
 266        const int iters = 18;
 58267        double? found = null;
 58268        var bestRatio = -1.0;
 58269        var bestColor = AllyariaColorValue.FromHsva(h, s, vStart);
 270
 2204271        for (var i = 0; i < iters; i++)
 272        {
 1044273            var mid = 0.5 * (lo + hi);
 1044274            var candidate = AllyariaColorValue.FromHsva(h, s, mid);
 1044275            var ratio = ContrastRatio(candidate, background);
 276
 1044277            if (ratio > bestRatio)
 278            {
 230279                bestRatio = ratio;
 230280                bestColor = candidate;
 281            }
 282
 1044283            if (ratio >= minimumRatio)
 284            {
 308285                found = mid;
 308286                hi = mid; // tighten toward smallest change
 287            }
 288            else
 289            {
 736290                lo = mid;
 291            }
 292        }
 293
 58294        if (found.HasValue)
 295        {
 28296            var final = AllyariaColorValue.FromHsva(h, s, hi);
 28297            var r = ContrastRatio(final, background);
 298
 28299            return new ContrastResult(final, background, r, true);
 300        }
 301
 30302        return new ContrastResult(bestColor, background, bestRatio, false);
 303    }
 304
 305    /// <summary>Converts sRGB 8-bit channel to linear-light [0..1] for luminance computation.</summary>
 306    /// <param name="c8">Channel byte.</param>
 307    /// <returns>Linear-light value.</returns>
 308    private static double SrgbToLinear(byte c8)
 309    {
 10152310        var c = c8 / 255.0;
 311
 10152312        return c <= 0.03928
 10152313            ? c / 12.92
 10152314            : Math.Pow((c + 0.055) / 1.055, 2.4);
 315    }
 316}