From 593880280a24e06859d8cf5d4820569005056acd Mon Sep 17 00:00:00 2001 From: "Ricardo Bossan (BEYONDSOFT CONSULTING INC) (from Dev Box)" Date: Fri, 21 Aug 2026 18:22:16 -0300 Subject: [PATCH 01/16] Add modern field stroke model types --- .../Forms/Rendering/ModernFieldStroke.cs | 20 ++++++++++++++ .../Rendering/ModernFieldStrokeContext.cs | 27 +++++++++++++++++++ .../Forms/Rendering/ModernFieldStrokeState.cs | 26 ++++++++++++++++++ 3 files changed, 73 insertions(+) create mode 100644 src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStroke.cs create mode 100644 src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStrokeContext.cs create mode 100644 src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStrokeState.cs 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..8f7f1d38dc2 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStroke.cs @@ -0,0 +1,20 @@ +// 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. +internal readonly record struct ModernFieldStroke( + Color SideTopColor, + Color BottomColor, + Color SurfaceColor, + float SideTopThicknessDip, + float BottomThicknessDip); 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..5b5d9126147 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStrokeContext.cs @@ -0,0 +1,27 @@ +// 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. +/// Whether High Contrast 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, + bool HighContrast, + Color AccentColor, + int DeviceDpi); 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, +} From b4655ff53e03f446a9e0f613aad0a35f76cca001 Mon Sep 17 00:00:00 2001 From: "Ricardo Bossan (BEYONDSOFT CONSULTING INC) (from Dev Box)" Date: Fri, 21 Aug 2026 18:22:46 -0300 Subject: [PATCH 02/16] Add linear-light stroke overlay color math --- .../Forms/Rendering/ModernControlColorMath.cs | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) 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..4d1e788fb85 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,15 @@ 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. Composited in linear light (see below). + private const int StrokeDefaultAlphaLight = 0x0F; // ControlStrokeColorDefault + private const int StrokeDefaultAlphaDark = 0x12; + private const int StrokeSecondaryAlphaLight = 0x29; // ControlStrokeColorSecondary + private const int StrokeSecondaryAlphaDark = 0x18; + private const int StrokeStrongAlphaLight = 0x72; // ControlStrongStrokeColorDefault + private const int StrokeStrongAlphaDark = 0x8B; + // 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 @@ -138,6 +147,56 @@ 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 (used for Hover). + internal static Color GetFieldStrokeSecondary(Color background, bool darkMode) + => CompositeStrokeOverlay(background, darkMode ? StrokeSecondaryAlphaDark : StrokeSecondaryAlphaLight, 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, From 0926815d6028b672066b6959dae8034605609e27 Mon Sep 17 00:00:00 2001 From: "Ricardo Bossan (BEYONDSOFT CONSULTING INC) (from Dev Box)" Date: Fri, 21 Aug 2026 18:26:02 -0300 Subject: [PATCH 03/16] Add modern field stroke resolver --- .../Rendering/ModernFieldStrokeResolver.cs | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStrokeResolver.cs 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..b4bc0be0c95 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStrokeResolver.cs @@ -0,0 +1,109 @@ +// 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; + +/// +/// 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 = 4f; + + /// Resolves the completed stroke for the given context. + internal static ModernFieldStroke GetStroke(in ModernFieldStrokeContext context) + { + ModernFieldStrokeState state = ResolveState(context); + + return context.HighContrast + ? GetHighContrastStroke(state) + : 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; + Color surface = state == ModernFieldStrokeState.Disabled + ? ModernControlColorMath.GetDisabledSurfaceColor() + : context.BackColor; + + Color sideTop; + Color bottom; + float bottomDip = BaseStrokeDip; + + switch (state) + { + case ModernFieldStrokeState.Focused: + sideTop = ModernControlColorMath.GetFieldStrokeSecondary(surface, dark); + bottom = context.AccentColor; + bottomDip = FocusBottomStrokeDip; + break; + + case ModernFieldStrokeState.Hover: + sideTop = ModernControlColorMath.GetFieldStrokeSecondary(surface, dark); + bottom = ModernControlColorMath.GetFieldStrokeStrong(surface, dark); + break; + + case ModernFieldStrokeState.Disabled: + sideTop = ModernControlColorMath.GetDisabledBorderColor(); + bottom = ModernControlColorMath.GetDisabledBorderColor(); + break; + + default: + // Rest and ReadOnly share the resting look; ReadOnly differs only by surface. + sideTop = ModernControlColorMath.GetFieldStrokeDefault(surface, dark); + bottom = ModernControlColorMath.GetFieldStrokeStrong(surface, dark); + break; + } + + return new ModernFieldStroke(sideTop, bottom, surface, BaseStrokeDip, bottomDip); + } + + private static ModernFieldStroke GetHighContrastStroke(ModernFieldStrokeState state) + { + if (state == ModernFieldStrokeState.Disabled) + { + Color grayText = SystemColors.GrayText; + return new ModernFieldStroke(grayText, grayText, SystemColors.Control, BaseStrokeDip, BaseStrokeDip); + } + + Color frame = SystemColors.WindowFrame; + bool focused = state == ModernFieldStrokeState.Focused; + Color bottom = focused ? SystemColors.Highlight : frame; + float bottomDip = focused ? FocusBottomStrokeDip : BaseStrokeDip; + + return new ModernFieldStroke(frame, bottom, SystemColors.Window, BaseStrokeDip, bottomDip); + } +} From 146343e1e099275f539a814c8112bb3329362bd1 Mon Sep 17 00:00:00 2001 From: "Ricardo Bossan (BEYONDSOFT CONSULTING INC) (from Dev Box)" Date: Fri, 21 Aug 2026 19:10:27 -0300 Subject: [PATCH 04/16] Route TextBoxBase.OnNcPaint through the modern field stroke resolver OnNcPaint now builds a ModernFieldStrokeContext and draws the resolved ModernFieldStroke (per-state side/top color, bottom color, surface, and DIP thicknesses) instead of deriving the border from ForeColor, so text color and border are finally independent. x Focus is expressed by the accent bottom edge alone; the former rounded focus ring is removed. The bottom-edge band is clamped so the accent stays on the bottom and its corners rather than wrapping up the sides. --- .../Forms/Controls/TextBox/TextBoxBase.cs | 70 +++++++++++-------- 1 file changed, 39 insertions(+), 31 deletions(-) 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..8de0a20b320 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 @@ -2663,15 +2663,28 @@ private protected virtual void OnNcPaint(Graphics graphics, HDC windowHdc) 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, + HighContrast: Application.SystemVisualSettings.HighContrastEnabled, + 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); Rectangle bounds = new( x: 0, @@ -2768,34 +2781,29 @@ private protected virtual void OnNcPaint(Graphics graphics, HDC windowHdc) break; } + // Bottom (elevation and focus) edge: follows the rounded corners, clipped to a bottom band, + // drawn with the resolved bottom color and thickness. Replaces the former focus ring so that + // focus is expressed by this bottom edge alone (see #14906). if (BorderStyle == BorderStyle.Fixed3D && canRenderRoundedChrome) { - Color focusColor = GetVisualStylesFocusColor(Application.SystemVisualSettings.HighContrastEnabled); - FocusIndicatorRenderer.DrawRoundedFocusIndicator( - offscreenGraphics, - 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); - } + int band = Math.Min(cornerRadius + bottomThickness + 1, focusBandHeight); + int bandTop = Math.Max(deflatedBounds.Top, deflatedBounds.Bottom - band + 1); + Rectangle bottomClip = Rectangle.FromLTRB( + deflatedBounds.Left, + bandTop, + deflatedBounds.Right + 1, + deflatedBounds.Bottom + 1); + + GraphicsState bottomState = offscreenGraphics.Save(); + offscreenGraphics.SetClip(bottomClip, CombineMode.Replace); + + using GraphicsPath bottomPath = new(); + bottomPath.AddRoundedRectangle(deflatedBounds, new Size(cornerRadius, cornerRadius)); + + using var bottomPen = stroke.BottomColor.GetCachedPenScope(bottomThickness); + offscreenGraphics.DrawPath(bottomPen, bottomPath); + + offscreenGraphics.Restore(bottomState); } Rectangle[] nonClientBands = GetNonClientPaintBands( From d1eb4c8b9fbdbe5810bf5548b69cef6b59ff6285 Mon Sep 17 00:00:00 2001 From: "Ricardo Bossan (BEYONDSOFT CONSULTING INC) (from Dev Box)" Date: Fri, 21 Aug 2026 19:13:45 -0300 Subject: [PATCH 05/16] Add Hover state to the modern editable field stroke Track pointer-over via OnMouseEnter/OnMouseLeave under the Net11 guard, repainting the non-client frame through InvalidateVisualStylesFrame (the same path focus uses), and feed the hovered flag into the stroke context so the resolver applies the Hover treatment. --- .../Forms/Controls/TextBox/TextBoxBase.cs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) 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 8de0a20b320..0b682aaf3e6 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 @@ -89,6 +89,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. @@ -1843,6 +1846,28 @@ protected override unsafe void OnLostFocus(EventArgs e) 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) From 3c2d01db955d83782394f2ee39e9d41c583d7e93 Mon Sep 17 00:00:00 2001 From: "Ricardo Bossan (BEYONDSOFT CONSULTING INC) (from Dev Box)" Date: Fri, 21 Aug 2026 21:43:53 -0300 Subject: [PATCH 06/16] Strengthen the Hover stroke and add a Hover surface tint --- .../Forms/Rendering/ModernControlColorMath.cs | 18 +++++++++++++++++- .../Rendering/ModernFieldStrokeResolver.cs | 13 ++++++++----- 2 files changed, 25 insertions(+), 6 deletions(-) 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 4d1e788fb85..7dc02a6855b 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernControlColorMath.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernControlColorMath.cs @@ -25,6 +25,14 @@ internal static class ModernControlColorMath private const int StrokeStrongAlphaLight = 0x72; // ControlStrongStrokeColorDefault 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; + + // Hover surface tint: a fill shift so hover reads clearly, per Leaf's #14906 table. Tunable. + private const int SurfaceHoverAlphaLight = 0x1C; + private const int SurfaceHoverAlphaDark = 0x1C; + // 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 @@ -151,10 +159,18 @@ internal static Color GetDisabledTextColor( 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 (used for Hover). + /// 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 Hover control surface: the background nudged darker (light) or lighter (dark). + internal static Color GetFieldHoverSurface(Color background, bool darkMode) + => CompositeStrokeOverlay(background, darkMode ? SurfaceHoverAlphaDark : SurfaceHoverAlphaLight, 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); diff --git a/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStrokeResolver.cs b/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStrokeResolver.cs index b4bc0be0c95..82a5f90b82d 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStrokeResolver.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStrokeResolver.cs @@ -55,9 +55,12 @@ private static ModernFieldStrokeState ResolveState(in ModernFieldStrokeContext c private static ModernFieldStroke GetThemedStroke(ModernFieldStrokeState state, in ModernFieldStrokeContext context) { bool dark = context.DarkMode; - Color surface = state == ModernFieldStrokeState.Disabled - ? ModernControlColorMath.GetDisabledSurfaceColor() - : context.BackColor; + Color surface = state switch + { + ModernFieldStrokeState.Disabled => ModernControlColorMath.GetDisabledSurfaceColor(), + ModernFieldStrokeState.Hover => ModernControlColorMath.GetFieldHoverSurface(context.BackColor, dark), + _ => context.BackColor, + }; Color sideTop; Color bottom; @@ -66,13 +69,13 @@ private static ModernFieldStroke GetThemedStroke(ModernFieldStrokeState state, i switch (state) { case ModernFieldStrokeState.Focused: - sideTop = ModernControlColorMath.GetFieldStrokeSecondary(surface, dark); + sideTop = ModernControlColorMath.GetFieldStrokeHover(surface, dark); bottom = context.AccentColor; bottomDip = FocusBottomStrokeDip; break; case ModernFieldStrokeState.Hover: - sideTop = ModernControlColorMath.GetFieldStrokeSecondary(surface, dark); + sideTop = ModernControlColorMath.GetFieldStrokeHover(surface, dark); bottom = ModernControlColorMath.GetFieldStrokeStrong(surface, dark); break; From 147ceb2df7442bc80bfe81c8a30d775a4da39699 Mon Sep 17 00:00:00 2001 From: "Ricardo Bossan (BEYONDSOFT CONSULTING INC) (from Dev Box)" Date: Fri, 21 Aug 2026 22:15:19 -0300 Subject: [PATCH 07/16] Adds unit tests --- .../ModernFieldStrokeResolverTests.cs | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 src/test/unit/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStrokeResolverTests.cs 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..e458523a8ab --- /dev/null +++ b/src/test/unit/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStrokeResolverTests.cs @@ -0,0 +1,160 @@ +// 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, + bool highContrast = false) + => new( + BackColor: Color.White, + Enabled: enabled, + ReadOnly: readOnly, + Focused: focused, + Hovered: hovered, + DarkMode: darkMode, + HighContrast: highContrast, + 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.BottomColor.Should().Be(ModernControlColorMath.GetDisabledBorderColor()); + stroke.SurfaceColor.Should().Be(ModernControlColorMath.GetDisabledSurfaceColor()); + } + + [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(4f); + } + + [Fact] + public void ReadOnly_wins_over_hover_and_matches_rest_strokes() + { + 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_tints_the_surface_relative_to_rest() + { + ModernFieldStroke hover = ModernFieldStrokeResolver.GetStroke(Context(hovered: true)); + + hover.SurfaceColor.Should().Be(ModernControlColorMath.GetFieldHoverSurface(Color.White, darkMode: false)); + hover.SurfaceColor.Should().NotBe(Color.White); + } + + // ---- 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_four_dip() + => ModernFieldStrokeResolver.GetStroke(Context(focused: true)).BottomThicknessDip.Should().Be(4f); + + [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(); + + // ---- High Contrast bypass ---- + + [Fact] + public void HighContrast_uses_system_frame_and_window() + { + ModernFieldStroke rest = ModernFieldStrokeResolver.GetStroke(Context(highContrast: true)); + + rest.SideTopColor.Should().Be(SystemColors.WindowFrame); + rest.SurfaceColor.Should().Be(SystemColors.Window); + } + + [Fact] + public void HighContrast_focus_bottom_is_highlight() + => ModernFieldStrokeResolver.GetStroke(Context(focused: true, highContrast: true)) + .BottomColor.Should().Be(SystemColors.Highlight); + + [Fact] + public void HighContrast_disabled_uses_gray_text() + { + ModernFieldStroke disabled = ModernFieldStrokeResolver.GetStroke(Context(enabled: false, highContrast: true)); + + disabled.SideTopColor.Should().Be(SystemColors.GrayText); + disabled.BottomColor.Should().Be(SystemColors.GrayText); + } +} From 49e3706f52d848cadb4a221f55db435312326a76 Mon Sep 17 00:00:00 2001 From: "Ricardo Bossan (BEYONDSOFT CONSULTING INC) (from Dev Box)" Date: Mon, 24 Aug 2026 17:08:16 -0300 Subject: [PATCH 08/16] Add read-only surface tint and stronger disabled bottom to the stroke model --- .../Forms/Rendering/ModernControlColorMath.cs | 18 ++++++++++++++++++ .../Rendering/ModernFieldStrokeResolver.cs | 19 ++++++++++++------- .../ModernFieldStrokeResolverTests.cs | 7 ++++--- 3 files changed, 34 insertions(+), 10 deletions(-) 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 7dc02a6855b..837e6779ed4 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernControlColorMath.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernControlColorMath.cs @@ -33,6 +33,10 @@ internal static class ModernControlColorMath private const int SurfaceHoverAlphaLight = 0x1C; private const int SurfaceHoverAlphaDark = 0x1C; + // 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 @@ -43,6 +47,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. @@ -74,6 +80,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 /// . @@ -171,6 +185,10 @@ internal static Color GetFieldStrokeHover(Color background, bool darkMode) internal static Color GetFieldHoverSurface(Color background, bool darkMode) => CompositeStrokeOverlay(background, darkMode ? SurfaceHoverAlphaDark : SurfaceHoverAlphaLight, 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); diff --git a/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStrokeResolver.cs b/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStrokeResolver.cs index 82a5f90b82d..6e5d59189ea 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStrokeResolver.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStrokeResolver.cs @@ -59,9 +59,14 @@ private static ModernFieldStroke GetThemedStroke(ModernFieldStrokeState state, i { ModernFieldStrokeState.Disabled => ModernControlColorMath.GetDisabledSurfaceColor(), ModernFieldStrokeState.Hover => ModernControlColorMath.GetFieldHoverSurface(context.BackColor, dark), + ModernFieldStrokeState.ReadOnly => ModernControlColorMath.GetFieldReadOnlySurface(context.BackColor, dark), _ => context.BackColor, }; + // Strokes composite over the normal background so ReadOnly keeps the Rest strokes; only Hover + // darkens its strokes along with its surface. + Color strokeBackground = state == ModernFieldStrokeState.Hover ? surface : context.BackColor; + Color sideTop; Color bottom; float bottomDip = BaseStrokeDip; @@ -69,25 +74,25 @@ private static ModernFieldStroke GetThemedStroke(ModernFieldStrokeState state, i switch (state) { case ModernFieldStrokeState.Focused: - sideTop = ModernControlColorMath.GetFieldStrokeHover(surface, dark); + sideTop = ModernControlColorMath.GetFieldStrokeHover(strokeBackground, dark); bottom = context.AccentColor; bottomDip = FocusBottomStrokeDip; break; case ModernFieldStrokeState.Hover: - sideTop = ModernControlColorMath.GetFieldStrokeHover(surface, dark); - bottom = ModernControlColorMath.GetFieldStrokeStrong(surface, dark); + sideTop = ModernControlColorMath.GetFieldStrokeHover(strokeBackground, dark); + bottom = ModernControlColorMath.GetFieldStrokeStrong(strokeBackground, dark); break; case ModernFieldStrokeState.Disabled: sideTop = ModernControlColorMath.GetDisabledBorderColor(); - bottom = ModernControlColorMath.GetDisabledBorderColor(); + bottom = ModernControlColorMath.GetDisabledStrongBorderColor(); break; default: - // Rest and ReadOnly share the resting look; ReadOnly differs only by surface. - sideTop = ModernControlColorMath.GetFieldStrokeDefault(surface, dark); - bottom = ModernControlColorMath.GetFieldStrokeStrong(surface, dark); + // Rest and ReadOnly share the resting strokes; ReadOnly differs only by its surface. + sideTop = ModernControlColorMath.GetFieldStrokeDefault(strokeBackground, dark); + bottom = ModernControlColorMath.GetFieldStrokeStrong(strokeBackground, dark); break; } 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 index e458523a8ab..fa2f77c7da2 100644 --- 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 @@ -35,7 +35,8 @@ public void Disabled_wins_over_everything() ModernFieldStroke stroke = ModernFieldStrokeResolver.GetStroke( Context(enabled: false, focused: true, readOnly: true, hovered: true)); - stroke.BottomColor.Should().Be(ModernControlColorMath.GetDisabledBorderColor()); + stroke.SideTopColor.Should().Be(ModernControlColorMath.GetDisabledBorderColor()); + stroke.BottomColor.Should().Be(ModernControlColorMath.GetDisabledStrongBorderColor()); stroke.SurfaceColor.Should().Be(ModernControlColorMath.GetDisabledSurfaceColor()); } @@ -50,14 +51,14 @@ public void Focused_wins_over_readonly_and_hover() } [Fact] - public void ReadOnly_wins_over_hover_and_matches_rest_strokes() + public void ReadOnly_wins_over_hover_matches_rest_strokes_but_tints_surface() { 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); + readOnly.SurfaceColor.Should().NotBe(rest.SurfaceColor); } [Fact] From 609fd6c6b57e9ff5b529ceb9f23935f1f3c20e43 Mon Sep 17 00:00:00 2001 From: "Ricardo Bossan (BEYONDSOFT CONSULTING INC) (from Dev Box)" Date: Mon, 24 Aug 2026 19:06:58 -0300 Subject: [PATCH 09/16] Raise light-mode resting border contrast --- .../Forms/Controls/TextBox/TextBoxBase.cs | 1 - .../Forms/Rendering/ModernControlColorMath.cs | 2 +- .../Rendering/ModernFieldStrokeContext.cs | 2 -- .../Rendering/ModernFieldStrokeResolver.cs | 24 ++------------- .../ModernFieldStrokeResolverTests.cs | 29 +------------------ 5 files changed, 4 insertions(+), 54 deletions(-) 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 0b682aaf3e6..ab07dec5bba 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 @@ -2695,7 +2695,6 @@ private protected virtual void OnNcPaint(Graphics graphics, HDC windowHdc) Focused: Focused, Hovered: _hovered, DarkMode: Application.IsDarkModeEnabled, - HighContrast: Application.SystemVisualSettings.HighContrastEnabled, AccentColor: Application.SystemVisualSettings.AccentColor, DeviceDpi: DeviceDpi); ModernFieldStroke stroke = ModernFieldStrokeResolver.GetStroke(strokeContext); 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 837e6779ed4..8f178438586 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernControlColorMath.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernControlColorMath.cs @@ -18,7 +18,7 @@ internal static class ModernControlColorMath // WinUI control-stroke overlay alphas over the black (light mode) / white (dark mode) pole, // verified against Common_themeresources_any.xaml. Composited in linear light (see below). - private const int StrokeDefaultAlphaLight = 0x0F; // ControlStrokeColorDefault + private const int StrokeDefaultAlphaLight = 0x1F; // raised for light-mode prominence per #14906 (WinUI base 0x0F) private const int StrokeDefaultAlphaDark = 0x12; private const int StrokeSecondaryAlphaLight = 0x29; // ControlStrokeColorSecondary private const int StrokeSecondaryAlphaDark = 0x18; diff --git a/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStrokeContext.cs b/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStrokeContext.cs index 5b5d9126147..ff3be23d9b9 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStrokeContext.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStrokeContext.cs @@ -12,7 +12,6 @@ namespace System.Windows.Forms; /// Whether the control has keyboard focus. /// Whether the pointer is over the control. /// Whether dark mode is in effect. -/// Whether High Contrast is in effect. /// The system accent color. /// The control's current device DPI. internal readonly record struct ModernFieldStrokeContext( @@ -22,6 +21,5 @@ internal readonly record struct ModernFieldStrokeContext( bool Focused, bool Hovered, bool DarkMode, - bool HighContrast, 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 index 6e5d59189ea..58e3d9cbee1 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStrokeResolver.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStrokeResolver.cs @@ -1,8 +1,6 @@ -// Licensed to the .NET Foundation under one or more agreements. +// 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; /// @@ -21,9 +19,7 @@ internal static ModernFieldStroke GetStroke(in ModernFieldStrokeContext context) { ModernFieldStrokeState state = ResolveState(context); - return context.HighContrast - ? GetHighContrastStroke(state) - : GetThemedStroke(state, context); + return GetThemedStroke(state, context); } // Precedence: Disabled > Focused > ReadOnly > Hover > Rest. @@ -98,20 +94,4 @@ private static ModernFieldStroke GetThemedStroke(ModernFieldStrokeState state, i return new ModernFieldStroke(sideTop, bottom, surface, BaseStrokeDip, bottomDip); } - - private static ModernFieldStroke GetHighContrastStroke(ModernFieldStrokeState state) - { - if (state == ModernFieldStrokeState.Disabled) - { - Color grayText = SystemColors.GrayText; - return new ModernFieldStroke(grayText, grayText, SystemColors.Control, BaseStrokeDip, BaseStrokeDip); - } - - Color frame = SystemColors.WindowFrame; - bool focused = state == ModernFieldStrokeState.Focused; - Color bottom = focused ? SystemColors.Highlight : frame; - float bottomDip = focused ? FocusBottomStrokeDip : BaseStrokeDip; - - return new ModernFieldStroke(frame, bottom, SystemColors.Window, BaseStrokeDip, bottomDip); - } } 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 index fa2f77c7da2..07165743554 100644 --- 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 @@ -14,8 +14,7 @@ private static ModernFieldStrokeContext Context( bool readOnly = false, bool focused = false, bool hovered = false, - bool darkMode = false, - bool highContrast = false) + bool darkMode = false) => new( BackColor: Color.White, Enabled: enabled, @@ -23,7 +22,6 @@ private static ModernFieldStrokeContext Context( Focused: focused, Hovered: hovered, DarkMode: darkMode, - HighContrast: highContrast, AccentColor: s_accent, DeviceDpi: 96); @@ -133,29 +131,4 @@ public void Dark_mode_lightens_the_stroke_over_a_dark_surface() [Fact] public void ForeColor_cannot_affect_the_stroke_by_construction() => typeof(ModernFieldStrokeContext).GetProperty("ForeColor").Should().BeNull(); - - // ---- High Contrast bypass ---- - - [Fact] - public void HighContrast_uses_system_frame_and_window() - { - ModernFieldStroke rest = ModernFieldStrokeResolver.GetStroke(Context(highContrast: true)); - - rest.SideTopColor.Should().Be(SystemColors.WindowFrame); - rest.SurfaceColor.Should().Be(SystemColors.Window); - } - - [Fact] - public void HighContrast_focus_bottom_is_highlight() - => ModernFieldStrokeResolver.GetStroke(Context(focused: true, highContrast: true)) - .BottomColor.Should().Be(SystemColors.Highlight); - - [Fact] - public void HighContrast_disabled_uses_gray_text() - { - ModernFieldStroke disabled = ModernFieldStrokeResolver.GetStroke(Context(enabled: false, highContrast: true)); - - disabled.SideTopColor.Should().Be(SystemColors.GrayText); - disabled.BottomColor.Should().Be(SystemColors.GrayText); - } } From 9fab2075e1210d30e1769e6daf883ec6e8322df4 Mon Sep 17 00:00:00 2001 From: "Ricardo Bossan (BEYONDSOFT CONSULTING INC) (from Dev Box)" Date: Mon, 24 Aug 2026 19:52:31 -0300 Subject: [PATCH 10/16] Make the light-mode bottom border edge meet WCAG 1.4.11 --- .../Windows/Forms/Rendering/ModernControlColorMath.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 8f178438586..46eb2a7a0d9 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernControlColorMath.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernControlColorMath.cs @@ -17,12 +17,13 @@ internal static class ModernControlColorMath 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. Composited in linear light (see below). - private const int StrokeDefaultAlphaLight = 0x1F; // raised for light-mode prominence per #14906 (WinUI base 0x0F) + // 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 = 0x12; private const int StrokeSecondaryAlphaLight = 0x29; // ControlStrokeColorSecondary private const int StrokeSecondaryAlphaDark = 0x18; - private const int StrokeStrongAlphaLight = 0x72; // ControlStrongStrokeColorDefault + private const int StrokeStrongAlphaLight = 0xD1; // resting bottom edge; ~4.5:1 for WCAG 1.4.11 + elevation (#14906). WinUI base 0x72 = 1.74:1. Value pending design. private const int StrokeStrongAlphaDark = 0x8B; // Hover overlay: tuned a step stronger than Secondary for a more noticeable cue (#14906 direction). From 041acc5cb462d7dd4d0d67628e1714b51874c39c Mon Sep 17 00:00:00 2001 From: "Ricardo Bossan (BEYONDSOFT CONSULTING INC) (from Dev Box)" Date: Wed, 26 Aug 2026 17:33:35 -0300 Subject: [PATCH 11/16] Straight bottom edge, flat-case focus fallback, drop animator --- .../Forms/Controls/TextBox/TextBoxBase.cs | 81 +++++-------------- .../System.Windows.Forms/TextBoxBaseTests.cs | 65 --------------- 2 files changed, 21 insertions(+), 125 deletions(-) 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 ab07dec5bba..0090ac50d93 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); @@ -366,7 +364,6 @@ public BorderStyle BorderStyle SourceGenerated.EnumValidator.Validate(value); _borderStyle = value; - _focusIndicatorRenderer?.Synchronize(Focused, invalidate: false); CommonProperties.xClearPreferredSizeCache(this); if (EffectiveVisualStylesMode >= VisualStylesMode.Net11) @@ -967,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, @@ -1698,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. @@ -1714,7 +1700,6 @@ protected override void OnVisualStylesModeChanged(EventArgs e) _triggerNewClientSizeRequest = false; base.OnVisualStylesModeChanged(e); AdjustHeight(false); - _focusIndicatorRenderer?.Synchronize(Focused, invalidate: false); RecalculateVisualStylesClientArea(); } @@ -1812,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); @@ -1831,16 +1807,7 @@ 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); @@ -2686,7 +2653,6 @@ 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(); ModernFieldStrokeContext strokeContext = new( BackColor: BackColor, @@ -2805,29 +2771,27 @@ private protected virtual void OnNcPaint(Graphics graphics, HDC windowHdc) break; } - // Bottom (elevation and focus) edge: follows the rounded corners, clipped to a bottom band, - // drawn with the resolved bottom color and thickness. Replaces the former focus ring so that - // focus is expressed by this bottom edge alone (see #14906). - if (BorderStyle == BorderStyle.Fixed3D && canRenderRoundedChrome) + // Bottom (elevation and focus) edge: a straight horizontal segment near the bottom, kept flat so + // it does not ride up the corner arcs the way a clipped rounded path would. It runs a little past + // the tangent points toward the corners so it is not stubby against the wide corner radius. + // Rounded Fixed3D draws it in every state; flat styles draw it only while focused, so those + // controls keep a focus indicator instead of losing it entirely (see #14906). + bool roundedChrome = BorderStyle == BorderStyle.Fixed3D && canRenderRoundedChrome; + if (roundedChrome || Focused) { - int band = Math.Min(cornerRadius + bottomThickness + 1, focusBandHeight); - int bandTop = Math.Max(deflatedBounds.Top, deflatedBounds.Bottom - band + 1); - Rectangle bottomClip = Rectangle.FromLTRB( - deflatedBounds.Left, - bandTop, - deflatedBounds.Right + 1, - deflatedBounds.Bottom + 1); + // Half the corner radius extends the underline toward the corners while the thick pen still + // meets the arc; going much closer would let the flat end poke below the rounded corner. + int inset = roundedChrome ? cornerRadius / 2 : 0; + int bottomLeft = deflatedBounds.Left + inset; + int bottomRight = deflatedBounds.Right - inset; - GraphicsState bottomState = offscreenGraphics.Save(); - offscreenGraphics.SetClip(bottomClip, CombineMode.Replace); - - using GraphicsPath bottomPath = new(); - bottomPath.AddRoundedRectangle(deflatedBounds, new Size(cornerRadius, cornerRadius)); - - using var bottomPen = stroke.BottomColor.GetCachedPenScope(bottomThickness); - offscreenGraphics.DrawPath(bottomPen, bottomPath); - - offscreenGraphics.Restore(bottomState); + if (bottomRight > bottomLeft) + { + // Same y the rounded body used for its bottom edge, so a thicker focus stroke grows about + // the same line. cornerRadius and bottomThickness are already DPI-scaled. + using var bottomPen = stroke.BottomColor.GetCachedPenScope(bottomThickness); + offscreenGraphics.DrawLine(bottomPen, bottomLeft, deflatedBounds.Bottom, bottomRight, deflatedBounds.Bottom); + } } Rectangle[] nonClientBands = GetNonClientPaintBands( @@ -2995,9 +2959,6 @@ private Rectangle GetNativeClientRectangle() clientRect.Height); } - private AnimatedFocusIndicatorRenderer FocusIndicatorRenderer - => _focusIndicatorRenderer ??= new(this, InvalidateVisualStylesFrame); - private unsafe void InvalidateVisualStylesFrame() { if (!IsHandleCreated) 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() { From e28a260f3ffb1726c65a8aa78ac6cb986a81c693 Mon Sep 17 00:00:00 2001 From: "Ricardo Bossan (BEYONDSOFT CONSULTING INC) (from Dev Box)" Date: Thu, 27 Aug 2026 21:26:29 -0300 Subject: [PATCH 12/16] Revert back to full bottom border --- .../Forms/Controls/TextBox/TextBoxBase.cs | 56 ++++++++++++------- 1 file changed, 37 insertions(+), 19 deletions(-) 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 0090ac50d93..205f6b3d9d2 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 @@ -964,6 +964,15 @@ 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, @@ -2653,6 +2662,7 @@ 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(); ModernFieldStrokeContext strokeContext = new( BackColor: BackColor, @@ -2771,27 +2781,35 @@ private protected virtual void OnNcPaint(Graphics graphics, HDC windowHdc) break; } - // Bottom (elevation and focus) edge: a straight horizontal segment near the bottom, kept flat so - // it does not ride up the corner arcs the way a clipped rounded path would. It runs a little past - // the tangent points toward the corners so it is not stubby against the wide corner radius. - // Rounded Fixed3D draws it in every state; flat styles draw it only while focused, so those - // controls keep a focus indicator instead of losing it entirely (see #14906). - bool roundedChrome = BorderStyle == BorderStyle.Fixed3D && canRenderRoundedChrome; - if (roundedChrome || Focused) + // Bottom (elevation and focus) edge. Rounded Fixed3D highlights the rounded corners along with + // the bottom, clipped to a bottom band (see #14906). Flat styles have no rounded corners, so they + // draw a straight focus underline while focused, keeping a focus indicator instead of losing it. + if (BorderStyle == BorderStyle.Fixed3D && canRenderRoundedChrome) { - // Half the corner radius extends the underline toward the corners while the thick pen still - // meets the arc; going much closer would let the flat end poke below the rounded corner. - int inset = roundedChrome ? cornerRadius / 2 : 0; - int bottomLeft = deflatedBounds.Left + inset; - int bottomRight = deflatedBounds.Right - inset; + int band = Math.Min(cornerRadius + bottomThickness + 1, focusBandHeight); + int bandTop = Math.Max(deflatedBounds.Top, deflatedBounds.Bottom - band + 1); + Rectangle bottomClip = Rectangle.FromLTRB( + deflatedBounds.Left, + bandTop, + deflatedBounds.Right + 1, + deflatedBounds.Bottom + 1); - if (bottomRight > bottomLeft) - { - // Same y the rounded body used for its bottom edge, so a thicker focus stroke grows about - // the same line. cornerRadius and bottomThickness are already DPI-scaled. - using var bottomPen = stroke.BottomColor.GetCachedPenScope(bottomThickness); - offscreenGraphics.DrawLine(bottomPen, bottomLeft, deflatedBounds.Bottom, bottomRight, deflatedBounds.Bottom); - } + GraphicsState bottomState = offscreenGraphics.Save(); + offscreenGraphics.SetClip(bottomClip, CombineMode.Replace); + + using GraphicsPath bottomPath = new(); + bottomPath.AddRoundedRectangle(deflatedBounds, new Size(cornerRadius, cornerRadius)); + + using var bottomPen = stroke.BottomColor.GetCachedPenScope(bottomThickness); + offscreenGraphics.DrawPath(bottomPen, bottomPath); + + offscreenGraphics.Restore(bottomState); + } + else if (Focused) + { + // Flat styles keep a straight focus underline across the full width. + using var bottomPen = stroke.BottomColor.GetCachedPenScope(bottomThickness); + offscreenGraphics.DrawLine(bottomPen, deflatedBounds.Left, deflatedBounds.Bottom, deflatedBounds.Right, deflatedBounds.Bottom); } Rectangle[] nonClientBands = GetNonClientPaintBands( From f9f6bee070278c47dfa9fbe00c0fb2b961dce8c7 Mon Sep 17 00:00:00 2001 From: "Ricardo Bossan (BEYONDSOFT CONSULTING INC) (from Dev Box)" Date: Fri, 28 Aug 2026 17:43:47 -0300 Subject: [PATCH 13/16] Handles testing team feedback and extends bottom-border --- .../Forms/Controls/TextBox/TextBoxBase.cs | 107 +++++++++++++++--- .../Forms/Rendering/ModernControlColorMath.cs | 10 +- .../Forms/Rendering/ModernFieldStroke.cs | 4 +- .../Rendering/ModernFieldStrokeResolver.cs | 15 ++- .../ModernFieldStrokeResolverTests.cs | 7 +- 5 files changed, 107 insertions(+), 36 deletions(-) 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 205f6b3d9d2..91018aeb9f1 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 @@ -2703,6 +2703,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); @@ -2781,31 +2782,55 @@ private protected virtual void OnNcPaint(Graphics graphics, HDC windowHdc) break; } - // Bottom (elevation and focus) edge. Rounded Fixed3D highlights the rounded corners along with - // the bottom, clipped to a bottom band (see #14906). Flat styles have no rounded corners, so they - // draw a straight focus underline while focused, keeping a focus indicator instead of losing it. + // Bottom (elevation and focus) edge. The rounded focus indicator grows out of the bottom border + // as a tapered fill, leaving the left, top, and right of the rounded frame untouched, so the + // corners do not become heavy (#14997). Non-focus states keep a resting bottom edge clipped to a + // band; flat styles draw a straight focus underline (see #14906). if (BorderStyle == BorderStyle.Fixed3D && canRenderRoundedChrome) { - int band = Math.Min(cornerRadius + bottomThickness + 1, focusBandHeight); - int bandTop = Math.Max(deflatedBounds.Top, deflatedBounds.Bottom - band + 1); - Rectangle bottomClip = Rectangle.FromLTRB( - deflatedBounds.Left, - bandTop, - deflatedBounds.Right + 1, - deflatedBounds.Bottom + 1); + if (stroke.HasFocusIndicator) + { + using GraphicsPath focusIndicatorPath = CreateVisualStylesFocusIndicatorPath( + deflatedBounds, + cornerRadius, + bottomThickness); + using var focusIndicatorBrush = stroke.BottomColor.GetCachedSolidBrushScope(); + + // The indicator overlays the bottom edge, including the scrollbar corner. It must not + // inherit the client or scrollbar exclusion used to preserve their native rendering. + GraphicsState focusIndicatorState = offscreenGraphics.Save(); + offscreenGraphics.SetClip(bounds, CombineMode.Replace); + offscreenGraphics.FillPath(focusIndicatorBrush, focusIndicatorPath); + offscreenGraphics.Restore(focusIndicatorState); + focusIndicatorBand = Rectangle.FromLTRB( + bounds.Left, + Math.Max(bounds.Top, deflatedBounds.Bottom - bottomThickness / 2), + bounds.Right, + bounds.Bottom); + } + else + { + int band = Math.Min(cornerRadius + bottomThickness + 1, focusBandHeight); + int bandTop = Math.Max(deflatedBounds.Top, deflatedBounds.Bottom - band + 1); + Rectangle bottomClip = Rectangle.FromLTRB( + deflatedBounds.Left, + bandTop, + deflatedBounds.Right + 1, + deflatedBounds.Bottom + 1); - GraphicsState bottomState = offscreenGraphics.Save(); - offscreenGraphics.SetClip(bottomClip, CombineMode.Replace); + GraphicsState bottomState = offscreenGraphics.Save(); + offscreenGraphics.SetClip(bottomClip, CombineMode.Replace); - using GraphicsPath bottomPath = new(); - bottomPath.AddRoundedRectangle(deflatedBounds, new Size(cornerRadius, cornerRadius)); + using GraphicsPath bottomPath = new(); + bottomPath.AddRoundedRectangle(deflatedBounds, new Size(cornerRadius, cornerRadius)); - using var bottomPen = stroke.BottomColor.GetCachedPenScope(bottomThickness); - offscreenGraphics.DrawPath(bottomPen, bottomPath); + using var bottomPen = stroke.BottomColor.GetCachedPenScope(bottomThickness); + offscreenGraphics.DrawPath(bottomPen, bottomPath); - offscreenGraphics.Restore(bottomState); + offscreenGraphics.Restore(bottomState); + } } - else if (Focused) + else if (stroke.HasFocusIndicator) { // Flat styles keep a straight focus underline across the full width. using var bottomPen = stroke.BottomColor.GetCachedPenScope(bottomThickness); @@ -2836,6 +2861,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 { @@ -2843,6 +2882,38 @@ private protected virtual void OnNcPaint(Graphics graphics, HDC windowHdc) } } + internal static GraphicsPath CreateVisualStylesFocusIndicatorPath( + 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, []); 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 46eb2a7a0d9..206c27b16c7 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernControlColorMath.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernControlColorMath.cs @@ -23,17 +23,13 @@ internal static class ModernControlColorMath private const int StrokeDefaultAlphaDark = 0x12; private const int StrokeSecondaryAlphaLight = 0x29; // ControlStrokeColorSecondary private const int StrokeSecondaryAlphaDark = 0x18; - private const int StrokeStrongAlphaLight = 0xD1; // resting bottom edge; ~4.5:1 for WCAG 1.4.11 + elevation (#14906). WinUI base 0x72 = 1.74:1. Value pending design. + 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; - // Hover surface tint: a fill shift so hover reads clearly, per Leaf's #14906 table. Tunable. - private const int SurfaceHoverAlphaLight = 0x1C; - private const int SurfaceHoverAlphaDark = 0x1C; - // 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; @@ -182,10 +178,6 @@ internal static Color GetFieldStrokeSecondary(Color background, bool darkMode) internal static Color GetFieldStrokeHover(Color background, bool darkMode) => CompositeStrokeOverlay(background, darkMode ? StrokeHoverAlphaDark : StrokeHoverAlphaLight, darkMode); - /// Gets the Hover control surface: the background nudged darker (light) or lighter (dark). - internal static Color GetFieldHoverSurface(Color background, bool darkMode) - => CompositeStrokeOverlay(background, darkMode ? SurfaceHoverAlphaDark : SurfaceHoverAlphaLight, 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); diff --git a/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStroke.cs b/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStroke.cs index 8f7f1d38dc2..be53c2bee14 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStroke.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStroke.cs @@ -12,9 +12,11 @@ namespace System.Windows.Forms; /// 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); + float BottomThicknessDip, + bool HasFocusIndicator); diff --git a/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStrokeResolver.cs b/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStrokeResolver.cs index 58e3d9cbee1..ac14851d295 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStrokeResolver.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStrokeResolver.cs @@ -54,14 +54,13 @@ private static ModernFieldStroke GetThemedStroke(ModernFieldStrokeState state, i Color surface = state switch { ModernFieldStrokeState.Disabled => ModernControlColorMath.GetDisabledSurfaceColor(), - ModernFieldStrokeState.Hover => ModernControlColorMath.GetFieldHoverSurface(context.BackColor, dark), ModernFieldStrokeState.ReadOnly => ModernControlColorMath.GetFieldReadOnlySurface(context.BackColor, dark), _ => context.BackColor, }; - // Strokes composite over the normal background so ReadOnly keeps the Rest strokes; only Hover - // darkens its strokes along with its surface. - Color strokeBackground = state == ModernFieldStrokeState.Hover ? surface : context.BackColor; + // Hover keeps the Rest surface so the editable area does not appear to shrink; it reads as Hover + // through its border color alone (#14997). All strokes composite over the normal background. + Color strokeBackground = context.BackColor; Color sideTop; Color bottom; @@ -92,6 +91,12 @@ private static ModernFieldStroke GetThemedStroke(ModernFieldStrokeState state, i break; } - return new ModernFieldStroke(sideTop, bottom, surface, BaseStrokeDip, bottomDip); + return new ModernFieldStroke( + sideTop, + bottom, + surface, + BaseStrokeDip, + bottomDip, + HasFocusIndicator: state == ModernFieldStrokeState.Focused); } } 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 index 07165743554..c5fa63c26f2 100644 --- 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 @@ -60,12 +60,13 @@ public void ReadOnly_wins_over_hover_matches_rest_strokes_but_tints_surface() } [Fact] - public void Hover_tints_the_surface_relative_to_rest() + 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(ModernControlColorMath.GetFieldHoverSurface(Color.White, darkMode: false)); - hover.SurfaceColor.Should().NotBe(Color.White); + hover.SurfaceColor.Should().Be(rest.SurfaceColor); + hover.SideTopColor.Should().NotBe(rest.SideTopColor); } // ---- Thicknesses (in DIPs) ---- From 274f799d996ddb1277996af95d83a23dc8aa621c Mon Sep 17 00:00:00 2001 From: "Ricardo Bossan (BEYONDSOFT CONSULTING INC) (from Dev Box)" Date: Tue, 1 Sep 2026 20:44:27 -0300 Subject: [PATCH 14/16] Handle test feedbacks --- .../Forms/Controls/TextBox/TextBoxBase.cs | 91 ++++++------------- .../Rendering/ModernFieldStrokeResolver.cs | 15 +-- .../ModernFieldStrokeResolverTests.cs | 12 +-- 3 files changed, 40 insertions(+), 78 deletions(-) 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 91018aeb9f1..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 @@ -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, @@ -2662,7 +2653,6 @@ 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(); ModernFieldStrokeContext strokeContext = new( BackColor: BackColor, @@ -2685,6 +2675,7 @@ private protected virtual void OnNcPaint(Graphics graphics, HDC windowHdc) using var clientBackgroundBrush = clientBackColor.GetCachedSolidBrushScope(); using var adornerBrush = adornerColor.GetCachedSolidBrushScope(); using var adornerPen = adornerColor.GetCachedPenScope(sideThickness); + using var flatBorderPen = stroke.BottomColor.GetCachedPenScope(sideThickness); Rectangle bounds = new( x: 0, @@ -2751,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: @@ -2776,63 +2767,39 @@ 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. The rounded focus indicator grows out of the bottom border - // as a tapered fill, leaving the left, top, and right of the rounded frame untouched, so the - // corners do not become heavy (#14997). Non-focus states keep a resting bottom edge clipped to a - // band; flat styles draw a straight focus underline (see #14906). + // 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) { - if (stroke.HasFocusIndicator) - { - using GraphicsPath focusIndicatorPath = CreateVisualStylesFocusIndicatorPath( - deflatedBounds, - cornerRadius, - bottomThickness); - using var focusIndicatorBrush = stroke.BottomColor.GetCachedSolidBrushScope(); - - // The indicator overlays the bottom edge, including the scrollbar corner. It must not - // inherit the client or scrollbar exclusion used to preserve their native rendering. - GraphicsState focusIndicatorState = offscreenGraphics.Save(); - offscreenGraphics.SetClip(bounds, CombineMode.Replace); - offscreenGraphics.FillPath(focusIndicatorBrush, focusIndicatorPath); - offscreenGraphics.Restore(focusIndicatorState); - focusIndicatorBand = Rectangle.FromLTRB( - bounds.Left, - Math.Max(bounds.Top, deflatedBounds.Bottom - bottomThickness / 2), - bounds.Right, - bounds.Bottom); - } - else - { - int band = Math.Min(cornerRadius + bottomThickness + 1, focusBandHeight); - int bandTop = Math.Max(deflatedBounds.Top, deflatedBounds.Bottom - band + 1); - Rectangle bottomClip = Rectangle.FromLTRB( - deflatedBounds.Left, - bandTop, - deflatedBounds.Right + 1, - deflatedBounds.Bottom + 1); - - GraphicsState bottomState = offscreenGraphics.Save(); - offscreenGraphics.SetClip(bottomClip, CombineMode.Replace); - - using GraphicsPath bottomPath = new(); - bottomPath.AddRoundedRectangle(deflatedBounds, new Size(cornerRadius, cornerRadius)); - - using var bottomPen = stroke.BottomColor.GetCachedPenScope(bottomThickness); - offscreenGraphics.DrawPath(bottomPen, bottomPath); - - offscreenGraphics.Restore(bottomState); - } - } - else if (stroke.HasFocusIndicator) - { - // Flat styles keep a straight focus underline across the full width. + using GraphicsPath bottomEdgePath = CreateVisualStylesBottomEdgePath( + deflatedBounds, + cornerRadius, + 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); } @@ -2882,7 +2849,7 @@ private protected virtual void OnNcPaint(Graphics graphics, HDC windowHdc) } } - internal static GraphicsPath CreateVisualStylesFocusIndicatorPath( + internal static GraphicsPath CreateVisualStylesBottomEdgePath( Rectangle bounds, int cornerSize, int indicatorThickness) diff --git a/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStrokeResolver.cs b/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStrokeResolver.cs index ac14851d295..f163b916cfe 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStrokeResolver.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStrokeResolver.cs @@ -12,7 +12,7 @@ namespace System.Windows.Forms; internal static class ModernFieldStrokeResolver { private const float BaseStrokeDip = 2f; - private const float FocusBottomStrokeDip = 4f; + private const float FocusBottomStrokeDip = 3f; /// Resolves the completed stroke for the given context. internal static ModernFieldStroke GetStroke(in ModernFieldStrokeContext context) @@ -51,15 +51,10 @@ private static ModernFieldStrokeState ResolveState(in ModernFieldStrokeContext c private static ModernFieldStroke GetThemedStroke(ModernFieldStrokeState state, in ModernFieldStrokeContext context) { bool dark = context.DarkMode; - Color surface = state switch - { - ModernFieldStrokeState.Disabled => ModernControlColorMath.GetDisabledSurfaceColor(), - ModernFieldStrokeState.ReadOnly => ModernControlColorMath.GetFieldReadOnlySurface(context.BackColor, dark), - _ => context.BackColor, - }; - - // Hover keeps the Rest surface so the editable area does not appear to shrink; it reads as Hover - // through its border color alone (#14997). All strokes composite over the normal background. + // 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; 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 index c5fa63c26f2..8ca7ad0113b 100644 --- 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 @@ -35,7 +35,7 @@ public void Disabled_wins_over_everything() stroke.SideTopColor.Should().Be(ModernControlColorMath.GetDisabledBorderColor()); stroke.BottomColor.Should().Be(ModernControlColorMath.GetDisabledStrongBorderColor()); - stroke.SurfaceColor.Should().Be(ModernControlColorMath.GetDisabledSurfaceColor()); + stroke.SurfaceColor.Should().Be(Color.White); } [Fact] @@ -45,18 +45,18 @@ public void Focused_wins_over_readonly_and_hover() Context(focused: true, readOnly: true, hovered: true)); stroke.BottomColor.Should().Be(s_accent); - stroke.BottomThicknessDip.Should().Be(4f); + stroke.BottomThicknessDip.Should().Be(3f); } [Fact] - public void ReadOnly_wins_over_hover_matches_rest_strokes_but_tints_surface() + 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().NotBe(rest.SurfaceColor); + readOnly.SurfaceColor.Should().Be(rest.SurfaceColor); } [Fact] @@ -81,8 +81,8 @@ public void Rest_uses_two_dip_side_and_bottom() } [Fact] - public void Focus_bottom_is_four_dip() - => ModernFieldStrokeResolver.GetStroke(Context(focused: true)).BottomThicknessDip.Should().Be(4f); + public void Focus_bottom_is_three_dip() + => ModernFieldStrokeResolver.GetStroke(Context(focused: true)).BottomThicknessDip.Should().Be(3f); [Theory] [InlineData(96)] From cdf30a02096c016d0b50eca9369a712a523c9af9 Mon Sep 17 00:00:00 2001 From: "Ricardo Bossan (BEYONDSOFT CONSULTING INC) (from Dev Box)" Date: Tue, 1 Sep 2026 22:02:19 -0300 Subject: [PATCH 15/16] Handles copilot feedbacks --- .../Windows/Forms/Rendering/ModernFieldStrokeResolver.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStrokeResolver.cs b/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStrokeResolver.cs index f163b916cfe..ca9dc837a93 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStrokeResolver.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStrokeResolver.cs @@ -64,7 +64,9 @@ private static ModernFieldStroke GetThemedStroke(ModernFieldStrokeState state, i switch (state) { case ModernFieldStrokeState.Focused: - sideTop = ModernControlColorMath.GetFieldStrokeHover(strokeBackground, dark); + // 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; From d8f0650883374bb7d4aed7ab4c9a116a53511820 Mon Sep 17 00:00:00 2001 From: "Ricardo Bossan (BEYONDSOFT CONSULTING INC) (from Dev Box)" Date: Tue, 1 Sep 2026 23:26:08 -0300 Subject: [PATCH 16/16] Makes dark mode border nearly invisible for parity with light more current style --- .../System/Windows/Forms/Rendering/ModernControlColorMath.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 206c27b16c7..6b2de223266 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernControlColorMath.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernControlColorMath.cs @@ -20,7 +20,7 @@ internal static class ModernControlColorMath // 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 = 0x12; + 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).