diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/TextBox/TextBoxBase.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/TextBox/TextBoxBase.cs index e5515679582..852416065cb 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/TextBox/TextBoxBase.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/TextBox/TextBoxBase.cs @@ -9,7 +9,6 @@ using System.Runtime.InteropServices; using System.Text; using System.Windows.Forms.Layout; -using System.Windows.Forms.Rendering.Animation; using Windows.Win32.System.Variant; using Windows.Win32.UI.Accessibility; @@ -52,7 +51,6 @@ public abstract partial class TextBoxBase : Control /// The current border for this edit control. /// private BorderStyle _borderStyle = BorderStyle.Fixed3D; - private AnimatedFocusIndicatorRenderer? _focusIndicatorRenderer; private const OBJECT_IDENTIFIER HorizontalScrollBarObjectId = (OBJECT_IDENTIFIER)(-6); private const OBJECT_IDENTIFIER VerticalScrollBarObjectId = (OBJECT_IDENTIFIER)(-5); @@ -89,6 +87,9 @@ public abstract partial class TextBoxBase : Control /// private bool _doubleClickFired; + // Pointer is over the control; drives the modern Hover stroke state. + private bool _hovered; + private static int[]? s_shortcutsToDisable; // We store all boolean properties in here. @@ -363,7 +364,6 @@ public BorderStyle BorderStyle SourceGenerated.EnumValidator.Validate(value); _borderStyle = value; - _focusIndicatorRenderer?.Synchronize(Focused, invalidate: false); CommonProperties.xClearPreferredSizeCache(this); if (EffectiveVisualStylesMode >= VisualStylesMode.Net11) @@ -964,15 +964,6 @@ private Size GetVisualStylesFocusBorderMetrics() DeviceDpiInternal); } - private int GetVisualStylesFocusBandHeight() - { - SystemVisualSettings settings = Application.SystemVisualSettings; - return GetVisualStylesFocusBandHeight( - settings.FocusBorderMetrics, - settings.TextScaleFactor, - DeviceDpiInternal); - } - internal static Size GetVisualStylesFocusBorderMetrics( Size focusBorderMetrics, float textScaleFactor, @@ -1695,8 +1686,6 @@ protected override void OnHandleCreated(EventArgs e) protected override void OnHandleDestroyed(EventArgs e) { - _focusIndicatorRenderer?.Dispose(); - _focusIndicatorRenderer = null; _textBoxFlags[s_modified] = Modified; _textBoxFlags[s_setSelectionOnHandleCreated] = true; // Update text selection cached values to be restored when recreating the handle. @@ -1711,7 +1700,6 @@ protected override void OnVisualStylesModeChanged(EventArgs e) _triggerNewClientSizeRequest = false; base.OnVisualStylesModeChanged(e); AdjustHeight(false); - _focusIndicatorRenderer?.Synchronize(Focused, invalidate: false); RecalculateVisualStylesClientArea(); } @@ -1809,16 +1797,7 @@ protected override unsafe void OnGotFocus(EventArgs e) { if (EffectiveVisualStylesMode >= VisualStylesMode.Net11) { - if (BorderStyle == BorderStyle.Fixed3D) - { - FocusIndicatorRenderer.SetFocused( - focused: true, - animate: SystemInformation.UIEffectsEnabled && !SystemInformation.HighContrast); - } - else - { - InvalidateVisualStylesFrame(); - } + InvalidateVisualStylesFrame(); } base.OnGotFocus(e); @@ -1828,21 +1807,34 @@ protected override unsafe void OnLostFocus(EventArgs e) { if (EffectiveVisualStylesMode >= VisualStylesMode.Net11) { - if (BorderStyle == BorderStyle.Fixed3D) - { - FocusIndicatorRenderer.SetFocused( - focused: false, - animate: SystemInformation.UIEffectsEnabled && !SystemInformation.HighContrast); - } - else - { - InvalidateVisualStylesFrame(); - } + InvalidateVisualStylesFrame(); } base.OnLostFocus(e); } + protected override void OnMouseEnter(EventArgs e) + { + if (EffectiveVisualStylesMode >= VisualStylesMode.Net11) + { + _hovered = true; + InvalidateVisualStylesFrame(); + } + + base.OnMouseEnter(e); + } + + protected override void OnMouseLeave(EventArgs e) + { + if (EffectiveVisualStylesMode >= VisualStylesMode.Net11) + { + _hovered = false; + InvalidateVisualStylesFrame(); + } + + base.OnMouseLeave(e); + } + protected override unsafe void OnSizeChanged(EventArgs e) { if (EffectiveVisualStylesMode >= VisualStylesMode.Net11) @@ -2661,17 +2653,29 @@ private protected virtual void OnNcPaint(Graphics graphics, HDC windowHdc) int cornerRadius = ScaleVisualStylesMetric(ModernControlVisualStyles.FieldCornerRadius); Size focusBorderMetrics = GetVisualStylesFocusBorderMetrics(); int borderThickness = Math.Max(focusBorderMetrics.Width, focusBorderMetrics.Height); - int focusBandHeight = GetVisualStylesFocusBandHeight(); - Color clientBackColor = BackColor; + ModernFieldStrokeContext strokeContext = new( + BackColor: BackColor, + Enabled: Enabled, + ReadOnly: ReadOnly, + Focused: Focused, + Hovered: _hovered, + DarkMode: Application.IsDarkModeEnabled, + AccentColor: Application.SystemVisualSettings.AccentColor, + DeviceDpi: DeviceDpi); + ModernFieldStroke stroke = ModernFieldStrokeResolver.GetStroke(strokeContext); + + int sideThickness = Math.Max(1, (int)MathF.Round(stroke.SideTopThicknessDip * DeviceDpi / 96f)); + int bottomThickness = Math.Max(1, (int)MathF.Round(stroke.BottomThicknessDip * DeviceDpi / 96f)); + + Color adornerColor = stroke.SideTopColor; + Color clientBackColor = stroke.SurfaceColor; Color parentBackColor = Parent?.BackColor ?? BackColor; - Color adornerColor = Enabled - ? ModernControlColorMath.TextControlBorderColor - : ModernControlColorMath.GetDisabledBorderColor(); using var clientBackgroundBrush = clientBackColor.GetCachedSolidBrushScope(); using var adornerBrush = adornerColor.GetCachedSolidBrushScope(); - using var adornerPen = adornerColor.GetCachedPenScope(borderThickness); + using var adornerPen = adornerColor.GetCachedPenScope(sideThickness); + using var flatBorderPen = stroke.BottomColor.GetCachedPenScope(sideThickness); Rectangle bounds = new( x: 0, @@ -2690,6 +2694,7 @@ private protected virtual void OnNcPaint(Graphics graphics, HDC windowHdc) // Making sure we never color outside the lines. deflatedBounds.Width -= 1; deflatedBounds.Height -= 1; + Rectangle focusIndicatorBand = Rectangle.Empty; // Keep the target clip excluded from the GDI+ drawing as well as from the explicit blits below. using Region region = new(bounds); @@ -2737,7 +2742,7 @@ private protected virtual void OnNcPaint(Graphics graphics, HDC windowHdc) case BorderStyle.FixedSingle: offscreenGraphics.FillRectangle(clientBackgroundBrush, deflatedBounds); - offscreenGraphics.DrawRectangle(adornerPen, deflatedBounds); + offscreenGraphics.DrawRectangle(flatBorderPen, deflatedBounds); break; case BorderStyle.Fixed3D: @@ -2762,40 +2767,41 @@ private protected virtual void OnNcPaint(Graphics graphics, HDC windowHdc) { // Chrome degradation fallback - flat render in place of the broken lozenge. offscreenGraphics.FillRectangle(clientBackgroundBrush, deflatedBounds); - offscreenGraphics.DrawRectangle(adornerPen, deflatedBounds); + offscreenGraphics.DrawRectangle(flatBorderPen, deflatedBounds); } break; } + // Bottom (elevation and focus) edge. Rounded Fixed3D draws the same tapered straight edge in every + // state, meeting the corners without riding up the arcs (#14997); states differ only by the + // resolved color and thickness. None draws a straight focus underline only while focused; the other + // flat styles already carry a visible box border. if (BorderStyle == BorderStyle.Fixed3D && canRenderRoundedChrome) { - Color focusColor = GetVisualStylesFocusColor(Application.SystemVisualSettings.HighContrastEnabled); - FocusIndicatorRenderer.DrawRoundedFocusIndicator( - offscreenGraphics, + using GraphicsPath bottomEdgePath = CreateVisualStylesBottomEdgePath( deflatedBounds, cornerRadius, - borderThickness, - focusBandHeight, - adornerColor, - focusColor); - } - else if (Focused) - { - Color focusColor = GetVisualStylesFocusColor(Application.SystemVisualSettings.HighContrastEnabled); - using var focusPen = focusColor.GetCachedPenScope(borderThickness); - int focusLineCount = Math.Min( - Math.Max(2, focusBorderMetrics.Height), - Math.Max(1, deflatedBounds.Height)); - for (int i = 0; i < focusLineCount; i++) - { - offscreenGraphics.DrawLine( - focusPen, - deflatedBounds.Left, - deflatedBounds.Bottom - i, - deflatedBounds.Right, - deflatedBounds.Bottom - i); - } + bottomThickness); + using var bottomEdgeBrush = stroke.BottomColor.GetCachedSolidBrushScope(); + + // The edge overlays the bottom, including the scrollbar corner. It must not inherit the client + // or scrollbar exclusion used to preserve their native rendering. + GraphicsState bottomEdgeState = offscreenGraphics.Save(); + offscreenGraphics.SetClip(bounds, CombineMode.Replace); + offscreenGraphics.FillPath(bottomEdgeBrush, bottomEdgePath); + offscreenGraphics.Restore(bottomEdgeState); + focusIndicatorBand = Rectangle.FromLTRB( + bounds.Left, + Math.Max(bounds.Top, deflatedBounds.Bottom - bottomThickness / 2), + bounds.Right, + bounds.Bottom); + } + else if (BorderStyle == BorderStyle.None && stroke.HasFocusIndicator) + { + // None has no box, so express focus with a straight underline. + using var bottomPen = stroke.BottomColor.GetCachedPenScope(bottomThickness); + offscreenGraphics.DrawLine(bottomPen, deflatedBounds.Left, deflatedBounds.Bottom, deflatedBounds.Right, deflatedBounds.Bottom); } Rectangle[] nonClientBands = GetNonClientPaintBands( @@ -2822,6 +2828,20 @@ private protected virtual void OnNcPaint(Graphics graphics, HDC windowHdc) rop: ROP_CODE.SRCCOPY); } } + + if (!focusIndicatorBand.IsEmpty) + { + PInvokeCore.BitBlt( + hdc: windowHdc, + x: focusIndicatorBand.X, + y: focusIndicatorBand.Y, + cx: focusIndicatorBand.Width, + cy: focusIndicatorBand.Height, + hdcSrc: (HDC)bufferHdc, + x1: focusIndicatorBand.X, + y1: focusIndicatorBand.Y, + rop: ROP_CODE.SRCCOPY); + } } finally { @@ -2829,6 +2849,38 @@ private protected virtual void OnNcPaint(Graphics graphics, HDC windowHdc) } } + internal static GraphicsPath CreateVisualStylesBottomEdgePath( + Rectangle bounds, + int cornerSize, + int indicatorThickness) + { + float cornerRadius = cornerSize / 2f; + float upperHeight = indicatorThickness / 2f; + float lowerHeight = indicatorThickness - upperHeight; + float upperCornerInset = cornerRadius / 4f; + float lowerCornerInset = cornerRadius; + + GraphicsPath path = new(); + path.StartFigure(); + path.AddLine( + bounds.Left + upperCornerInset, + bounds.Bottom - upperHeight, + bounds.Right - upperCornerInset, + bounds.Bottom - upperHeight); + path.AddLine( + bounds.Right - upperCornerInset, + bounds.Bottom - upperHeight, + bounds.Right - lowerCornerInset, + bounds.Bottom + lowerHeight); + path.AddLine( + bounds.Right - lowerCornerInset, + bounds.Bottom + lowerHeight, + bounds.Left + lowerCornerInset, + bounds.Bottom + lowerHeight); + path.CloseFigure(); + return path; + } + private static Rectangle[] GetNonClientPaintBands(Rectangle bounds, Rectangle clientBounds) => GetNonClientPaintBands(bounds, clientBounds, []); @@ -2963,9 +3015,6 @@ private Rectangle GetNativeClientRectangle() clientRect.Height); } - private AnimatedFocusIndicatorRenderer FocusIndicatorRenderer - => _focusIndicatorRenderer ??= new(this, InvalidateVisualStylesFrame); - private unsafe void InvalidateVisualStylesFrame() { if (!IsHandleCreated) diff --git a/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernControlColorMath.cs b/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernControlColorMath.cs index 7ccbc7e6926..6b2de223266 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernControlColorMath.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernControlColorMath.cs @@ -16,6 +16,24 @@ internal static class ModernControlColorMath private const float DisabledMuteAmount = 0.45f; private const int ContrastSearchIterations = 10; + // WinUI control-stroke overlay alphas over the black (light mode) / white (dark mode) pole, + // verified against Common_themeresources_any.xaml, except light-mode Strong, which is raised + // above WinUI so the visible bottom edge meets WCAG 1.4.11 (#14906). Composited in linear light. + private const int StrokeDefaultAlphaLight = 0x0F; // ControlStrokeColorDefault + private const int StrokeDefaultAlphaDark = 0x03; // near-invisible dark rest side, matching the light/classic look where the side effectively vanishes (WinUI value is 0x12, #14919) + private const int StrokeSecondaryAlphaLight = 0x29; // ControlStrokeColorSecondary + private const int StrokeSecondaryAlphaDark = 0x18; + private const int StrokeStrongAlphaLight = 0xB6; // resting bottom edge; ~3.1:1, the WCAG 1.4.11 floor, lightened from 0xD1 so it is less heavy than the focus accent (#14906, #14997). + private const int StrokeStrongAlphaDark = 0x8B; + + // Hover overlay: tuned a step stronger than Secondary for a more noticeable cue (#14906 direction). + private const int StrokeHoverAlphaLight = 0x40; + private const int StrokeHoverAlphaDark = 0x28; + + // Read-only surface tint: a subtle fill shift signalling non-editability, per Leaf's #14906 table. + private const int SurfaceReadOnlyAlphaLight = 0x0A; + private const int SurfaceReadOnlyAlphaDark = 0x0A; + // Shared disabled-state palette for modern renderers. Modern controls do not honor user-set // BackColor/ForeColor while disabled, so these fixed surfaces replace them. This is the single // source of truth: the modern Button renderers and the modern ComboBox adapter all read from @@ -26,6 +44,8 @@ internal static class ModernControlColorMath private static readonly Color s_lightModeDisabledBorder = Color.FromArgb(0xD0, 0xD0, 0xD0); private static readonly Color s_darkModeDisabledForeground = Color.FromArgb(0x88, 0x88, 0x88); private static readonly Color s_lightModeDisabledForeground = Color.FromArgb(0xA0, 0xA0, 0xA0); + private static readonly Color s_darkModeDisabledBorderStrong = Color.FromArgb(0x6A, 0x6A, 0x6A); + private static readonly Color s_lightModeDisabledBorderStrong = Color.FromArgb(0xB0, 0xB0, 0xB0); /// /// Gets the stable border color for modern editable text controls when enabled. @@ -57,6 +77,14 @@ internal static Color GetDisabledBorderColor() ? s_darkModeDisabledBorder : s_lightModeDisabledBorder; + /// Gets the stronger disabled border color used for the disabled bottom (elevation) edge. + internal static Color GetDisabledStrongBorderColor() + => SystemInformation.HighContrast + ? SystemColors.GrayText + : Application.IsDarkModeEnabled + ? s_darkModeDisabledBorderStrong + : s_lightModeDisabledBorderStrong; + /// /// Gets the contrast-adjusted foreground color for content drawn on /// . @@ -138,6 +166,64 @@ internal static Color GetDisabledTextColor( return result; } + /// Gets the default, lightest field border stroke composited onto . + internal static Color GetFieldStrokeDefault(Color background, bool darkMode) + => CompositeStrokeOverlay(background, darkMode ? StrokeDefaultAlphaDark : StrokeDefaultAlphaLight, darkMode); + + /// Gets the secondary field border stroke, a step stronger than default. + internal static Color GetFieldStrokeSecondary(Color background, bool darkMode) + => CompositeStrokeOverlay(background, darkMode ? StrokeSecondaryAlphaDark : StrokeSecondaryAlphaLight, darkMode); + + /// Gets the hover field border stroke: a bit stronger than secondary for a noticeable cue. + internal static Color GetFieldStrokeHover(Color background, bool darkMode) + => CompositeStrokeOverlay(background, darkMode ? StrokeHoverAlphaDark : StrokeHoverAlphaLight, darkMode); + + /// Gets the ReadOnly control surface: a subtle non-editable tint of the background. + internal static Color GetFieldReadOnlySurface(Color background, bool darkMode) + => CompositeStrokeOverlay(background, darkMode ? SurfaceReadOnlyAlphaDark : SurfaceReadOnlyAlphaLight, darkMode); + + /// Gets the strong field border stroke, used for the resting bottom (elevation) edge. + internal static Color GetFieldStrokeStrong(Color background, bool darkMode) + => CompositeStrokeOverlay(background, darkMode ? StrokeStrongAlphaDark : StrokeStrongAlphaLight, darkMode); + + // Composites a black (light) or white (dark) overlay of the given 0-255 alpha onto an opaque + // background in linear light, returning an opaque color. A straight sRGB blend is wrong here. + private static Color CompositeStrokeOverlay(Color background, int overlayAlpha, bool darkMode) + { + background = ResolveOpaqueColor(background); + float alpha = Math.Clamp(overlayAlpha / 255f, 0f, 1f); + float pole = darkMode ? 1f : 0f; + + return Color.FromArgb( + byte.MaxValue, + CompositeChannel(background.R), + CompositeChannel(background.G), + CompositeChannel(background.B)); + + byte CompositeChannel(byte channel) + { + float mixed = (pole * alpha) + (SrgbToLinear(channel) * (1f - alpha)); + return LinearToSrgb(mixed); + } + } + + private static float SrgbToLinear(byte channel) + { + float value = channel / 255f; + return value <= 0.04045f + ? value / 12.92f + : MathF.Pow((value + 0.055f) / 1.055f, 2.4f); + } + + private static byte LinearToSrgb(float linear) + { + linear = Math.Clamp(linear, 0f, 1f); + float value = linear <= 0.0031308f + ? linear * 12.92f + : (1.055f * MathF.Pow(linear, 1f / 2.4f)) - 0.055f; + return (byte)MathF.Round(value * 255f); + } + private static bool HasMinimumContrast( Color foreColor, Color firstBackColor, diff --git a/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStroke.cs b/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStroke.cs new file mode 100644 index 00000000000..be53c2bee14 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStroke.cs @@ -0,0 +1,22 @@ +// 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; + +/// +/// A fully resolved modern editable-control border for one . +/// Paint paths receive only this; state precedence and color derivation stay inside the resolver. +/// +/// Color of the top, left, and right edges. +/// Color of the bottom edge. +/// Fill color of the control surface for this state. +/// Thickness of the top, left, and right edges, in DIPs. +/// Thickness of the bottom edge, in DIPs. +/// Whether the bottom edge expands into the focus indicator. +internal readonly record struct ModernFieldStroke( + Color SideTopColor, + Color BottomColor, + Color SurfaceColor, + float SideTopThicknessDip, + float BottomThicknessDip, + bool HasFocusIndicator); diff --git a/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStrokeContext.cs b/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStrokeContext.cs new file mode 100644 index 00000000000..ff3be23d9b9 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStrokeContext.cs @@ -0,0 +1,25 @@ +// 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; + +/// +/// The inputs the stroke resolver uses to produce a for a control. +/// +/// The control's effective, opaque background color. +/// Whether the control is enabled. +/// Whether the control is read-only. +/// Whether the control has keyboard focus. +/// Whether the pointer is over the control. +/// Whether dark mode is in effect. +/// The system accent color. +/// The control's current device DPI. +internal readonly record struct ModernFieldStrokeContext( + Color BackColor, + bool Enabled, + bool ReadOnly, + bool Focused, + bool Hovered, + bool DarkMode, + Color AccentColor, + int DeviceDpi); diff --git a/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStrokeResolver.cs b/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStrokeResolver.cs new file mode 100644 index 00000000000..ca9dc837a93 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStrokeResolver.cs @@ -0,0 +1,99 @@ +// 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; + +/// +/// Resolves a for a modern editable control from its +/// . This is the single chokepoint: paint paths call +/// and receive a completed stroke, while state precedence and all color +/// and thickness selection stay private here. Visual target: see #14906. +/// +internal static class ModernFieldStrokeResolver +{ + private const float BaseStrokeDip = 2f; + private const float FocusBottomStrokeDip = 3f; + + /// Resolves the completed stroke for the given context. + internal static ModernFieldStroke GetStroke(in ModernFieldStrokeContext context) + { + ModernFieldStrokeState state = ResolveState(context); + + return GetThemedStroke(state, context); + } + + // Precedence: Disabled > Focused > ReadOnly > Hover > Rest. + private static ModernFieldStrokeState ResolveState(in ModernFieldStrokeContext context) + { + if (!context.Enabled) + { + return ModernFieldStrokeState.Disabled; + } + + if (context.Focused) + { + return ModernFieldStrokeState.Focused; + } + + if (context.ReadOnly) + { + return ModernFieldStrokeState.ReadOnly; + } + + if (context.Hovered) + { + return ModernFieldStrokeState.Hover; + } + + return ModernFieldStrokeState.Rest; + } + + private static ModernFieldStroke GetThemedStroke(ModernFieldStrokeState state, in ModernFieldStrokeContext context) + { + bool dark = context.DarkMode; + // Every state keeps the control's own surface, so no inner border appears between the painted band + // and the native client area (#14997). States are expressed through border color and thickness, + // and all strokes composite over the normal background. + Color surface = context.BackColor; + Color strokeBackground = context.BackColor; + + Color sideTop; + Color bottom; + float bottomDip = BaseStrokeDip; + + switch (state) + { + case ModernFieldStrokeState.Focused: + // Focus keeps the subtle resting side color (per Leaf's design image); the accent bottom + // edge is the focus cue, not a heavier grey box around the sides. + sideTop = ModernControlColorMath.GetFieldStrokeDefault(strokeBackground, dark); + bottom = context.AccentColor; + bottomDip = FocusBottomStrokeDip; + break; + + case ModernFieldStrokeState.Hover: + sideTop = ModernControlColorMath.GetFieldStrokeHover(strokeBackground, dark); + bottom = ModernControlColorMath.GetFieldStrokeStrong(strokeBackground, dark); + break; + + case ModernFieldStrokeState.Disabled: + sideTop = ModernControlColorMath.GetDisabledBorderColor(); + bottom = ModernControlColorMath.GetDisabledStrongBorderColor(); + break; + + default: + // Rest and ReadOnly share the resting strokes; ReadOnly differs only by its surface. + sideTop = ModernControlColorMath.GetFieldStrokeDefault(strokeBackground, dark); + bottom = ModernControlColorMath.GetFieldStrokeStrong(strokeBackground, dark); + break; + } + + return new ModernFieldStroke( + sideTop, + bottom, + surface, + BaseStrokeDip, + bottomDip, + HasFocusIndicator: state == ModernFieldStrokeState.Focused); + } +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStrokeState.cs b/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStrokeState.cs new file mode 100644 index 00000000000..025ee75d2ba --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStrokeState.cs @@ -0,0 +1,26 @@ +// 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; + +/// +/// The interaction state of a modern (Net11) editable-control border, used to resolve its per-state +/// stroke. When several apply, precedence is Disabled > Focused > ReadOnly > Hover > Rest. +/// +internal enum ModernFieldStrokeState +{ + /// Default resting appearance. + Rest, + + /// The pointer is over the control. + Hover, + + /// The control has keyboard focus. + Focused, + + /// The control is disabled. + Disabled, + + /// The control is read-only. + ReadOnly, +} diff --git a/src/test/unit/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStrokeResolverTests.cs b/src/test/unit/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStrokeResolverTests.cs new file mode 100644 index 00000000000..8ca7ad0113b --- /dev/null +++ b/src/test/unit/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStrokeResolverTests.cs @@ -0,0 +1,135 @@ +// 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; + +namespace System.Windows.Forms.Tests; + +public class ModernFieldStrokeResolverTests +{ + private static readonly Color s_accent = Color.FromArgb(0, 120, 215); + + private static ModernFieldStrokeContext Context( + bool enabled = true, + bool readOnly = false, + bool focused = false, + bool hovered = false, + bool darkMode = false) + => new( + BackColor: Color.White, + Enabled: enabled, + ReadOnly: readOnly, + Focused: focused, + Hovered: hovered, + DarkMode: darkMode, + AccentColor: s_accent, + DeviceDpi: 96); + + // ---- Precedence: Disabled > Focused > ReadOnly > Hover > Rest ---- + + [Fact] + public void Disabled_wins_over_everything() + { + ModernFieldStroke stroke = ModernFieldStrokeResolver.GetStroke( + Context(enabled: false, focused: true, readOnly: true, hovered: true)); + + stroke.SideTopColor.Should().Be(ModernControlColorMath.GetDisabledBorderColor()); + stroke.BottomColor.Should().Be(ModernControlColorMath.GetDisabledStrongBorderColor()); + stroke.SurfaceColor.Should().Be(Color.White); + } + + [Fact] + public void Focused_wins_over_readonly_and_hover() + { + ModernFieldStroke stroke = ModernFieldStrokeResolver.GetStroke( + Context(focused: true, readOnly: true, hovered: true)); + + stroke.BottomColor.Should().Be(s_accent); + stroke.BottomThicknessDip.Should().Be(3f); + } + + [Fact] + public void ReadOnly_wins_over_hover_and_matches_the_rest_stroke() + { + ModernFieldStroke readOnly = ModernFieldStrokeResolver.GetStroke(Context(readOnly: true, hovered: true)); + ModernFieldStroke rest = ModernFieldStrokeResolver.GetStroke(Context()); + + readOnly.SideTopColor.Should().Be(rest.SideTopColor); + readOnly.BottomColor.Should().Be(rest.BottomColor); + readOnly.SurfaceColor.Should().Be(rest.SurfaceColor); + } + + [Fact] + public void Hover_keeps_the_rest_surface_and_differs_by_border() + { + ModernFieldStroke hover = ModernFieldStrokeResolver.GetStroke(Context(hovered: true)); + ModernFieldStroke rest = ModernFieldStrokeResolver.GetStroke(Context()); + + hover.SurfaceColor.Should().Be(rest.SurfaceColor); + hover.SideTopColor.Should().NotBe(rest.SideTopColor); + } + + // ---- Thicknesses (in DIPs) ---- + + [Fact] + public void Rest_uses_two_dip_side_and_bottom() + { + ModernFieldStroke rest = ModernFieldStrokeResolver.GetStroke(Context()); + + rest.SideTopThicknessDip.Should().Be(2f); + rest.BottomThicknessDip.Should().Be(2f); + } + + [Fact] + public void Focus_bottom_is_three_dip() + => ModernFieldStrokeResolver.GetStroke(Context(focused: true)).BottomThicknessDip.Should().Be(3f); + + [Theory] + [InlineData(96)] + [InlineData(144)] + [InlineData(192)] + public void Thickness_is_expressed_in_dips_independent_of_dpi(int dpi) + { + ModernFieldStroke stroke = ModernFieldStrokeResolver.GetStroke(Context() with { DeviceDpi = dpi }); + + stroke.SideTopThicknessDip.Should().Be(2f); + stroke.BottomThicknessDip.Should().Be(2f); + } + + // ---- Color math (linear-light overlays) ---- + + [Fact] + public void Resolved_colors_are_opaque() + { + ModernFieldStroke rest = ModernFieldStrokeResolver.GetStroke(Context()); + + rest.SideTopColor.A.Should().Be(255); + rest.BottomColor.A.Should().Be(255); + rest.SurfaceColor.A.Should().Be(255); + } + + [Fact] + public void Stronger_overlay_is_darker_and_neutral_over_white() + { + Color light = ModernControlColorMath.GetFieldStrokeDefault(Color.White, darkMode: false); + Color strong = ModernControlColorMath.GetFieldStrokeStrong(Color.White, darkMode: false); + + light.R.Should().BeGreaterThan(strong.R); + light.R.Should().Be(light.G); + light.G.Should().Be(light.B); + } + + [Fact] + public void Dark_mode_lightens_the_stroke_over_a_dark_surface() + { + Color darkBackground = Color.FromArgb(32, 32, 32); + + Color stroke = ModernControlColorMath.GetFieldStrokeDefault(darkBackground, darkMode: true); + + stroke.R.Should().BeGreaterThan(darkBackground.R); + } + + [Fact] + public void ForeColor_cannot_affect_the_stroke_by_construction() + => typeof(ModernFieldStrokeContext).GetProperty("ForeColor").Should().BeNull(); +} diff --git a/src/test/unit/System.Windows.Forms/TextBoxBaseTests.cs b/src/test/unit/System.Windows.Forms/TextBoxBaseTests.cs index 5824a4b13cb..56b38706560 100644 --- a/src/test/unit/System.Windows.Forms/TextBoxBaseTests.cs +++ b/src/test/unit/System.Windows.Forms/TextBoxBaseTests.cs @@ -401,71 +401,6 @@ public void TextBoxBase_GetVisualStylesFocusColor_ReturnsExpected(bool highContr Assert.Equal(expected, TextBoxBase.GetVisualStylesFocusColor(highContrast)); } - [WinFormsFact] - public void TextBoxBase_ModernFixed3D_FocusTransitionReversesFromCurrentBlend() - { - using SystemVisualSettingsTestScope settingsScope = new(clientAreaAnimationEnabled: true); - - if (SystemInformation.HighContrast) - { - return; - } - - using SubTextBox control = new() - { - BorderStyle = BorderStyle.Fixed3D, - VisualStylesMode = VisualStylesMode.Net11 - }; - control.CreateControl(); - - control.OnGotFocus(EventArgs.Empty); - Rendering.Animation.AnimatedFocusIndicatorRenderer renderer = - control.TestAccessor.Dynamic._focusIndicatorRenderer; - - if (!SystemInformation.UIEffectsEnabled) - { - Assert.Equal(1f, renderer.FocusAmount); - Assert.False(renderer.IsRunning); - control.OnLostFocus(EventArgs.Empty); - Assert.Equal(0f, renderer.FocusAmount); - return; - } - - Assert.True(renderer.IsRunning); - renderer.AnimationProc(0.5f); - Assert.Equal(0.75f, renderer.FocusAmount, precision: 3); - - control.OnLostFocus(EventArgs.Empty); - renderer.AnimationProc(0.5f); - - Assert.Equal(0.1875f, renderer.FocusAmount, precision: 4); - renderer.EndAnimation(); - Assert.False(renderer.IsRunning); - Assert.Equal(0f, renderer.FocusAmount); - } - - [WinFormsTheory] - [InlineData(BorderStyle.None)] - [InlineData(BorderStyle.FixedSingle)] - public void TextBoxBase_ModernNon3DBorder_FocusDoesNotStartAnimation(BorderStyle borderStyle) - { - if (SystemInformation.HighContrast) - { - return; - } - - using SubTextBox control = new() - { - BorderStyle = borderStyle, - VisualStylesMode = VisualStylesMode.Net11 - }; - control.CreateControl(); - - control.OnGotFocus(EventArgs.Empty); - - Assert.Null(control.TestAccessor.Dynamic._focusIndicatorRenderer); - } - [WinFormsFact] public void TextBoxBase_ModernGeometry_UsesInternalInsetBeforeUserPadding() {