File: System\Windows\Forms\Rendering\Button\PopupButtonColorMath.cs
Web Access
Project: src\winforms\src\System.Windows.Forms\System.Windows.Forms.csproj (System.Windows.Forms)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
namespace System.Windows.Forms.Rendering.Button;
 
/// <summary>
///  Color utilities used by the <see cref="FlatStyle.Popup"/> key-cap renderer to derive material shading
///  colors from arbitrary effective colors.
/// </summary>
/// <remarks>
///  <para>
///   Nothing in here assumes a light or dark base color; every derivation is relative to the input, which is
///   what makes the material effect work with saturated, neutral, light, dark and (dark-mode remapped) system
///   colors alike.
///  </para>
/// </remarks>
internal static class PopupButtonColorMath
{
    public const float MinimumReadableContrastRatio = 4.5f;
 
    /// <summary>
    ///  Gets the relative luminance of a color in the range <c>0</c>-<c>1</c>.
    /// </summary>
    public static float GetLuminance(Color color)
        => ((0.299f * color.R) + (0.587f * color.G) + (0.114f * color.B)) / 255f;
 
    /// <summary>
    ///  Gets the WCAG relative luminance of a color, including the required sRGB linearization.
    /// </summary>
    public static float GetRelativeLuminance(Color color)
        => (0.2126f * Linearize(color.R))
            + (0.7152f * Linearize(color.G))
            + (0.0722f * Linearize(color.B));
 
    /// <summary>
    ///  Gets the WCAG contrast ratio between two colors.
    /// </summary>
    public static float GetContrastRatio(Color first, Color second)
    {
        float firstLuminance = GetRelativeLuminance(first);
        float secondLuminance = GetRelativeLuminance(second);
        float lighter = Math.Max(firstLuminance, secondLuminance);
        float darker = Math.Min(firstLuminance, secondLuminance);
 
        return (lighter + 0.05f) / (darker + 0.05f);
    }
 
    /// <summary>
    ///  Chooses black or white, whichever has the greater WCAG contrast ratio against the background.
    /// </summary>
    public static Color GetReadableForeColor(Color backColor)
        => GetContrastRatio(Color.Black, backColor) >= GetContrastRatio(Color.White, backColor)
            ? Color.Black
            : Color.White;
 
    /// <summary>
    ///  Chooses black or white by its worst contrast ratio across the darkest and lightest rendered surfaces.
    /// </summary>
    public static Color GetReadableForeColor(Color darkestBackColor, Color lightestBackColor)
    {
        float blackContrast = Math.Min(
            GetContrastRatio(Color.Black, darkestBackColor),
            GetContrastRatio(Color.Black, lightestBackColor));
        float whiteContrast = Math.Min(
            GetContrastRatio(Color.White, darkestBackColor),
            GetContrastRatio(Color.White, lightestBackColor));
 
        return blackContrast >= whiteContrast ? Color.Black : Color.White;
    }
 
    /// <summary>
    ///  Composites a foreground color over a background color.
    /// </summary>
    public static Color Composite(Color foreground, Color background)
    {
        float foregroundAlpha = foreground.A / 255f;
        float backgroundAlpha = background.A / 255f;
        float alpha = foregroundAlpha + (backgroundAlpha * (1f - foregroundAlpha));
 
        if (alpha <= 0f)
        {
            return Color.Transparent;
        }
 
        return Color.FromArgb(
            (int)MathF.Round(alpha * 255f),
            CompositeChannel(foreground.R, background.R),
            CompositeChannel(foreground.G, background.G),
            CompositeChannel(foreground.B, background.B));
 
        int CompositeChannel(byte foregroundChannel, byte backgroundChannel)
            => (int)MathF.Round(
                ((foregroundChannel * foregroundAlpha)
                    + (backgroundChannel * backgroundAlpha * (1f - foregroundAlpha)))
                / alpha);
    }
 
    /// <summary>
    ///  Linearly blends <paramref name="baseColor"/> towards <paramref name="target"/>.
    /// </summary>
    /// <param name="baseColor">The starting color.</param>
    /// <param name="target">The color to blend towards.</param>
    /// <param name="amount">Blend amount, <c>0</c> (base) to <c>1</c> (target).</param>
    public static Color Blend(Color baseColor, Color target, float amount)
    {
        amount = Math.Clamp(amount, 0f, 1f);
 
        int r = (int)MathF.Round(baseColor.R + ((target.R - baseColor.R) * amount));
        int g = (int)MathF.Round(baseColor.G + ((target.G - baseColor.G) * amount));
        int b = (int)MathF.Round(baseColor.B + ((target.B - baseColor.B) * amount));
 
        return Color.FromArgb(baseColor.A, r, g, b);
    }
 
    /// <summary>
    ///  Blends the color towards white by the given amount.
    /// </summary>
    public static Color Lighten(Color color, float amount)
        => Blend(color, Color.White, amount);
 
    /// <summary>
    ///  Blends the color towards black by the given amount.
    /// </summary>
    public static Color Darken(Color color, float amount)
        => Blend(color, Color.Black, amount);
 
    /// <summary>
    ///  Blends the color towards the pole (black or white) that increases contrast against itself - lightens
    ///  dark colors, darkens light colors.
    /// </summary>
    /// <remarks>
    ///  <para>
    ///   Used for borders and cues that must remain visible on both light and dark surfaces.
    ///  </para>
    /// </remarks>
    public static Color TowardsContrast(Color color, float amount)
        => GetLuminance(color) > 0.5f
            ? Darken(color, amount)
            : Lighten(color, amount);
 
    /// <summary>
    ///  Desaturates the color towards a gray of the same luminance and slightly pulls it towards a mid tone.
    ///  Used for disabled surfaces.
    /// </summary>
    public static Color Mute(Color color, float amount)
    {
        int gray = (int)MathF.Round(GetLuminance(color) * 255f);
        Color desaturated = Blend(color, Color.FromArgb(color.A, gray, gray, gray), amount);
 
        return Blend(desaturated, Color.FromArgb(color.A, 128, 128, 128), amount * 0.35f);
    }
 
    /// <summary>
    ///  Ensures a minimum luminance difference between <paramref name="color"/> and <paramref name="against"/>
    ///  by pushing <paramref name="color"/> away if needed.
    /// </summary>
    /// <param name="color">The color to adjust.</param>
    /// <param name="against">The surface it must stay readable on.</param>
    /// <param name="minDelta">Minimum luminance delta, <c>0</c>-<c>1</c>.</param>
    public static Color EnsureContrast(Color color, Color against, float minDelta)
    {
        float delta = MathF.Abs(GetLuminance(color) - GetLuminance(against));
 
        if (delta >= minDelta)
        {
            return color;
        }
 
        float push = minDelta - delta;
 
        return GetLuminance(against) > 0.5f
            ? Darken(color, push)
            : Lighten(color, push);
    }
 
    /// <summary>
    ///  Derives an opaque highlight color for text relief on the given surface.
    /// </summary>
    /// <remarks>
    ///  <para>
    ///   Opaque blended colors are used instead of alpha because GDI text rendering via
    ///   <see cref="TextRenderer"/> ignores the alpha channel.
    ///  </para>
    /// </remarks>
    public static Color GetTextHighlight(Color surface, float strength)
        => Lighten(surface, 0.28f * strength);
 
    /// <summary>
    ///  Derives an opaque shadow color for text relief on the given surface.
    /// </summary>
    public static Color GetTextShadow(Color surface, float strength)
        => Darken(surface, 0.34f * strength);
 
    private static float Linearize(byte channel)
    {
        float value = channel / 255f;
 
        return value <= 0.04045f
            ? value / 12.92f
            : MathF.Pow((value + 0.055f) / 1.055f, 2.4f);
    }
}