From b66339008f4b6b43c917b231093a9bd86f97be22 Mon Sep 17 00:00:00 2001 From: "Ricardo Bossan (BEYONDSOFT CONSULTING INC) (from Dev Box)" Date: Sun, 15 Mar 2026 22:26:59 -0300 Subject: [PATCH 1/2] Fix CheckBox/RadioButton Appearance.Button + FlatStyle.Standard invisible in dark mode Fixes #14347 `CheckBox` and `RadioButton` with `Appearance.Button + FlatStyle.Standard` intentionally disabled owner-draw in dark mode to work around a VisualStyleRenderer HighDPI issue: ```csharp private protected override bool OwnerDraw => (!Application.IsDarkModeEnabled || Appearance != Appearance.Button || FlatStyle != FlatStyle.Standard) && base.OwnerDraw; ``` With `OwnerDraw = false`, WinForms delegates painting to the native ComCtl32 button (`BS_3STATE | BS_PUSHLIKE`). The native control renders with light-mode colors (or transparent) on a dark form background, making the controls completely invisible. - **`CheckBox.cs` / `RadioButton.cs`**: Remove the dark mode exception from `OwnerDraw`. Both controls now return `base.OwnerDraw` (i.e., `FlatStyle != FlatStyle.System`) unconditionally, restoring owner-draw for `Appearance.Button + FlatStyle.Standard` in dark mode. - **`CheckBoxStandardAdapter.cs` / `RadioButtonStandardAdapter.cs`**: Change `CreateButtonAdapter()` from `new ButtonStandardAdapter(Control)` to `DarkModeAdapterFactory.CreateStandardAdapter(Control)`. In dark mode this returns `ButtonDarkModeAdapter`, which renders using explicit dark mode colors (`#333333` background, `#9B9B9B` border, `#F0F0F0` text) and does not use VisualStyleRenderers, resolving both the visibility issue and the HighDPI concern that motivated the original workaround. `CheckBox` and `RadioButton` controls with `Appearance.Button + FlatStyle.Standard` are now visible and correctly styled in dark mode. Previously they were completely invisible. No. Minimal. Manual. - 11.0.100-preview.3.26161.119 --- .../ButtonInternal/CheckBoxStandardAdapter.cs | 6 +++--- .../ButtonInternal/RadioButtonStandardAdapter.cs | 4 ++-- .../Windows/Forms/Controls/Buttons/CheckBox.cs | 12 +----------- .../Windows/Forms/Controls/Buttons/RadioButton.cs | 13 +------------ 4 files changed, 7 insertions(+), 28 deletions(-) diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/CheckBoxStandardAdapter.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/CheckBoxStandardAdapter.cs index 3a7f7d3b0c8..d846da9c067 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/CheckBoxStandardAdapter.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/CheckBoxStandardAdapter.cs @@ -89,7 +89,7 @@ internal override Size GetPreferredSizeCore(Size proposedSize) { if (Control.Appearance == Appearance.Button) { - ButtonStandardAdapter adapter = new(Control); + ButtonBaseAdapter adapter = DarkModeAdapterFactory.CreateStandardAdapter(Control); return adapter.GetPreferredSizeCore(proposedSize); } else @@ -105,9 +105,9 @@ internal override Size GetPreferredSizeCore(Size proposedSize) } } - private new ButtonStandardAdapter ButtonAdapter => (ButtonStandardAdapter)base.ButtonAdapter; + private new ButtonBaseAdapter ButtonAdapter => base.ButtonAdapter; - protected override ButtonBaseAdapter CreateButtonAdapter() => new ButtonStandardAdapter(Control); + protected override ButtonBaseAdapter CreateButtonAdapter() => DarkModeAdapterFactory.CreateStandardAdapter(Control); protected override LayoutOptions Layout(PaintEventArgs e) { diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/RadioButtonStandardAdapter.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/RadioButtonStandardAdapter.cs index 0181286c263..4a75be1ebf2 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/RadioButtonStandardAdapter.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/RadioButtonStandardAdapter.cs @@ -50,9 +50,9 @@ internal override void PaintOver(PaintEventArgs e, CheckState state) } } - private new ButtonStandardAdapter ButtonAdapter => (ButtonStandardAdapter)base.ButtonAdapter; + private new ButtonBaseAdapter ButtonAdapter => base.ButtonAdapter; - protected override ButtonBaseAdapter CreateButtonAdapter() => new ButtonStandardAdapter(Control); + protected override ButtonBaseAdapter CreateButtonAdapter() => DarkModeAdapterFactory.CreateStandardAdapter(Control); protected override LayoutOptions Layout(PaintEventArgs e) { diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/CheckBox.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/CheckBox.cs index 57526b2644b..e3139311bca 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/CheckBox.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/CheckBox.cs @@ -96,17 +96,7 @@ public Appearance Appearance } } - private protected override bool OwnerDraw => - // We want NO owner draw ONLY when we're - // * In Dark Mode - // * When _then_ the Appearance is Button - // * But then ONLY when we're rendering with FlatStyle.Standard - // (because that would let us usually let us draw with the VisualStyleRenderers, - // which cause HighDPI issues in Dark Mode). - (!Application.IsDarkModeEnabled - || Appearance != Appearance.Button - || FlatStyle != FlatStyle.Standard) - && base.OwnerDraw; + private protected override bool OwnerDraw => base.OwnerDraw; [SRCategory(nameof(SR.CatPropertyChanged))] [SRDescription(nameof(SR.CheckBoxOnAppearanceChangedDescr))] diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/RadioButton.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/RadioButton.cs index a6674f1ea69..f5c190832bc 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/RadioButton.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/RadioButton.cs @@ -173,18 +173,7 @@ public bool Checked } } - private protected override bool OwnerDraw => - // Order is key here - do NOT change! - // We want NO owner draw ONLY when we're - // * in Dark Mode - // * when _then_ the Appearance is Button - // * but then ONLY when we're rendering with FlatStyle.Standard - // (because that would let us usually let us draw with the VisualStyleRenderers, - // which cause HighDPI issues in Dark Mode). - (!Application.IsDarkModeEnabled - || Appearance != Appearance.Button - || FlatStyle != FlatStyle.Standard) - && base.OwnerDraw; + private protected override bool OwnerDraw => base.OwnerDraw; /// [Browsable(false)] From 8ba856950a17eda1d51f5b91ef048b4bd3907dd6 Mon Sep 17 00:00:00 2001 From: "Ricardo Bossan (BEYONDSOFT CONSULTING INC) (from Dev Box)" Date: Thu, 9 Apr 2026 21:24:42 -0300 Subject: [PATCH 2/2] Handle feedback --- .../DarkMode/ButtonDarkModeAdapter.cs | 12 ++++++--- .../System/Windows/Forms/CheckBoxTests.cs | 25 +++++++++++++++++++ .../System/Windows/Forms/RadioButtonTests.cs | 25 +++++++++++++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/ButtonDarkModeAdapter.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/ButtonDarkModeAdapter.cs index a85dd3ed4cc..47775d46960 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/ButtonDarkModeAdapter.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/ButtonDarkModeAdapter.cs @@ -158,22 +158,28 @@ internal override void PaintOver(PaintEventArgs e, CheckState state) g.SmoothingMode = Drawing.Drawing2D.SmoothingMode.AntiAlias; LayoutData layout = CommonLayout().Layout(); + + // A checked toggle-button must remain visually pressed even on hover. + PushButtonState pushButtonState = state == CheckState.Checked + ? PushButtonState.Pressed + : PushButtonState.Hot; + ButtonDarkModeRenderer.RenderButton( g, Control.ClientRectangle, Control.FlatStyle, - PushButtonState.Hot, + pushButtonState, Control.IsDefault, Control.Focused, Control.ShowFocusCues, Control.Parent?.BackColor ?? Control.BackColor, - GetButtonBackColor(PushButtonState.Hot), + GetButtonBackColor(pushButtonState), _ => PaintImage(e, layout), () => PaintField( e, layout, PaintDarkModeRender(e).Calculate(), - GetButtonTextColor(e, PushButtonState.Hot), + GetButtonTextColor(e, pushButtonState), drawFocus: false) ); diff --git a/src/test/unit/System.Windows.Forms/System/Windows/Forms/CheckBoxTests.cs b/src/test/unit/System.Windows.Forms/System/Windows/Forms/CheckBoxTests.cs index 506c893a1d7..12891c5e22f 100644 --- a/src/test/unit/System.Windows.Forms/System/Windows/Forms/CheckBoxTests.cs +++ b/src/test/unit/System.Windows.Forms/System/Windows/Forms/CheckBoxTests.cs @@ -631,6 +631,31 @@ public void CheckBox_ToStringTest() Assert.Equal(expected, actual); } +#pragma warning disable SYSLIB5002 // Type is for evaluation purposes only and is subject to change or removal in future updates. + [WinFormsFact] + public void CheckBox_OwnerDraw_AppearanceButton_FlatStyleStandard_InDarkMode_IsTrue() + { + // Regression test for https://github.com/dotnet/winforms/issues/14347: + // CheckBox with Appearance.Button + FlatStyle.Standard must always use owner-draw, + // including in dark mode. If OwnerDraw returns false the control falls back to + // native ComCtl32 painting which renders an invisible button on dark backgrounds. + Application.SetColorMode(SystemColorMode.Dark); + try + { + using SubCheckBox control = new() + { + Appearance = Appearance.Button, + FlatStyle = FlatStyle.Standard + }; + Assert.True(control.GetStyle(ControlStyles.UserPaint)); + } + finally + { + Application.SetColorMode(SystemColorMode.Classic); + } + } +#pragma warning restore SYSLIB5002 + public class SubCheckBox : CheckBox { public new bool CanEnableIme => base.CanEnableIme; diff --git a/src/test/unit/System.Windows.Forms/System/Windows/Forms/RadioButtonTests.cs b/src/test/unit/System.Windows.Forms/System/Windows/Forms/RadioButtonTests.cs index 08142ccb0cf..ac6f72e0d33 100644 --- a/src/test/unit/System.Windows.Forms/System/Windows/Forms/RadioButtonTests.cs +++ b/src/test/unit/System.Windows.Forms/System/Windows/Forms/RadioButtonTests.cs @@ -1665,5 +1665,30 @@ internal override bool RaiseAutomationPropertyChangedEvent(UIA_PROPERTY_ID prope [InlineData(Appearance.Normal, FlatStyle.System)] public void RadioButton_OverChangeRectangle_Get(Appearance appearance, FlatStyle flatStyle) => base.ButtonBase_OverChangeRectangle_Get(appearance, flatStyle); +#pragma warning disable SYSLIB5002 // Type is for evaluation purposes only and is subject to change or removal in future updates. + [WinFormsFact] + public void RadioButton_OwnerDraw_AppearanceButton_FlatStyleStandard_InDarkMode_IsTrue() + { + // Regression test for https://github.com/dotnet/winforms/issues/14347: + // RadioButton with Appearance.Button + FlatStyle.Standard must always use owner-draw, + // including in dark mode. If OwnerDraw returns false the control falls back to + // native ComCtl32 painting which renders an invisible button on dark backgrounds. + Application.SetColorMode(SystemColorMode.Dark); + try + { + using SubRadioButton control = new() + { + Appearance = Appearance.Button, + FlatStyle = FlatStyle.Standard + }; + Assert.True(control.GetStyle(ControlStyles.UserPaint)); + } + finally + { + Application.SetColorMode(SystemColorMode.Classic); + } + } +#pragma warning restore SYSLIB5002 + protected override ButtonBase CreateButton() => new SubRadioButton(); }