// 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_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_lightBorder = Color.FromArgb(0xD0, 0xD0, 0xD0);
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)
{
return state == PushButtonState.Disabled
? ModernControlColorMath.GetDisabledForeColor(backColor)
: ModernButtonColorMath.GetReadableForeColor(backColor);
}
public override Color GetBackgroundColor(PushButtonState state, bool isDefault)
{
if (state == PushButtonState.Disabled)
{
return ModernControlColorMath.GetDisabledSurfaceColor();
}
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;
}
}