Add a per-state stroke model for Net11 editable-control borders - #14919
Add a per-state stroke model for Net11 editable-control borders#14919ricardobossan wants to merge 16 commits into
Conversation
This comment was marked as outdated.
This comment was marked as outdated.
641ef68 to
7fb6b99
Compare
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.
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.
7fb6b99 to
9fab207
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR updates the Net11 (modern visual styles) non-client rendering for editable text controls (via TextBoxBase) by introducing a per-state stroke model and routing border/focus rendering through a single resolver, including new linear-light compositing for WinUI-style overlays.
Changes:
- Introduces a
ModernFieldStroke*model (State,Context,Stroke) plusModernFieldStrokeResolver.GetStroke()with explicit precedence for Disabled/Focused/ReadOnly/Hover/Rest. - Adds linear-light overlay compositing utilities to
ModernControlColorMathfor modern stroke/surface colors derived from effective background (notForeColor). - Updates
TextBoxBaseNC painting to use the resolved stroke (including hover tracking) and removes the old focus-indicator animation path; adds resolver unit tests.
File summaries
| File | Description |
|---|---|
| src/System.Windows.Forms/System/Windows/Forms/Controls/TextBox/TextBoxBase.cs | Replaces ForeColor-driven/animated focus rendering with resolved per-state stroke + hover tracking; updates NC paint focus/bottom edge logic. |
| src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernControlColorMath.cs | Adds linear-light compositing helpers and new field-stroke/surface color APIs (default/hover/strong/read-only + disabled strong border). |
| src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStroke.cs | Adds resolved stroke record used by paint paths. |
| src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStrokeContext.cs | Adds resolver input context record (enabled/read-only/focused/hovered/dark/accent/etc.). |
| src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStrokeResolver.cs | Adds the single chokepoint resolver to compute strokes from context + precedence rules. |
| src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStrokeState.cs | Adds internal enum describing stroke interaction states. |
| src/test/unit/System.Windows.Forms/System/Windows/Forms/Rendering/ModernFieldStrokeResolverTests.cs | Adds unit tests covering precedence, thickness DIPs, opacity, and color-math invariants. |
| src/test/unit/System.Windows.Forms/TextBoxBaseTests.cs | Removes tests tied to the old AnimatedFocusIndicatorRenderer behavior that no longer applies. |
Review details
Suppressed comments (1)
src/System.Windows.Forms/System/Windows/Forms/Controls/TextBox/TextBoxBase.cs:2789
- PaintRoundedBorderRegionMitigation still uses borderThickness from focus metrics, but the border now being drawn is sideThickness. If these differ, the mitigation can trace too wide/narrow and either leave artifacts or overpaint. Pass sideThickness to keep mitigation aligned with the actual stroke width.
// 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)
- Files reviewed: 8/8 changed files
- Comments generated: 4
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| 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. |
| // 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 |
| int sideThickness = Math.Max(1, (int)MathF.Round(stroke.SideTopThicknessDip * DeviceDpi / 96f)); | ||
| int bottomThickness = Math.Max(1, (int)MathF.Round(stroke.BottomThicknessDip * DeviceDpi / 96f)); |
| int borderThickness = Math.Max(focusBorderMetrics.Width, focusBorderMetrics.Height); | ||
| int focusBandHeight = GetVisualStylesFocusBandHeight(); | ||
|
|
||
| Color clientBackColor = BackColor; | ||
| ModernFieldStrokeContext strokeContext = new( | ||
| BackColor: BackColor, |
|
@ricardobossan, do you feel confident to take this out of draft and get it merged, after Olina tested this? Or is there still work to do form your perspective? |
Fixes #14906
Proposed changes
Replace the flat, ForeColor-driven border and the primitive focus ring on VisualStylesMode.Net11 editable controls (TextBox, MaskedTextBox, RichTextBox, all via TextBoxBase) with a proper per-state stroke model.
ModernFieldStrokeState(Rest, Hover, Focused, Disabled, ReadOnly), aModernFieldStrokerecord (side/top color, bottom color, surface, and DIP thicknesses), and a singleModernFieldStrokeResolver.GetStrokechokepoint with precedence Disabled > Focused > ReadOnly > Hover > Rest. Paint code receives only a completed stroke; all state and color selection stays behind the resolver.ModernControlColorMath. WinUI control-stroke overlays are semi-transparent and must be composited in linear light. The existingPopupButtonColorMath.Blendworks in encoded sRGB and cannot, so this adds the correct compositing. Colors derive from the effective background and are independent ofForeColor.TextBoxBase.OnNcPaintnow draws from the resolved stroke: a light side/top, a stronger darker bottom elevation edge, and a per-state surface. Focus is expressed by an accent bottom edge at 4 DIP (bottom only), which replaces the former rounded focus ring. Hover tracks pointer-over viaOnMouseEnter/OnMouseLeaveand repaints the non-client frame the same way focus does.Scope is TextBoxBase. ComboBox (a WM_PAINT client-area adapter) and UpDown are out of scope and tracked as follow-ups in #14906. Design direction and the per-state values come from the discussion in #14906.
Customer Impact
Under VisualStylesMode.Net11, editable-control borders no longer inherit
ForeColor(issue #14847, fixed here generally for all TextBoxBase controls), and they gain distinct rest, hover, focus, disabled, and read-only treatments consistent with the modern visual style.Regression?
No. The change is gated on Net11; Classic, Disabled, and High Contrast (which opts out of VisualStyles) paint exactly as before.
Risk
Low to medium. It changes the appearance of Net11 editable-control borders (a new feature surface), behind the Net11 gate, and is covered by resolver unit tests.
Test methodology
Known follow-ups (not in this PR)
Screenshots
The Classic-mode control visible in both confirms the legacy (< Net11) path is unchanged. DPI scaling was verified manually (the 2 DIP and 4 DIP strokes scale up proportionally); it is absent from the recordings only because changing the host display scale stopped the screen recorder.