File: System\Windows\Forms\Controls\Buttons\ButtonInternal\DarkMode\ModernButtonDarkModeRenderer.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.
 
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Windows.Forms.VisualStyles;
 
namespace System.Windows.Forms;
 
/// <summary>
///  Modern, WinUI-inspired push button renderer used when <see cref="Control.VisualStylesMode"/> is
///  <see cref="VisualStylesMode.Net11"/> or later. It supports both dark and light color schemes and draws a
///  rounded button area, an optional dark gap ring, and a rounded focus ring for the focused button.
/// </summary>
/// <remarks>
///  <para>
///   The visual is intentionally driven by a small set of DPI-scaled constants so the appearance can be
///   fine-tuned during exploratory testing. All widths are expressed in logical (96-DPI) pixels.
///  </para>
/// </remarks>
internal sealed class ModernButtonDarkModeRenderer : ButtonDarkModeRendererBase
{
    // Logical (96-DPI) layout constants. DPI-scaled via the base Scale() helper.
    private const int FocusRingThicknessLogical = 2;
    private const int FocusGapThicknessLogical = 1;
    private const int FocusedCornerRadiusLogical = 6;
    private const int UnfocusedCornerRadiusLogical = 8;
    private const int BorderThicknessLogical = 1;
    private const int ContentInsetLogical = 4;
 
    // Dark scheme - normal button area.
    private static readonly Color s_darkNormal = Color.FromArgb(0x2D, 0x2D, 0x2D);
    private static readonly Color s_darkNormalHover = Color.FromArgb(0x32, 0x32, 0x32);
    private static readonly Color s_darkNormalPressed = Color.FromArgb(0x2A, 0x2A, 0x2A);
    private static readonly Color s_darkDisabled = Color.FromArgb(0x25, 0x25, 0x25);
 
    private static readonly Color s_darkDisabledText = Color.FromArgb(0x88, 0x88, 0x88);
 
    private static readonly Color s_darkGap = Color.FromArgb(0x0A, 0x0A, 0x0A);
    private static readonly Color s_darkFocusRing = Color.White;
 
    // Light (WinUI) scheme - normal button area.
    private static readonly Color s_lightNormal = Color.FromArgb(0xFB, 0xFB, 0xFB);
    private static readonly Color s_lightNormalHover = Color.FromArgb(0xF9, 0xF9, 0xF9);
    private static readonly Color s_lightNormalPressed = Color.FromArgb(0xF5, 0xF5, 0xF5);
    private static readonly Color s_lightDisabled = Color.FromArgb(0xFA, 0xFA, 0xFA);
    private static readonly Color s_lightBorder = Color.FromArgb(0xD0, 0xD0, 0xD0);
 
    private static readonly Color s_lightDisabledText = Color.FromArgb(0xA0, 0xA0, 0xA0);
 
    private static bool IsDark => Application.IsDarkModeEnabled;
 
    private int FocusRingThickness
        => ScaleBorderThickness(Math.Max(1, Scale(FocusRingThicknessLogical) - HighDpiCorrection));
 
    private int FocusGapThickness
        => Math.Max(1, Scale(FocusGapThicknessLogical) - HighDpiCorrection);
 
    private int FocusBodyInset
        => Math.Max(
            FocusRingThickness + FocusGapThickness,
            Math.Max(1, Scale(FocusRingThicknessLogical))
                + Math.Max(1, Scale(FocusGapThicknessLogical)));
 
    private int HighDpiCorrection => DeviceDpi > ScaleHelper.OneHundredPercentLogicalDpi ? 1 : 0;
 
    private int GetCornerRadius(bool focused, bool isDefault)
        => Math.Max(1, Scale(focused || isDefault ? FocusedCornerRadiusLogical : UnfocusedCornerRadiusLogical));
 
    // When focused, the body is inset just enough to leave room for the focus ring and a single-pixel gap,
    // keeping that gap tight so the rounded body claims as much real estate as possible.
    private protected override Padding PaddingCore
        => new(FocusBodyInset);
 
    // When the focus ring is not drawn there is nothing to inset for, so the rounded body expands to fill the
    // whole client area - covering the band the ring and its gap would otherwise occupy. This gives the button
    // a more generous, less cramped background without changing the control's own Padding.
    private protected override Padding GetContentPadding(bool focusRingVisible)
        => focusRingVisible ? PaddingCore : Padding.Empty;
 
    internal override Padding GetPreferredSizePadding()
        => new(FocusBodyInset + Scale(ContentInsetLogical));
 
    private protected override bool UseModernStateDefaults => true;
 
    private protected override bool PaintParentBackground => true;
 
    public override Rectangle DrawButtonBackground(
        Graphics graphics,
        Rectangle bounds,
        PushButtonState state,
        bool isDefault,
        bool focused,
        Color backColor)
    {
        GraphicsState? saved = graphics.Save();
        try
        {
            graphics.SmoothingMode = SmoothingMode.AntiAlias;
 
            RectangleF pathBounds = GetPathBounds(bounds);
            float radius = GetCornerRadius(focused, isDefault);
            int borderThickness = ScaleBorderThickness(
                Math.Max(1, Scale(BorderThicknessLogical)));
 
            if (!IsDark
                && !isDefault
                && state != PushButtonState.Disabled
                && borderThickness > 0)
            {
                Color borderColor = ResolveBorderColor(s_lightBorder);
                using var borderBrush = borderColor.GetCachedSolidBrushScope();
                using GraphicsPath borderPath = CreateRingPath(
                    pathBounds,
                    radius,
                    borderThickness);
                graphics.FillPath(borderBrush, borderPath);
 
                pathBounds = Inset(pathBounds, borderThickness);
                radius = Math.Max(1, radius - (2 * borderThickness));
            }
 
            using var brush = backColor.GetCachedSolidBrushScope();
            using GraphicsPath bodyPath = CreateRoundedPath(pathBounds, radius);
            graphics.FillPath(brush, bodyPath);
        }
        finally
        {
            if (saved is not null)
            {
                graphics.Restore(saved);
            }
        }
 
        int inset = Scale(ContentInsetLogical);
        return Rectangle.Inflate(bounds, -inset, -inset);
    }
 
    public override void DrawFocusIndicator(Graphics graphics, Rectangle bounds, bool isDefault)
    {
        GraphicsState? saved = graphics.Save();
        try
        {
            graphics.SmoothingMode = SmoothingMode.AntiAlias;
 
            if (FocusRingThickness == 0)
            {
                return;
            }
 
            RectangleF outerBounds = GetPathBounds(bounds);
            int bodyInset = FocusRingThickness + FocusGapThickness;
            float outerRadius = GetCornerRadius(focused: true, isDefault: isDefault)
                + (2 * bodyInset);
 
            Color gapColor = IsDark ? s_darkGap : SystemColors.Window;
            using (var gapBrush = gapColor.GetCachedSolidBrushScope())
            {
                using GraphicsPath gapPath = CreateRingPath(
                    Inset(outerBounds, FocusRingThickness),
                    outerRadius - (2 * FocusRingThickness),
                    FocusGapThickness);
                graphics.FillPath(gapBrush, gapPath);
            }
 
            Color ringColor = ResolveBorderColor(IsDark ? s_darkFocusRing : SystemColors.WindowText);
            using var ringBrush = ringColor.GetCachedSolidBrushScope();
            using GraphicsPath ringPath = CreateRingPath(
                outerBounds,
                outerRadius,
                FocusRingThickness);
            graphics.FillPath(ringBrush, ringPath);
        }
        finally
        {
            if (saved is not null)
            {
                graphics.Restore(saved);
            }
        }
    }
 
    public override Color GetTextColor(PushButtonState state, bool isDefault, Color backColor)
    {
        if (state == PushButtonState.Disabled)
        {
            Color preferredForeColor = IsDark
                ? s_darkDisabledText
                : s_lightDisabledText;
 
            return ModernControlColorMath.GetDisabledTextColor(
                preferredForeColor,
                backColor);
        }
 
        return ModernButtonColorMath.GetReadableForeColor(backColor);
    }
 
    public override Color GetBackgroundColor(PushButtonState state, bool isDefault)
    {
        if (state == PushButtonState.Disabled)
        {
            return IsDark ? s_darkDisabled : s_lightDisabled;
        }
 
        if (isDefault)
        {
            return ModernButtonColorMath.GetDefaultButtonColor(state);
        }
 
        return IsDark
            ? state switch
            {
                PushButtonState.Hot => s_darkNormalHover,
                PushButtonState.Pressed => s_darkNormalPressed,
                _ => s_darkNormal
            }
            : state switch
            {
                PushButtonState.Hot => s_lightNormalHover,
                PushButtonState.Pressed => s_lightNormalPressed,
                _ => s_lightNormal
            };
    }
 
    private static RectangleF GetPathBounds(Rectangle bounds)
        => new(
            bounds.X,
            bounds.Y,
            Math.Max(1, bounds.Width - 1),
            Math.Max(1, bounds.Height - 1));
 
    private static RectangleF Inset(RectangleF bounds, float inset)
        => new(
            bounds.X + inset,
            bounds.Y + inset,
            Math.Max(1, bounds.Width - (2 * inset)),
            Math.Max(1, bounds.Height - (2 * inset)));
 
    private static GraphicsPath CreateRoundedPath(RectangleF bounds, float radius)
    {
        GraphicsPath path = new();
        float clampedRadius = Math.Clamp(radius, 1, Math.Min(bounds.Width, bounds.Height));
        path.AddRoundedRectangle(bounds, new SizeF(clampedRadius, clampedRadius));
        return path;
    }
 
    private static GraphicsPath CreateRingPath(
        RectangleF outerBounds,
        float outerRadius,
        float thickness)
    {
        GraphicsPath path = CreateRoundedPath(outerBounds, outerRadius);
        RectangleF innerBounds = Inset(outerBounds, thickness);
        float innerRadius = Math.Max(1, outerRadius - (2 * thickness));
        using GraphicsPath innerPath = CreateRoundedPath(innerBounds, innerRadius);
        path.FillMode = FillMode.Alternate;
        path.AddPath(innerPath, connect: false);
        return path;
    }
}