Fix issue 14198: [Dark Mode] Improve visual contrast of the Group Headers in ListView control in dark#14406
Fix issue 14198: [Dark Mode] Improve visual contrast of the Group Headers in ListView control in dark#14406SimonZhao888 wants to merge 6 commits into
Conversation
…ders in ListView control in dark
There was a problem hiding this comment.
Pull request overview
This PR targets WinForms ListView dark mode rendering by improving the visual contrast of group headers (and related group text) when groups are enabled.
Changes:
- Adds custom dark-mode rendering logic for group header text, chevron, subtitle, and footer.
- Adjusts custom-draw flags and text color handling to receive group-related draw notifications and control group text colors.
- Updates the applied dark-mode theme identifier for the ListView/ColumnHeader to use the ItemsView theme.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Improves ListView group header readability in dark mode by adjusting theming/custom-draw behavior and adding an overlay render pass to redraw group header/subtitle/footer with higher-contrast styling.
Changes:
- Add dark-mode overlay drawing for group header/subtitle/footer and a custom chevron/underline rendering path.
- Adjust ListView custom draw flags/colors so group custom-draw notifications are raised and native group text is suppressed in dark mode.
- Switch the applied dark-mode theme identifier to
DarkMode_ItemsViewand attempt to configure group text colors viaLVM_SETGROUPMETRICS.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
Please consider Copilot's suggestion and complete the PR description |
I'm trying to add a selected overlay to a Group Item in Dark Mode. |
|
/azp run |
|
Azure Pipelines successfully started running 1 pipeline(s). |
| int centerX = headerRect.Right - 9; | ||
| int centerY = headerTextRect.Top + (headerTextRect.Height / 2) + 1; | ||
|
|
||
| Rectangle nativeChevronRect = new( | ||
| headerRect.Right - 28, | ||
| headerRect.Top, | ||
| 28, | ||
| headerRect.Height); | ||
| using (Brush backgroundBrush = new SolidBrush(backgroundColor)) | ||
| { | ||
| g.FillRectangle(backgroundBrush, nativeChevronRect); | ||
| } | ||
|
|
||
| Point[] points = collapsed | ||
| ? [new Point(centerX - 3, centerY - 5), new Point(centerX + 2, centerY), new Point(centerX - 3, centerY + 5)] | ||
| : [new Point(centerX - 5, centerY - 2), new Point(centerX, centerY + 3), new Point(centerX + 5, centerY - 2)]; | ||
|
|
||
| using Pen chevronPen = new(chevronColor, 2.6f) | ||
| { | ||
| StartCap = Drawing.Drawing2D.LineCap.Round, | ||
| EndCap = Drawing.Drawing2D.LineCap.Round, | ||
| LineJoin = Drawing.Drawing2D.LineJoin.Round | ||
| }; | ||
|
|
||
| g.DrawLines(chevronPen, points); |
| COLORREF textColor = (COLORREF)ColorTranslator.ToWin32(ForeColor); | ||
| COLORREF headerColor = (COLORREF)ColorTranslator.ToWin32(BackColor); | ||
| LVGROUPMETRICS groupMetrics = new() | ||
| { | ||
| cbSize = (uint)sizeof(LVGROUPMETRICS), | ||
| mask = LVGMF_TEXTCOLOR, | ||
| crHeader = headerColor, | ||
| crFooter = textColor | ||
| }; | ||
|
|
||
| PInvokeCore.SendMessage(this, LVM_SETGROUPMETRICS, (WPARAM)0, ref groupMetrics); |
| ? Color.FromArgb(64, 90, 150, 255) | ||
| : (isHovered ? Color.FromArgb(28, 255, 255, 255) : Color.Empty); |
| int interactionBottom = headerRect.Bottom; | ||
| if (!string.IsNullOrEmpty(group.Subtitle)) | ||
| { | ||
| interactionBottom = headerRect.Top + Font.Height + verticalSpacing + Font.Height + headerVerticalPadding; | ||
| } | ||
|
|
||
| if (!string.IsNullOrEmpty(group.Footer) && (collapsed || group.Items.Count == 0)) | ||
| { | ||
| int subtitleOffset = string.IsNullOrEmpty(group.Subtitle) ? 1 : 2; | ||
| int collapsedFooterY = headerRect.Top + (Font.Height * subtitleOffset) + footerCollapsedAdditionalOffset; | ||
| interactionBottom = Math.Min(interactionBottom, Math.Max(headerRect.Top, collapsedFooterY - LogicalToDeviceUnits(1))); | ||
| } | ||
|
|
||
| Rectangle interactionRect = Rectangle.Intersect( | ||
| groupRect, | ||
| new Rectangle( | ||
| groupRect.Left, | ||
| headerRect.Top, | ||
| groupRect.Width, | ||
| Math.Max(0, interactionBottom - headerRect.Top))); | ||
|
|
| int oldHoveredGroupId = _darkModeHoveredGroupId; | ||
| int hoveredGroupId = GetDarkModeGroupHeaderHitId(); | ||
| if (_darkModeHoveredGroupId != hoveredGroupId) | ||
| { | ||
| _darkModeHoveredGroupId = hoveredGroupId; | ||
| InvalidateDarkModeGroupHeader(oldHoveredGroupId); | ||
| InvalidateDarkModeGroupHeader(_darkModeHoveredGroupId); | ||
| } |
| // Suppress native hover processing in dark mode group overlay mode. | ||
| // Native hover invalidates group headers while mouse moves over grouped content, | ||
| // causing repaint jitter. | ||
| LVHITTESTINFO lvhi = SetupHitTestInfo(); | ||
| bool isMouseOverItem = (int)PInvokeCore.SendMessage(this, PInvoke.LVM_HITTEST, (WPARAM)0, ref lvhi) >= 0; | ||
| bool allowNativeHover = isMouseOverItem || HoverSelection || HotTracking; | ||
|
|
||
| if (!allowNativeHover) | ||
| { | ||
| Capture = false; | ||
| return; | ||
| } |
| public COLORREF crHeader; | ||
| public COLORREF crFooter; | ||
| } | ||
|
|
There was a problem hiding this comment.
Hardcoded colors are not theme-aware
Color.FromArgb(120, 180, 255) (header), Color.FromArgb(80, 170, 255) (chevron), Color.FromArgb(64, 90, 150, 255) (selected overlay), and Color.FromArgb(28, 255, 255, 255) (hover overlay) are all hardcoded magic values. These should at minimum be extracted as named static readonly constants for maintainability. Ideally they should come from system/theme colors or be configurable — if the system dark mode palette changes in a future Windows version, these will look wrong.
| HWND header = (HWND)PInvokeCore.SendMessage(this, PInvoke.LVM_GETHEADER); | ||
| if (!header.IsNull) | ||
| { | ||
| PInvokeCore.GetWindowRect(header, out RECT headerRect); |
There was a problem hiding this comment.
Chevron drawing is not DPI-aware
In this method, several pixel values are not scaled via LogicalToDeviceUnits():
centerX = headerRect.Right - 9andcenterYoffset+1- The native chevron cover rect uses a hardcoded width of
28 - Chevron point offsets (3, 5, 2) and pen width (
2.6f) are raw pixel values dividerTextSpacing = 6is unscaled
The parent method (DrawDarkModeGroupSubtitleAndFooterOverlay) correctly uses LogicalToDeviceUnits() for padding/spacing. On high-DPI displays (200%+), the chevron will appear undersized and mispositioned relative to the DPI-scaled header text.
| { | ||
| return background; | ||
| } | ||
|
|
There was a problem hiding this comment.
Duplicated layout logic between drawing and hit-testing
The interaction rect computation here (headerVerticalPadding, verticalSpacing, footerCollapsedAdditionalOffset, interactionBottom calculation) is duplicated nearly identically in TryGetDarkModeGroupInteractionRect further below. If any layout constant or formula changes, both locations must be updated in sync — this is fragile. Consider extracting a shared helper that computes the layout metrics once.
| { | ||
| Capture = false; | ||
| return; | ||
| } |
There was a problem hiding this comment.
Suppressing base.WndProc for WM_MOUSEMOVE may have side effects
When !allowNativeHover, the code sets Capture = false and returns, completely bypassing base.WndProc(ref m). This means other mouse-move processing (accessibility notifications, tooltip tracking, drag detection) will be skipped. Could this cause subtle regressions for drag-and-drop or accessibility scenarios? It might be safer to only suppress the specific hover invalidation rather than skipping the entire base message processing.
| textColor = nmlvcd->clrTextBk; | ||
| } | ||
| else | ||
| { |
There was a problem hiding this comment.
Using clrTextBk as text color is semantically confusing
textColor = nmlvcd->clrTextBk;This reads the background color field and uses it as the text color. Even if this works due to how the native control populates these fields for group items, it's surprising to readers and fragile if the native behavior changes. A comment explaining why the background color field contains the desired text color value would help future maintainers.
Fixes #14198
Proposed changes
UpdateDarkModeGroupTextColors()to set the group header and footer text colors usingLVM_SETGROUPMETRICS.CDDS_ITEMPREPAINT: In dark mode, returnCDRF_SKIPDEFAULTto skip native group painting, and cacheclrTextBkanduItemStatein a dictionary for use during the overlay phase.Customer Impact
Regression?
Risk
Screenshots
Before
After
2026-05-15.103056.mp4
Test environment(s)